diff --git a/_project/TODO/main/planning/uat-clickhouse-server-loader-type-coverage.yaml b/_project/TODO/main/planning/uat-clickhouse-server-loader-type-coverage.yaml index 8bb08e351a..5ed83da2e6 100644 --- a/_project/TODO/main/planning/uat-clickhouse-server-loader-type-coverage.yaml +++ b/_project/TODO/main/planning/uat-clickhouse-server-loader-type-coverage.yaml @@ -100,9 +100,12 @@ work: Keep server mode on client-side INSERT batches for Docker-host files; do not revert to server-side `file()` loading. Preserve shared ClickHouse SQL behavior while making type conversion explicit for server inserts. - Resolve the nullable-empty-field policy explicitly: prefer preserving - NULL when the benchmark schema permits it, and only use type defaults for - truly non-Nullable columns when that matches the source semantics. + Implement the ratified nullable-empty-field policy (see open_questions + below): an empty numeric/date value becomes NULL with Nullable(T) DDL + where the reference schema marks the column nullable; NEVER coerce to a + type default. An empty landing in a column the reference schema marks + NOT NULL must fail loudly as a benchmark data defect, not be silently + defaulted. DONE (Buckets B + C): _convert_field_for_clickhouse now classifies DATETIME/DateTime64/TIMESTAMP before the DATE prefix and parses to Python @@ -151,6 +154,7 @@ deferred: deps: needs: - uat-clickhouse-server-rc1-recovery + - uat-clickhouse-loader-empty-value-nullability must_preserve: - "Keep the PR #738 server-mode contract: Docker-host data files load through client-side batches, not server-side `file()` paths." diff --git a/_project/TODO/uat-hardening/planning/tpch-throughput-parameter-aware-rowcounts.yaml b/_project/TODO/uat-hardening/planning/tpch-throughput-parameter-aware-rowcounts.yaml index c0643939c1..202b9ea1e0 100644 --- a/_project/TODO/uat-hardening/planning/tpch-throughput-parameter-aware-rowcounts.yaml +++ b/_project/TODO/uat-hardening/planning/tpch-throughput-parameter-aware-rowcounts.yaml @@ -75,7 +75,10 @@ verification: scope_limit: only_modify: - - "tests/uat/throughput.py" + - "benchbox/core/tpch/" + - "benchbox/core/expected_results/" + - "benchbox/core/validation/" + - "tests/unit/core/" - "tests/uat/test_throughput.py" metadata: diff --git a/_project/TODO/uat-hardening/planning/uat-throughput-result-path-manifest.yaml b/_project/TODO/uat-hardening/planning/uat-throughput-result-path-manifest.yaml index eb4957be43..c9de095430 100644 --- a/_project/TODO/uat-hardening/planning/uat-throughput-result-path-manifest.yaml +++ b/_project/TODO/uat-hardening/planning/uat-throughput-result-path-manifest.yaml @@ -53,6 +53,16 @@ work: must_preserve), and delete the mtime-window logic. Keep a loud failure if the path is absent rather than silently falling back to glob. + Handoff: tests/uat/runner.py's `official` branch (currently ~line 241-251) + calls resolve_official_result_path directly and does not pass or parse + `stdout_text` at all -- only the non-official `else` branch does + (last_nonempty_output_line(stdout_text)). If w1 picks the quiet-stdout-line + contract, runner.py's official branch must be updated to read that line the + same way the non-official branch already does; if w1 picks a manifest file, + runner.py must locate/pass it instead. Either way runner.py's official + branch changes, so it (and test_runner.py) must be in scope, not just the + resolver + CLI. + deferred: - summary: "Backfill emitted-path contract to other non-quiet producers if any remain" reason: "Scope here is the official/throughput path; a broader producer audit is separate." @@ -88,6 +98,8 @@ scope_limit: only_modify: - "tests/uat/throughput.py" - "tests/uat/test_throughput.py" + - "tests/uat/runner.py" + - "tests/uat/test_runner.py" - "benchbox/cli/ (run-official output contract)" - "docs/reference/public-contracts.md" diff --git a/_project/config/fast_test_lane_policy.json b/_project/config/fast_test_lane_policy.json index e9d4c7ab71..de4d50f626 100644 --- a/_project/config/fast_test_lane_policy.json +++ b/_project/config/fast_test_lane_policy.json @@ -1,7 +1,7 @@ { "enabled": true, - "max_fast_tests": 25190, - "_ceiling_note": "Bumped 13200 -> 22000 on 2026-04-13 to reflect grown fast-lane (20,184 collected). Bumped 22000 -> 22100 on 2026-05-10 for joinorder-canonical-foundation Phase-1 (data_fetch + scale-factor + surface-field tests; 22038 collected). Bumped 22100 -> 22400 on 2026-05-11 for joinorder-canonical-cutover canonical SQL/DataFrame, MCP, publishing, and corpus-inventory guards; 22317 collected. Bumped 22400 -> 22500 on 2026-05-13 for UAT enabled-platform remediation coverage; pr-preflight collected 22419. Bumped 22500 -> 22510 on 2026-05-15 after rebasing onto develop at 36ab01058; pr-preflight collected 22501 before this branch's new public theme contract tests, which are medium-marked to avoid fast-lane growth. Bumped 22510 -> 22530 on 2026-05-14 for pr-review-followup batch coverage (joinorder dataframe family, pg_mooncake adapter, landing quickstart validation, explorer read-model retry); pr-preflight collected 22520. Bumped 22530 -> 22540 on 2026-05-15 for results-explorer-post-theme-reconcile count-aware Home/script coverage; CI collected 22537. Bumped 22540 -> 22543 on 2026-05-16 for pr-review-followups coverage on UAT CLI dispatch, UAT release-gate envelope scoping, and develop-post-merge orphan ordering; ci-lint collected 22543. Bumped 22543 -> 22550 on 2026-05-16 for prompts landing Phase 1 CLI hygiene validator/prompt-shape coverage; pr-preflight collected 22550. Bumped 22550 -> 22553 on 2026-05-16 for prompts landing Phase 2 cost-class and registry prompt-safety coverage; timing policy collected 22553. Bumped 22553 -> 22558 on 2026-05-16 for prompts landing Phase 3 platform-option and TPC-DS dsdgen validator coverage; timing_policy_check collected 22558. Bumped 22558 -> 22561 on 2026-05-16 for prompts landing Phase 4 provenance template and capture-plan footer coverage; timing_policy_check collected 22561. Bumped 22561 -> 22563 on 2026-05-16 for prompts landing review fixes covering MCP cost-gating parity and compare summary discipline; targeted lane count increased by two fast tests. Bumped 22563 -> 22568 on 2026-05-16 for prompts landing Phase 5 MCP parity prompt-surface coverage; timing_policy_check collected 22568. Bumped 22568 -> 22570 on 2026-05-16 for prompts landing MCP real-tool parity and platform-option gap follow-up coverage; timing_policy_check collected 22570. Bumped 22570 -> 22572 on 2026-05-19 for pr-review-followups prompt regressions covering credential-before-smoke ordering and smoke-scale dsdgen warning behavior; timing_policy_check collected 22572. Set 22572 -> 25000 on 2026-05-19 by maintainer direction during pr-review-followups cleanup. Ratchet down if the lane contracts; never raise without justification. Bumped 25000 -> 25050 on 2026-07-16 for tuning-mode-vocabulary-and-facet-implementation-20260712 (ADR-2 canonical_mode/tuned-fallback/official-refusal coverage, the PACKAGED_RESOURCE composition pin added when merging develop's #1188 packaged-template tier, and the physical_mechanisms unknown-vs-empty ingest-pipeline regression tests); develop alone collected 24995, this branch's merge collected 25022. Bumped 25050 -> 25060 on 2026-07-17 for tuning-capability-registry-coverage-20260716 (alias-resolution invariant tests and derived generator-coverage drift guards for the 9 newly-registered platforms); CI collected 25052 on the merge ref. Bumped 25060 -> 25080 on 2026-07-17 composing #1198's registry-coverage guards with #1176's provenance/hash export coverage (test_tuning_provenance_export.py + test_requested_config_hash.py); both branches independently bumped from 25050 (#1198: 25052 collected; #1176 pre-compose: 25062 collected); composed merge tree collects 25077. Bumped 25080 -> 25140 on 2026-07-17 for tuning-from-config-forwarding-sweep-20260716 (test_tuning_config_forwarding.py's registry-driven parametrized test, one case per registered platform adapter, verifying from_config forwards tuning_enabled/tuning_config/unified_tuning_configuration/tuning_source/tuning_source_file); timing_policy_check collected 25128. Bumped 25140 -> 25180 on 2026-07-18 for the todo-db-tracker local-SQLite spike (tests/unit/scripts/test_todo_db.py: 49 fast unit tests covering the enforced lifecycle invariants, archive import, and concurrency guards); develop collected 25128, this branch collects 25177. Bumped 25180 -> 25190 on 2026-07-18 composing the todo-db spike branch with develop's post-25128 growth: the branch alone collects 25177 (49 fast tracker tests; the 11 wrapper tests are medium-marked and collect 0 under -m fast), but the PR merge ref collects 25187.", + "max_fast_tests": 25220, + "_ceiling_note": "Bumped 13200 -> 22000 on 2026-04-13 to reflect grown fast-lane (20,184 collected). Bumped 22000 -> 22100 on 2026-05-10 for joinorder-canonical-foundation Phase-1 (data_fetch + scale-factor + surface-field tests; 22038 collected). Bumped 22100 -> 22400 on 2026-05-11 for joinorder-canonical-cutover canonical SQL/DataFrame, MCP, publishing, and corpus-inventory guards; 22317 collected. Bumped 22400 -> 22500 on 2026-05-13 for UAT enabled-platform remediation coverage; pr-preflight collected 22419. Bumped 22500 -> 22510 on 2026-05-15 after rebasing onto develop at 36ab01058; pr-preflight collected 22501 before this branch's new public theme contract tests, which are medium-marked to avoid fast-lane growth. Bumped 22510 -> 22530 on 2026-05-14 for pr-review-followup batch coverage (joinorder dataframe family, pg_mooncake adapter, landing quickstart validation, explorer read-model retry); pr-preflight collected 22520. Bumped 22530 -> 22540 on 2026-05-15 for results-explorer-post-theme-reconcile count-aware Home/script coverage; CI collected 22537. Bumped 22540 -> 22543 on 2026-05-16 for pr-review-followups coverage on UAT CLI dispatch, UAT release-gate envelope scoping, and develop-post-merge orphan ordering; ci-lint collected 22543. Bumped 22543 -> 22550 on 2026-05-16 for prompts landing Phase 1 CLI hygiene validator/prompt-shape coverage; pr-preflight collected 22550. Bumped 22550 -> 22553 on 2026-05-16 for prompts landing Phase 2 cost-class and registry prompt-safety coverage; timing policy collected 22553. Bumped 22553 -> 22558 on 2026-05-16 for prompts landing Phase 3 platform-option and TPC-DS dsdgen validator coverage; timing_policy_check collected 22558. Bumped 22558 -> 22561 on 2026-05-16 for prompts landing Phase 4 provenance template and capture-plan footer coverage; timing_policy_check collected 22561. Bumped 22561 -> 22563 on 2026-05-16 for prompts landing review fixes covering MCP cost-gating parity and compare summary discipline; targeted lane count increased by two fast tests. Bumped 22563 -> 22568 on 2026-05-16 for prompts landing Phase 5 MCP parity prompt-surface coverage; timing_policy_check collected 22568. Bumped 22568 -> 22570 on 2026-05-16 for prompts landing MCP real-tool parity and platform-option gap follow-up coverage; timing_policy_check collected 22570. Bumped 22570 -> 22572 on 2026-05-19 for pr-review-followups prompt regressions covering credential-before-smoke ordering and smoke-scale dsdgen warning behavior; timing_policy_check collected 22572. Set 22572 -> 25000 on 2026-05-19 by maintainer direction during pr-review-followups cleanup. Ratchet down if the lane contracts; never raise without justification. Bumped 25000 -> 25050 on 2026-07-16 for tuning-mode-vocabulary-and-facet-implementation-20260712 (ADR-2 canonical_mode/tuned-fallback/official-refusal coverage, the PACKAGED_RESOURCE composition pin added when merging develop's #1188 packaged-template tier, and the physical_mechanisms unknown-vs-empty ingest-pipeline regression tests); develop alone collected 24995, this branch's merge collected 25022. Bumped 25050 -> 25060 on 2026-07-17 for tuning-capability-registry-coverage-20260716 (alias-resolution invariant tests and derived generator-coverage drift guards for the 9 newly-registered platforms); CI collected 25052 on the merge ref. Bumped 25060 -> 25080 on 2026-07-17 composing #1198's registry-coverage guards with #1176's provenance/hash export coverage (test_tuning_provenance_export.py + test_requested_config_hash.py); both branches independently bumped from 25050 (#1198: 25052 collected; #1176 pre-compose: 25062 collected); composed merge tree collects 25077. Bumped 25080 -> 25140 on 2026-07-17 for tuning-from-config-forwarding-sweep-20260716 (test_tuning_config_forwarding.py's registry-driven parametrized test, one case per registered platform adapter, verifying from_config forwards tuning_enabled/tuning_config/unified_tuning_configuration/tuning_source/tuning_source_file); timing_policy_check collected 25128. Bumped 25140 -> 25180 on 2026-07-18 for the todo-db-tracker local-SQLite spike (tests/unit/scripts/test_todo_db.py: 49 fast unit tests covering the enforced lifecycle invariants, archive import, and concurrency guards); develop collected 25128, this branch collects 25177. Bumped 25180 -> 25190 on 2026-07-18 composing the todo-db spike branch with develop's post-25128 growth: the branch alone collects 25177 (49 fast tracker tests; the 11 wrapper tests are medium-marked and collect 0 under -m fast), but the PR merge ref collects 25187. Bumped 25190 -> 25220 on 2026-07-18 for the post-CODEOWNERS-retirement merge queue, composed once so the queued branches carry byte-identical policy content instead of five conflicting bumps (#1142 seed-validation exclusion coverage +10; #1116 bot-finding sweep regressions +4; #1202 capability-registry/report regressions ~+8; #1206 object-schema loader regressions ~+5; #1114 branch-rename test moves +0): develop alone collects 25187; the queue's final merge ref is expected to collect ~25214.", "forbidden_marker_expressions": [ "resource_heavy", "stress", diff --git a/benchbox/core/tuning/capability_registry.py b/benchbox/core/tuning/capability_registry.py index aac0e4ce92..8fc6bd0691 100644 --- a/benchbox/core/tuning/capability_registry.py +++ b/benchbox/core/tuning/capability_registry.py @@ -611,6 +611,17 @@ def _constraint_entries() -> dict[TuningType, TuningCapability]: "PgDuckDBAdapter never runs any post_create_statements via the inherited no-op " "apply_table_tunings/generate_tuning_clause path.", ), + _T.SORTING: _none( + "pg_duckdb_inherited_postgresql_noop", + "PostgreSQLDDLGenerator treats SORTING as CLUSTERING -- a shared code path (postgresql.py:" + "141-159) appends both a CREATE INDEX and a CLUSTER post_create_statement for either tuning " + "type. PgDuckDBDDLGenerator's inherited generate_tuning_clauses runs that same path before its " + "filter (pg_duckdb.py:83-88) strips only the CLUSTER statement by prefix match, so a tuned " + "SORTING config's CREATE INDEX still surfaces in dry-run preview, same as CLUSTERING. Same " + "execution-time no-op as CLUSTERING/PARTITIONING regardless: PgDuckDBAdapter never runs " + "post_create_statements via the inherited no-op apply_table_tunings/generate_tuning_clause " + "path.", + ), }, "pg-mooncake": { _T.PARTITIONING: _none( diff --git a/tests/uat/phases/execute.py b/tests/uat/phases/execute.py index daccfc1f23..0f51a5d957 100644 --- a/tests/uat/phases/execute.py +++ b/tests/uat/phases/execute.py @@ -870,12 +870,19 @@ def _docker_result_message(result: docker_assets.DockerCommandResult) -> str: def append_lifecycle_log(log_dir: Path | None, line: str) -> None: - """Append a timestamped line to uat_lifecycle.log. Public: also called from orchestrator.py (sweep-start engine identity, uat-container-engine-routing w2).""" + """Append a timestamped line to uat_lifecycle.log. Public: also called from orchestrator.py (sweep-start engine identity, uat-container-engine-routing w2). + + The timestamp is offset-aware (``datetime.now().astimezone()``, not plain + ``datetime.now()``) so a release-gate boundary comparison spanning a DST + transition (see report.py's ``release_gate_ordering_violations``) can use + each event's own recorded offset instead of having to assume one (#1179, + #1202 follow-up). + """ if log_dir is None: return log_dir.mkdir(parents=True, exist_ok=True) with (log_dir / "uat_lifecycle.log").open("a", encoding="utf-8") as fh: - fh.write(f"{_dt.datetime.now().isoformat(timespec='seconds')} {line}\n") + fh.write(f"{_dt.datetime.now().astimezone().isoformat(timespec='seconds')} {line}\n") def default_log_dir(config: UATConfig, now: _dt.datetime | None = None) -> Path: diff --git a/tests/uat/phases/report.py b/tests/uat/phases/report.py index ce77057ea1..617bb2ab71 100644 --- a/tests/uat/phases/report.py +++ b/tests/uat/phases/report.py @@ -426,16 +426,21 @@ def release_gate_ordering_violations( violations: list[str] = [] for log_text in docker_stage_lifecycle_logs: for timestamp, platform in parse_docker_up_events(log_text): - # append_lifecycle_log() writes datetime.now() (naive local - # wall-clock time, no offset), but native_stage_completed_at is - # offset-aware in production (orchestrator.py's - # datetime.now().astimezone(), #1162). Comparing a naive and an - # aware datetime raises TypeError, so normalize a naive - # timestamp onto native_stage_completed_at's awareness -- via - # astimezone(), which interprets a naive datetime as local time - # and attaches the current local UTC offset -- before comparing. + # append_lifecycle_log() now writes offset-aware timestamps + # (datetime.now().astimezone(), #1202 follow-up) so each Docker + # event carries its own real offset -- correct even across a DST + # transition mid-sweep. This branch is a best-effort fallback for + # uat_lifecycle.log files written by an older BenchBox version + # that recorded naive local wall-clock time with no offset at + # all: attach the boundary's own offset, since both timestamps + # come from the same host/sweep run and no better information is + # available for a legacy naive entry (#1179). A stale log mixing + # naive pre-upgrade lines with a DST transition can still + # misattribute the offset -- there is no way to recover the true + # offset of a naive timestamp after the fact; only fresh + # offset-aware logs are exact. if timestamp.tzinfo is None and native_stage_completed_at.tzinfo is not None: - timestamp = timestamp.astimezone() + timestamp = timestamp.replace(tzinfo=native_stage_completed_at.tzinfo) if timestamp <= native_stage_completed_at: violations.append( f"Docker stack '{platform}' started at {timestamp.isoformat()} " diff --git a/tests/uat/test_report.py b/tests/uat/test_report.py index dc1bec8da5..85cbc1274b 100644 --- a/tests/uat/test_report.py +++ b/tests/uat/test_report.py @@ -7,7 +7,7 @@ import pytest -from tests.uat.phases import report +from tests.uat.phases import execute, report from tests.uat.runner import CellResult pytestmark = pytest.mark.fast @@ -296,10 +296,12 @@ def test_release_gate_ordering_flags_docker_up_before_native_completion(): def test_release_gate_ordering_does_not_raise_against_offset_aware_boundary(): """orchestrator.py's completed_at is offset-aware (datetime.now().astimezone(), - #1162), but append_lifecycle_log() still writes naive uat_lifecycle.log - timestamps. Comparing the two used to raise TypeError; parse_docker_up_events - must normalize the naive side so the comparison (and any real violation) - still works. + #1162). ``_DOCKER_LOG_OK``/``_DOCKER_LOG_EARLY`` above use naive timestamps + to exercise the legacy-log fallback path (pre-#1202-follow-up + append_lifecycle_log() output, or any hand-written fixture); comparing a + naive timestamp against an aware boundary used to raise TypeError, so + parse_docker_up_events must normalize the naive side and the comparison + (and any real violation) must still work. """ aware_boundary = _dt.datetime(2026, 5, 30, 1, 0, 0).astimezone() @@ -311,3 +313,60 @@ def test_release_gate_ordering_does_not_raise_against_offset_aware_boundary(): ) assert len(violations) == 1 assert "cedardb" in violations[0] + + +def test_release_gate_ordering_uses_boundary_offset_for_naive_docker_timestamps(): + """Regression for #1179: a valid PDT run must not be flagged as a violation + just because the checker process runs under a different timezone (e.g. + UTC). astimezone() previously attached the *checker process's* current + local offset to the naive Docker timestamp instead of the producer's -- + for a boundary completed at 2026-05-30T01:00:00-07:00 (PDT) and a Docker + naive timestamp of 2026-05-30T02:00:00 (also PDT wall-clock, i.e. + genuinely after the boundary), a checker running under UTC would wrongly + attach +00:00 to the naive timestamp instead of -07:00, making it appear + to be hours *before* the boundary instant and firing a false violation. + """ + pdt = _dt.timezone(_dt.timedelta(hours=-7)) + boundary = _dt.datetime(2026, 5, 30, 1, 0, 0, tzinfo=pdt) + + violations = report.release_gate_ordering_violations([_DOCKER_LOG_OK], native_stage_completed_at=boundary) + + assert violations == [] + + +def test_release_gate_ordering_respects_each_events_own_offset_across_dst(): + """A boundary and a later Docker event can carry genuinely different UTC + offsets when a sweep straddles a DST transition (fall-back: PDT -07:00 -> + PST -08:00). Once each event carries its own real offset (#1202 + follow-up: append_lifecycle_log() now writes datetime.now().astimezone() + instead of naive datetime.now()), the comparison must use that offset + directly rather than reusing the boundary's -- the boundary's fixed + offset would misread the later, different-offset event's wall-clock time + (2026-11-01T01:15:00-08:00, genuinely after the boundary) as 45 minutes + *before* a boundary of 2026-11-01T01:30:00-07:00, a false violation. + """ + pdt = _dt.timezone(_dt.timedelta(hours=-7)) + boundary = _dt.datetime(2026, 11, 1, 1, 30, 0, tzinfo=pdt) + docker_log = ( + "2026-11-01T01:15:00-08:00 [docker] platform=lakesail action=up status=ok " + "project=benchbox-uat-gate-lakesail command=['docker'] message=started\n" + ) + + violations = report.release_gate_ordering_violations([docker_log], native_stage_completed_at=boundary) + + assert violations == [] + + +def test_append_lifecycle_log_writes_offset_aware_timestamp(tmp_path: Path): + """Regression for the #1202 follow-up: append_lifecycle_log() must write + an offset-aware timestamp (parseable straight through by + parse_docker_up_events with no naive-timestamp fallback needed), not + plain datetime.now(). + """ + execute.append_lifecycle_log(tmp_path, "[docker] platform=duckdb action=up status=ok") + + line = (tmp_path / "uat_lifecycle.log").read_text(encoding="utf-8").strip() + timestamp_token = line.split(" ", 1)[0] + parsed = _dt.datetime.fromisoformat(timestamp_token) + + assert parsed.tzinfo is not None diff --git a/tests/unit/core/tuning/test_capability_source_drift.py b/tests/unit/core/tuning/test_capability_source_drift.py index 68075b7bd3..32afbca8c7 100644 --- a/tests/unit/core/tuning/test_capability_source_drift.py +++ b/tests/unit/core/tuning/test_capability_source_drift.py @@ -43,6 +43,7 @@ from benchbox.core.tuning.capability_registry import ( PLATFORM_TUNING_CAPABILITIES, WORKLOAD_PROFILE_MAPPED_PLATFORMS, + get_capability, interface_compatibility_map, known_registry_platforms, resolve_platform_key, @@ -409,6 +410,20 @@ def test_coverage_20260716_aliases_resolve_to_canonical_registry_key(alias, cano assert canonical in PLATFORM_TUNING_CAPABILITIES +def test_pg_duckdb_sorting_has_a_registry_entry(): + """Regression for #1198: PgDuckDBDDLGenerator inherits PostgreSQLDDLGenerator's + SUPPORTED_TUNING_TYPES, which includes sorting (postgresql.py:78) -- a tuned + SORTING config is processed (as a CLUSTER-index pair, then CLUSTER filtered + out), not rejected. The registry entry originally covered only PARTITIONING + and CLUSTERING, so get_capability("pg-duckdb", SORTING) returned None + (== "not compatible") instead of the correct rendered_via="none" + ("compatible, but real execution renders nothing"). + """ + capability = get_capability("pg-duckdb", TuningType.SORTING) + assert capability is not None + assert capability.rendered_via == "none" + + # --------------------------------------------------------------------------- # Pre-existing drift coverage from PR #1177 (finding R7), kept as-is: these # compare the three sources' independent, as-presented-today behavior (not