diff --git a/.codespell-ignore.txt b/.codespell-ignore.txt index 1783a51d6d..7c1795726b 100644 --- a/.codespell-ignore.txt +++ b/.codespell-ignore.txt @@ -87,5 +87,9 @@ assertIn # DuckDB SQL function name iif +# TPC-DS canonical item-class literals +birdal +womens + # IATA airport codes in FlightData reference data (not misspellings) MKE diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 9ee1437975..0000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,88 +0,0 @@ -# Soundness-critical result comparators and plan parsers require owner review. -# Mirrors _project/scripts/auto_merge_soundness_paths.py:SOUNDNESS_PREFIXES -# 1:1 -- keep these two files in lockstep (pinned by -# tests/unit/test_auto_merge_soundness_paths.py::test_codeowners_covers_soundness_paths). -benchbox/core/**/validation.py @joeharris76 -benchbox/core/equivalence/** @joeharris76 -benchbox/core/query_plans/parsers/** @joeharris76 -# Oracle-adjacent widening (soundness-surface-widening): reference-value -# loader/registry/digest fixtures, the validation+plan-capture entry point, -# and the sql_compat rule-dispatch core (not the whole high-churn tree). -benchbox/core/expected_results/** @joeharris76 -benchbox/platforms/base/result_capture.py @joeharris76 -benchbox/sql_compat/resolver.py @joeharris76 -benchbox/sql_compat/decision.py @joeharris76 -benchbox/sql_compat/rules/_registration.py @joeharris76 -# Publication privacy: the anonymizer decides every published byte and the -# public pseudonym identity. Both failure modes are silent (leak a private -# value, or repartition public identity). The specs YAML defines which keys -# get hashed, so dropping an entry there is a silent leak with no code diff. -# Widened after PR #1512. -benchbox/core/results/anonymization.py @joeharris76 -benchbox/core/results/anonymization_specs.yaml @joeharris76 -benchbox/core/results/provenance.py @joeharris76 -benchbox/core/results/status.py @joeharris76 -benchbox/core/results/query_status.py @joeharris76 -benchbox/validation/bundle.py @joeharris76 -benchbox/core/publishing/admission.py @joeharris76 -benchbox/core/publishing/bundle_publisher.py @joeharris76 -_project/scripts/explorer_pipeline/models.py @joeharris76 -_project/scripts/explorer_pipeline/pipeline.py @joeharris76 -_project/scripts/explorer_pipeline/ranking.py @joeharris76 -_project/scripts/explorer_pipeline/transformer.py @joeharris76 -_project/scripts/explorer_pipeline/duckdb_builder.py @joeharris76 -_project/scripts/explorer_pipeline/compare_math.py @joeharris76 -_project/scripts/results_explorer_snapshot_invariants.py @joeharris76 -benchbox/core/results/canonical_json.py @joeharris76 -benchbox/core/results/schema_policy.py @joeharris76 -_project/scripts/explorer_publish.py @joeharris76 -scripts/generate_corpus_inventory.py @joeharris76 -scripts/validate_submission.py @joeharris76 -results-data/bundles/**/*.override.json @joeharris76 -.github/workflows/validate-submission.yml @joeharris76 -.github/workflows/sync-results-data-to-published.yml @joeharris76 -.github/workflows/docs.yml @joeharris76 -.github/workflows/publication-deploy.yml @joeharris76 -.github/workflows/publication-preview-deploy.yml @joeharris76 -.github/workflows/publication-preview-soak.yml @joeharris76 -.github/workflows/publication-lane-docs.yml @joeharris76 -.github/workflows/publication-lane-explorer.yml @joeharris76 -.github/workflows/publication-corpus-cutover.yml @joeharris76 -.github/workflows/publication-transaction.yml @joeharris76 -.github/workflows/publication-recover.yml @joeharris76 -.github/workflows/publication-canaries.yml @joeharris76 -scripts/publication/verify_lane_isolation.py @joeharris76 -# Self-protection: the review-gate machinery and the PyPI-publishing -# workflow. In-workflow checks are attacker-controlled for same-repo PRs -# (GitHub runs the PR's own copy of a pull_request-triggered workflow). -# NOTE: the develop ruleset enforces code-owner review -# (require_code_owner_review: true, live-verified 2026-09-16 against ruleset -# 15611785, active; the 2026-07-18 retirement is superseded — sole owner -# authors every PR and GitHub forbids self-approval, so soundness-path PRs -# need manual maintainer merge; -# see docs/operations/repo-admin-settings.md "Soundness-path review -# enforcement"). This file's role is to mirror -# SOUNDNESS_PREFIXES (auto-merge withholding) and route review requests. -# .github/workflows/pr.yml deliberately excluded -# (high churn; its required check is pinned by the ruleset-drift canary). -scripts/heavy_tier_needed.py @joeharris76 -_project/scripts/auto_merge_soundness_paths.py @joeharris76 -.github/workflows/auto-merge-on-open.yml @joeharris76 -.github/workflows/release.yml @joeharris76 -# Independent-publication authority and trust-policy surfaces. These exact -# paths mirror SOUNDNESS_FILES so policy changes route to manual maintainer -# review and cannot retain auto-merge. -_project/decisions/independent-publication-a0-freeze-2026-08-31.md @joeharris76 -docs/development/adr/adr-independent-publication-authorities.md @joeharris76 -docs/development/adr/adr-public-result-id-permanence.md @joeharris76 -docs/development/adr/adr-published-results-slim-corpus-branch.md @joeharris76 -docs/development/independent-publication-threat-model.md @joeharris76 -docs/operations/independent-publication-contract.md @joeharris76 -docs/operations/publication-deployer-soak-and-retirement.md @joeharris76 -docs/operations/results-phase-2-runbook.md @joeharris76 -docs/operations/results-phase-3-runbook.md @joeharris76 -docs/reference/hosted-results-contract.md @joeharris76 -docs/reference/threat-model.md @joeharris76 -scripts/check_decision_records.py @joeharris76 -publication/** @joeharris76 -scripts/publication/** @joeharris76 diff --git a/.github/ci-units.yml b/.github/ci-units.yml new file mode 100644 index 0000000000..6241927c1e --- /dev/null +++ b/.github/ci-units.yml @@ -0,0 +1,110 @@ +# Ownership map for the six merge units reported by .github/workflows/ci.yml. +# +# A changed path belongs to EVERY unit whose patterns match it, so a +# cross-unit contract is expressed by listing a path under more than one +# unit. A changed path that matches no unit belongs to `core`: the classifier +# fails closed so a new top-level area runs the widest unit, never none. +# `all-units` paths (the classifier itself and the workflow that consumes it) +# run every unit. + +all-units: + - ".github/ci-units.yml" + - ".github/workflows/ci.yml" + - "scripts/ci_units.py" + - "scripts/ci_unit_result.py" + +core: + - "benchbox/**" + - "tests/**" + - "examples/**" + - "docker/**" + - "_binaries/**" + - "_sources/**" + - "quality/**" + - "pyproject.toml" + - "uv.lock" + - "MANIFEST.in" + - "tox.ini" + - "pytest.ini" + - "pytest-ci.ini" + - ".importlinter" + - "codecov.yml" + - "deploy/**" + # Executable documentation machinery is product code: it runs under lint, + # type, and unit tests like any other Python or template source. + - "docs/conf.py" + - "docs/Makefile" + - "docs/_extensions/**" + - "docs/_static/**" + - "docs/_templates/**" + - "docs/**.py" + - "docs/**.js" + - "docs/**.css" + - "docs/**.html" + - "docs/**.jinja" + - "docs/**.j2" + - "docs/agent/**" + - "docs/development/dependency-audit-raw.md" + - "docs/operations/agent-instruction*.md" + - "landing/**.py" + - "website/**.py" + +explorer: + - "results-explorer/**" + - "_project/scripts/explorer_pipeline/**" + - "_project/scripts/explorer_publish.py" + - "tests/parity/**" + - "scripts/generate_corpus_inventory.py" + # Producer-owned contracts: the explorer consumes the results schema, the + # published bundles, and the chart vocabulary the CLI writes. + - "benchbox/core/results/**" + - "benchbox/core/visualization/**" + - "results-data/**" + +results-data: + - "results-data/**" + - "scripts/validate_submission.py" + - "scripts/check_submission_validator_sync.py" + - "benchbox/core/results/**" + - "benchbox/validation/**" + +docs: + - "docs/**" + - "website/**" + - "_project/scripts/generate_oracle_coverage_map.py" + - "scripts/generate_query_docs.py" + - "scripts/generate_compat_docs.py" + # Autodoc imports the package; the CLI and registries define the public + # surface the reference pages describe. + - "benchbox/__init__.py" + - "benchbox/cli/**" + - "benchbox/core/platform_registry.py" + - "benchbox/core/benchmark_registry.py" + +landing: + - "landing/**" + - "website/**" + - "docs/_static/custom.css" + - "docs/_templates/page.html" + - "results-explorer/index.html" + - "scripts/generate_landing_quickstarts.py" + # Quickstarts are generated from the platform registry and dependency groups. + - "benchbox/core/platform_registry.py" + - "benchbox/platforms/manifest.py" + - "pyproject.toml" + +tooling: + - ".github/**" + - "scripts/**" + - "_project/**" + - "_blog/**" + - "make/**" + - "Makefile" + - ".pre-commit-config.yaml" + - ".claude/**" + - ".agents/**" + - "skill-sync.conf" + - "tools/**" + - ".gitignore" + - ".gitattributes" + - "*.md" diff --git a/.github/path-filters.yml b/.github/path-filters.yml index 21eeb09110..027db43d13 100644 --- a/.github/path-filters.yml +++ b/.github/path-filters.yml @@ -63,6 +63,7 @@ code-ci: - "landing/**" - "results-data/**" - "results-explorer/**" + - "website/**" - "docker/**" - "_binaries/**" - "_sources/**" @@ -104,7 +105,7 @@ code-ci: # Extra path-filter groups (beyond the three core buckets above). Each key # here becomes a `-needed` boolean output from ci-paths, computed by -# path_filter_decision.py, so a pr.yml job can gate on +# path_filter_decision.py, so a ci.yml job can gate on # `needs.ci-paths.outputs.-needed == 'true'` without inventing a # second classifier. Most extra groups only select an additional job. The # skill-integrity group is also a first-class specialized class: when no @@ -117,7 +118,7 @@ skill-integrity: - "tools/skill-sync" # Packaging-metadata changes: build/install/audit signal (package-smoke, -# dependency-audit jobs in pr.yml) only needs to run when one of these +# dependency-audit jobs in ci.yml) only needs to run when one of these # changed. See tracker item `pr-gate-package-and-audit-promotion`. packaging: - "pyproject.toml" @@ -127,8 +128,33 @@ packaging: - "benchbox/__init__.py" - "uv.lock" +# Test-infrastructure inputs to the Windows and macOS binary framing job in +# ci.yml. Subprocess, file-lock, file-handle, HOME and plugin changes pass on +# Linux and fail only where a held lock file is unreadable and open files block +# directory cleanup. The job also runs for the bundled dbgen binaries and the +# framing test itself. +framing: + - "tests/utilities/**" + - "conftest.py" + - "**/conftest.py" + - "tests/plugins/**" + - "tests/**/plugins/*" + - "tests/fixtures/database_fixtures.py" + - "tests/fixtures/test_data_fixtures.py" + - "tests/fixtures/result_dict_fixtures.py" + - "tests/fixtures/platform_fixtures.py" + - "tests/fixtures/utility_fixtures.py" + - "_benchbox_pytest_xdist_safety.py" + - "scripts/local_validation.py" + - "tests/duration_policy.py" + - "pytest.ini" + - "tests/unit/core/tpch/test_tpch_dbgen_framing_binaries.py" + - "benchbox/__init__.py" + - "benchbox/_binaries/tpc-h/**" + - "_binaries/tpc-h/**" + # Results-explorer visualization / CLI<->explorer parity source changes: -# gates the parity-check job in pr.yml. See +# gates the parity-check job in ci.yml. See # tracker item `parity-check-develop-ring`. viz: - "results-explorer/**" @@ -146,8 +172,7 @@ explorer-vitest: - "scripts/generate_corpus_inventory.py" - "tests/parity/fixtures/**" - "tests/unit/core/tuning/fixtures/**" - - ".github/workflows/results-explorer-browser.yml" - - ".github/workflows/pr.yml" + - ".github/workflows/ci.yml" - ".github/path-filters.yml" - "tests/unit/core/tuning/fixtures/tuning_mode_vocabulary.yaml" - "tests/parity/fixtures/chart_ids.json" @@ -166,3 +191,23 @@ site-theme: - "docs/_static/custom.css" - "docs/_templates/page.html" - "results-explorer/index.html" + +site: + - "website/**" + - "docs/**" + - "landing/**" + - "results-explorer/**" + - "results-data/**" + - "_project/scripts/explorer_pipeline/**" + - "_project/scripts/explorer_publish.py" + - "scripts/generate_query_docs.py" + - "benchbox/**" + - "_binaries/**" + - "_sources/**" + - "scripts/publication/check_artifact_privacy.py" + - "Makefile" + - "make/documentation.mk" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/ci.yml" + - ".github/path-filters.yml" diff --git a/.github/soundness-paths.txt b/.github/soundness-paths.txt new file mode 100644 index 0000000000..4970aadf76 --- /dev/null +++ b/.github/soundness-paths.txt @@ -0,0 +1,113 @@ +# Shared soundness review surface. +# Format: kindpattern. Kinds are prefix, file, glob, or regex. +# Prefixes end in / and exact paths use file. The glob and regex entries are +# matching rules, not CODEOWNERS spellings. +regex ^benchbox/core/(?:.+/)?validation\.py$ +glob results-data/bundles/**/*.override.json + +prefix benchbox/core/equivalence/ +prefix benchbox/core/query_plans/parsers/ +prefix benchbox/core/expected_results/ +prefix publication/ +prefix scripts/publication/ +prefix scripts/site_deploy/ +prefix deploy/ +prefix .github/workflows/ +prefix _sources/tpc-h/dbgen/answers/ +prefix _sources/tpc-ds/answer_sets/ + +file scripts/heavy_tier_needed.py +file scripts/pr_arm.py +file benchbox/core/validation/engines.py +file benchbox/utils/data_validation.py +file benchbox/core/tpcdi/query_validation.py +file benchbox/core/tpchavoc/dataframe_equivalence.py +file benchbox/core/tpchavoc/equivalence.py +file benchbox/core/results/result_digest.py +file _project/scripts/regenerate_correctness_gate_digests.py +file benchbox/core/dataframe/query_validation.py +file benchbox/core/datavault/validation_specs.yaml +file benchbox/core/validation/cross_platform.py +file benchbox/core/validation/query_validation.py +file benchbox/core/validation/data.py +file benchbox/platforms/base/validation.py +file benchbox/core/tpc_validation.py +file _project/scripts/build_joinorder_data.py +file _project/joinorder/reference_cardinalities.json +file _project/joinorder/tiny_reference_cardinalities.json +file benchbox/platforms/base/result_capture.py +file benchbox/sql_compat/resolver.py +file benchbox/sql_compat/decision.py +file benchbox/sql_compat/rules/_registration.py +file benchbox/core/results/anonymization.py +file benchbox/core/results/anonymization_specs.yaml +file benchbox/core/results/provenance.py +file benchbox/core/results/status.py +file benchbox/core/results/query_status.py +file benchbox/validation/bundle.py +file benchbox/core/publishing/admission.py +file benchbox/core/publishing/bundle_publisher.py +file _project/scripts/explorer_pipeline/models.py +file _project/scripts/explorer_pipeline/pipeline.py +file _project/scripts/explorer_pipeline/ranking.py +file _project/scripts/explorer_pipeline/transformer.py +file _project/scripts/explorer_pipeline/duckdb_builder.py +file _project/scripts/explorer_pipeline/compare_math.py +file _project/scripts/results_explorer_snapshot_invariants.py +file benchbox/core/results/canonical_json.py +file benchbox/core/results/schema_policy.py +file _project/scripts/explorer_publish.py +file scripts/generate_corpus_inventory.py +file scripts/validate_submission.py +file .github/workflows/validate-submission.yml +file .github/workflows/sync-results-data-to-published.yml +file .github/workflows/docs.yml +file .github/workflows/publication-deploy.yml +file .github/workflows/publication-preview-deploy.yml +file .github/workflows/publication-preview-soak.yml +file .github/workflows/publication-lane-docs.yml +file .github/workflows/publication-lane-explorer.yml +file .github/workflows/publication-corpus-cutover.yml +file .github/workflows/publication-transaction.yml +file .github/workflows/publication-recover.yml +file .github/workflows/publication-canaries.yml +file .github/workflows/site-deploy.yml +file scripts/publication/verify_lane_isolation.py +file _project/scripts/auto_merge_soundness_paths.py +file _project/scripts/soundness_paths.py +file _project/scripts/oracle_review_check.py +file .github/workflows/oracle-review.yml +file _project/scripts/check_soundness_review.py +file .github/soundness-paths.txt +file .github/PULL_REQUEST_TEMPLATE.md +file .github/workflows/ci.yml +file .github/ci-units.yml +file scripts/ci_units.py +file scripts/ci_unit_result.py +file scripts/release_flow.py +file scripts/release_artifact_consumer.py +file scripts/update_version.py +file scripts/generate_changelog_entry.py +file scripts/check_release_curation.py +file scripts/check_dependency_bounds.py +file _project/scripts/check_uv_lock_revision.py +file _project/decisions/single-repo-migration.md +file .github/workflows/release.yml +file scripts/check_decision_records.py +prefix benchbox/_binaries/ +file benchbox/utils/binary_manifest.py +file scripts/bundled_binary_manifest.py +file scripts/verify_distribution_binaries.py +file quality/comment-policy.json +file quality/comment-policy-requirements.txt +file quality/comment-policy-package.json +file quality/comment-policy-package-lock.json +file scripts/check_comment_policy.py +file scripts/comment_syntax.py +file scripts/comment_syntax_js.cjs +file scripts/run_comment_policy.py +file scripts/comment_payloads.py +file scripts/comment_execution.py +file scripts/comment_policy_entry.py +file _project/scripts/soundness_merge_digest.py +file .github/workflows/soundness-merge-digest.yml diff --git a/.github/workflows/auto-merge-on-open.yml b/.github/workflows/auto-merge-on-open.yml index 195fb789c2..018923ab1e 100644 --- a/.github/workflows/auto-merge-on-open.yml +++ b/.github/workflows/auto-merge-on-open.yml @@ -6,58 +6,9 @@ name: Auto-merge revocation # arm paths are `make pr-ready` / `make pr-arm-auto-merge` (or # `make pr-open READY=1` when reusing an already-open, reviewed PR) on a # finished branch. A newly created PR stays held. This workflow NEVER arms. -# -# History: this file used to arm on `opened` (defeating the #1567 Makefile -# hold — live proof: #1568/#1569 merged hands-free seconds after creation), -# then after #1592 armed only on `ready_for_review`. That arm path never -# fired once: drafts are not used in this repo, so no `ready_for_review` -# event ever occurred (0 events across PRs #1459–#1630). The dead arm step -# was removed per _project/decisions/auto-merge-policy-consolidation-2026-08-06.md -# (D2); a dead enabling path in the gate machinery is pure comprehension -# cost. If the draft workflow is ever adopted, arm via `make pr-ready`, not -# by resurrecting an arm step here. -# -# What this workflow does — revocation only, re-evaluated on every trigger: -# 1. Soundness paths touched (union of base-ref and PR-copy predicate, -# fail-closed): disable auto-merge. A push that newly touches a -# soundness-critical path must not ride an auto-merge armed by an -# earlier (non-soundness) state. GitHub only auto-disables auto-merge -# on push when the pusher lacks write access, so in the normal -# same-repo/write-author flow a stale enabled auto-merge would -# otherwise survive and merge after checks without review. -# 2. Durable hold label `no-auto-merge` present: disable auto-merge. -# `labeled` is in the trigger list so applying the label revokes -# without needing a push. The label also blocks the local arm path -# (`make pr-arm-auto-merge` refuses it) and the nightly green-unmerged -# sweep treats it as intentional (not stranded). Remove the label -# before arming. See docs/operations/pr-triage.md. -# 3. Draft status — job-level skip: a draft cannot carry auto-merge, so -# there is nothing to revoke. -# -# Soundness predicate evaluation (disable path): -# Union of (a) the base-ref copy and (b) the PR checkout copy. Base-ref -# alone stops a PR from judging its own diff by a narrowed rule set. -# Evaluating the PR copy alongside it means a gate *widened* mid-flight -# in the same PR still revokes auto-merge for paths only the new gate -# covers. Self-touch of the predicate or this workflow forces -# soundness_path=true regardless. -# -# HONESTY NOTE on the self-protection checks below (base-ref predicate -# execution + self-touch override): they are BEST-EFFORT for same-repo PRs. -# GitHub executes the PR's OWN copy of a pull_request-triggered workflow -# file, so a PR that edits this workflow can delete these checks in the -# same commit that would have triggered them. The repo-layer backstop is -# the develop ruleset's require_code_owner_review rule, re-enabled -# 2026-07-21 with required approvals 0 (retired 2026-07-18 over a -# self-approval deadlock, then restored — see -# docs/operations/repo-admin-settings.md, "Soundness-path review -# enforcement"). CODEOWNERS mirrors the soundness prefixes 1:1. GitHub -# forbids self-approval, so soundness PRs never merge hands-free: the owner -# reviews the diff and merges manually. The residual workflow-tamper risk -# is recorded and accepted in that runbook section. on: - pull_request: + pull_request_target: # `labeled` so applying `no-auto-merge` revokes auto-merge without a push. # No `ready_for_review`: nothing armable exists on a draft, so the # draft→ready transition has nothing to revoke (and never arms — see @@ -67,20 +18,13 @@ on: permissions: pull-requests: write - contents: write + contents: read jobs: revoke: if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - fetch-depth: 0 - - - name: Fetch base branch - run: git fetch --no-tags origin "${{ github.base_ref || 'develop' }}:refs/remotes/origin/${{ github.base_ref || 'develop' }}" - - name: Check explicit hold label id: hold env: @@ -100,69 +44,6 @@ jobs: echo "Explicit hold label no-auto-merge present; will revoke any enabled auto-merge." fi - - name: Check soundness-critical paths - id: soundness - run: | - set -euo pipefail - # --no-renames so a rename OUT of a protected tree surfaces the deleted - # source path. With rename detection on, `git diff` reports only the - # (unprotected) destination, so a PR that removes a soundness-critical - # file via rename would slip past the gate. - diff_paths="$(git diff --name-only --no-renames "origin/${{ github.base_ref || 'develop' }}...HEAD")" - - # (a) BASE-REF copy: a PR that narrows SOUNDNESS_PREFIXES must still be - # judged by develop's rules, not its own. The base branch is already - # fetched by the preceding "Fetch base branch" step. - git show "origin/${{ github.base_ref || 'develop' }}:_project/scripts/auto_merge_soundness_paths.py" > /tmp/predicate_base.py - base_result="$(printf '%s\n' "$diff_paths" | python3 /tmp/predicate_base.py --stdin --format github-output)" - - # (b) PR checkout copy: a gate *widened* in this PR still revokes - # auto-merge for paths only the new gate covers. Fail closed (treat as - # soundness) if the PR copy is missing or errors — never let a broken - # PR-side predicate silence revocation. - pr_result="soundness_path=false" - if [ -f "_project/scripts/auto_merge_soundness_paths.py" ]; then - if ! pr_result="$(printf '%s\n' "$diff_paths" | python3 _project/scripts/auto_merge_soundness_paths.py --stdin --format github-output)"; then - pr_result="soundness_path=true" - fi - fi - - # Union: soundness if either copy says so, so a PR cannot weaken its - # own gate by narrowing the PR-side rules. - if [ "$base_result" = "soundness_path=true" ] || [ "$pr_result" = "soundness_path=true" ]; then - result="soundness_path=true" - else - result="soundness_path=false" - fi - - # Base-ref + PR-copy union alone does not flag "this diff touches the - # predicate (or the workflow that runs it)" as review-worthy - a PR - # could still edit the predicate to make FUTURE PRs unsafe without - # itself needing owner review. Force soundness_path=true whenever - # the diff touches either file, regardless of what either predicate - # says about the rest of the diff. - if printf '%s\n' "$diff_paths" | grep -qxF "_project/scripts/auto_merge_soundness_paths.py" \ - || printf '%s\n' "$diff_paths" | grep -qxF ".github/workflows/auto-merge-on-open.yml"; then - result="soundness_path=true" - fi - - echo "$result" >> "$GITHUB_OUTPUT" - - - name: Disable auto-merge for soundness PR - if: steps.soundness.outputs.soundness_path == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - echo "Soundness-critical paths changed; clearing any auto-merge/queue enrollment pending review." - # A push that newly touches a soundness path must not ride an auto-merge - # enabled by an earlier (non-soundness) push. Disabling auto-merge also - # dequeues the PR from any active merge queue. --disable-auto exits - # non-zero when auto-merge is already off, so tolerate that. - gh pr merge --disable-auto --repo "$REPO" "$PR_NUMBER" || true - - name: Disable auto-merge for explicit hold if: steps.hold.outputs.held == 'true' env: diff --git a/.github/workflows/pr.yml b/.github/workflows/ci.yml similarity index 52% rename from .github/workflows/pr.yml rename to .github/workflows/ci.yml index 7d36628c79..cd7a4f83cf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,100 @@ -name: Develop PR +name: CI + +# One workflow, six always-reporting unit results: core, explorer, results-data, +# docs, landing, and tooling. The classifier maps changed paths to units +# (.github/ci-units.yml); an untouched unit reports success immediately, and a +# touched unit must succeed in every job it required (a skipped required job +# fails the unit). There is deliberately no branch filter on pull_request, so a +# change opened against any base gets CI instead of an empty check list. + +env: + BENCHBOX_TEST_LOCK_WAIT_SECONDS: "0" on: pull_request: - branches: - - develop - merge_group: - branches: - - develop + types: [opened, synchronize, reopened, edited, ready_for_review, converted_to_draft] concurrency: - group: develop-pr-${{ github.event_name == 'merge_group' && github.event.merge_group.head_ref || github.ref }} + group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: contents: read + pull-requests: read jobs: + comment-policy: + name: comment-policy + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.11' + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + with: + enable-cache: false + working-directory: ${{ runner.temp }} + ignore-empty-workdir: true + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 + with: + node-version: '20' + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + fetch-depth: 0 + persist-credentials: false + - name: Enforce comment and docstring policy + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + : "${BASE_REF:?BASE_REF is required}" + installed="$(git ls-tree -r --name-only "$BASE_REF" -- \ + scripts/check_comment_policy.py scripts/comment_syntax.py scripts/comment_syntax_js.cjs \ + scripts/comment_payloads.py scripts/comment_execution.py scripts/comment_policy_entry.py \ + quality/comment-policy.json quality/comment-policy-requirements.txt \ + quality/comment-policy-package.json quality/comment-policy-package-lock.json)" + if git cat-file -e "${BASE_REF}:scripts/run_comment_policy.py" 2>/dev/null; then + git show "${BASE_REF}:scripts/run_comment_policy.py" > "$RUNNER_TEMP/comment-policy-runner.py" + elif [ -z "$installed" ] && git merge-base --is-ancestor ed5c263c513ba65499f4918d3a7de607f280c65b "$BASE_REF"; then + cp scripts/run_comment_policy.py "$RUNNER_TEMP/comment-policy-runner.py" + else + echo "Trusted comment-policy launcher is missing" >&2 + exit 1 + fi + python -I "$RUNNER_TEMP/comment-policy-runner.py" --native-tests + + # Base guard: stacked PRs get CI from this workflow (no branch filter), + # so an unsupported feature-branch base must fail loudly here instead of + # sliding into develop unvalidated under a parent PR. + base-guard: + name: PR base branch is an integration branch + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check base branch + env: + BASE_REF: ${{ github.base_ref }} + IS_DRAFT: ${{ github.event.pull_request.draft }} + run: | + set -euo pipefail + case "$BASE_REF" in + develop|release|published-results) + echo "Base branch '$BASE_REF' is an integration branch; the normal CI lanes apply." + ;; + *) + if [ "$IS_DRAFT" = "true" ]; then + echo "Draft PR stacked on '$BASE_REF'; it cannot be armed until it is retargeted at develop." + else + echo "::error::Ready PR base branch is '$BASE_REF', not develop, release or published-results." + echo "Only a draft PR may target a parent branch. After the parent merges, retarget this PR at develop and rebase it onto the squash commit." + exit 1 + fi + ;; + esac + + # Classifier: shared with the legacy path rules, plus the six-unit ownership map. ci-paths: name: ci-paths runs-on: ubuntu-latest @@ -30,12 +109,21 @@ jobs: unknown-paths: ${{ steps.classify.outputs.unknown-paths }} estimated-runner-minutes-saved: ${{ steps.classify.outputs.estimated-runner-minutes-saved }} packaging-needed: ${{ steps.classify.outputs.packaging-needed }} + framing-needed: ${{ steps.classify.outputs.framing-needed }} heavy-needed: ${{ steps.heavy.outputs.heavy-needed }} viz-needed: ${{ steps.classify.outputs.viz-needed }} explorer-vitest-needed: ${{ steps.classify.outputs.explorer-vitest-needed }} explorer-paths-needed: ${{ steps.classify.outputs.explorer-paths-needed }} explorer-tokens-needed: ${{ steps.classify.outputs.explorer-tokens-needed }} site-theme-needed: ${{ steps.classify.outputs.site-theme-needed }} + site-needed: ${{ steps.classify.outputs.site-needed }} + unit-core: ${{ steps.units.outputs.unit-core }} + unit-explorer: ${{ steps.units.outputs.unit-explorer }} + unit-results-data: ${{ steps.units.outputs.unit-results-data }} + unit-docs: ${{ steps.units.outputs.unit-docs }} + unit-landing: ${{ steps.units.outputs.unit-landing }} + unit-tooling: ${{ steps.units.outputs.unit-tooling }} + code-tests-needed: ${{ steps.units.outputs.code-tests-needed }} steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -47,6 +135,27 @@ jobs: # must run for every PR without tracker credentials or dependencies. run: python _project/scripts/todo_state_contract_check.py + - name: Select release candidate + id: release + env: + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + [[ "$EVENT_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid event base SHA" >&2; exit 1; } + git fetch --no-tags origin "$EVENT_BASE_SHA" + python -I -S scripts/release_flow.py select --base-sha "$EVENT_BASE_SHA" --github-output "$GITHUB_OUTPUT" + + - name: Install uv for release checks + if: steps.release.outputs.release-check-needed == 'true' + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Check release content + if: steps.release.outputs.release-check-needed == 'true' + env: + RELEASE_VERSION: ${{ steps.release.outputs.release-version }} + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: make release-check VERSION="$RELEASE_VERSION" BASE_REF="$EVENT_BASE_SHA" + - name: Fetch base branch run: git fetch --no-tags origin "${{ github.base_ref || 'develop' }}:refs/remotes/origin/${{ github.base_ref || 'develop' }}" @@ -59,17 +168,30 @@ jobs: # later develop merge may make this PR BEHIND, but must not change # the tree whose paths and manifest structure this run certifies. python scripts/path_filter_decision.py \ - --base-ref "${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}" \ + --base-ref "${{ github.event.pull_request.base.sha }}" \ --json-out ci-path-decision.json \ --lists-dir .ci-paths \ --github-output "$GITHUB_OUTPUT" \ --summary "$GITHUB_STEP_SUMMARY" + - name: Classify merge units + id: units + # Bare python is intentional: stdlib-only, no dependency sync. The + # classifier fails closed: a diff error or an unowned path runs core. + run: | + python scripts/ci_units.py \ + --base-ref "${{ github.event.pull_request.base.sha }}" \ + --json-out ci-unit-decision.json \ + --github-output "$GITHUB_OUTPUT" \ + --summary "$GITHUB_STEP_SUMMARY" + - name: Upload path decision uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ci-path-decision - path: ci-path-decision.json + path: | + ci-path-decision.json + ci-unit-decision.json retention-days: 30 - name: Decide heavy-tier need @@ -84,19 +206,20 @@ jobs: python scripts/heavy_tier_needed.py \ --decision-in ci-path-decision.json \ --event "${{ github.event_name }}" \ - --base-ref "${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}" \ + --base-ref "${{ github.event.pull_request.base.sha }}" \ --github-output "$GITHUB_OUTPUT" \ --summary "$GITHUB_STEP_SUMMARY" - # Pre-merge foreign-platform smoke coverage for the bundled TPC-H binaries. - # The merge queue runs direct macOS and Windows execution on every - # code-routed tree, while pull_request runs skip it unless a carve-out - # applies (merge_group event, soundness paths, or packaging paths via the - # heavy-needed output). Nightly retains the complete three-platform matrix. + # Cross-platform TPC-H binary framing: the only pre-merge macOS/Windows + # execution of the bundled dbgen framing guard. Pull requests run it when a + # carve-out applies (soundness or packaging paths via the heavy-needed + # output) or when the change touches test infrastructure or the bundled + # binaries (the framing-needed output). Nightly retains the complete + # three-platform matrix. tpch-binary-framing: name: TPC-H binary framing (${{ matrix.os }}) needs: ci-paths - if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' }} + if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' || needs.ci-paths.outputs.framing-needed == 'true' }} runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: @@ -149,7 +272,7 @@ jobs: # Bare python is intentional: the classifier is stdlib-only. run: | python scripts/path_filter_decision.py \ - --base-ref "${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}" \ + --base-ref "${{ github.event.pull_request.base.sha }}" \ --json-out ci-path-decision.json \ --lists-dir .ci-paths @@ -360,7 +483,7 @@ jobs: - name: Audit instruction authority and PR commit identity env: - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | python3 _project/scripts/agent_instruction_audit.py python3 _project/scripts/agent_instruction_audit.py --check-commit-range "$BASE_SHA" @@ -377,17 +500,51 @@ jobs: tests/unit/scripts/test_skill_sync_ci_policy.py \ tests/unit/scripts/test_path_filter_decision.py \ tests/unit/scripts/test_ci_lint_environment_boundary.py \ - tests/unit/workflows/test_pr_workflow_path_classifier.py \ tests/unit/scripts/test_agent_instruction_audit.py \ -q - name: Validate tracked artifact hygiene run: uv run -- python _project/scripts/artifact_hygiene_check.py --all-tracked + audit-sha: + name: audit-sha + needs: ci-paths + if: ${{ contains(needs.ci-paths.outputs.changed-paths, '_project/audits/') || contains(needs.ci-paths.outputs.changed-paths, 'results-explorer/e2e/captures/') }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + + - name: Fetch base branch + env: + BASE_REF: ${{ github.base_ref || 'develop' }} + run: git fetch --no-tags origin "$BASE_REF:refs/remotes/origin/$BASE_REF" + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Validate changed audit develop SHA stamps + shell: bash + run: | + set -euo pipefail + git diff --name-only --diff-filter=ACMRT "origin/${{ github.base_ref || 'develop' }}...HEAD" \ + | grep -E '^_project/audits/.*\.md$' > /tmp/changed-audits.txt || true + if [ ! -s /tmp/changed-audits.txt ]; then + echo "No changed audit Markdown files to validate." + exit 0 + fi + ANCESTRY_REF="HEAD" + while IFS= read -r audit_path; do + make audit-sha-check FILE="$audit_path" AUDIT_SHA_TARGET_REF="origin/${{ github.base_ref || 'develop' }}" AUDIT_SHA_ANCESTRY_REF="$ANCESTRY_REF" + done < /tmp/changed-audits.txt + + # Core: light tier. code-lint: name: lint needs: ci-paths - if: ${{ needs.ci-paths.outputs.needs-code-ci == 'true' }} + if: ${{ needs.ci-paths.outputs.code-tests-needed == 'true' }} runs-on: ubuntu-latest steps: - name: Checkout code @@ -414,16 +571,28 @@ jobs: # `continue-on-error: true` so one guard's failure doesn't hide the # others' results behind a stopped job. The job as a whole still # fails iff any guard failed -- see the `lint-guard-summary` step, - # which is what `ci-required-result` actually gates on. Setup steps + # which is what the `core` unit actually gates on. Setup steps # above (checkout/setup-python/setup-uv/install deps) are not # guards and carry neither `id: guard-*` nor `continue-on-error`. - name: Validate uv.lock revision against the PR base id: guard-uv-lock-revision continue-on-error: true env: - BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + BASE_REF: ${{ github.event.pull_request.base.sha }} run: make uv-lock-revision-check + - name: SQLGlot repro retirement check (runs repros on sqlglot upgrade) + id: guard-sqlglot-repro-retirement + continue-on-error: true + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + run: make sqlglot-repro-retirement-check + + - name: Stale _project/ reference check + id: guard-project-references + continue-on-error: true + run: uv run -- python _project/scripts/check_project_references.py + - name: Lint and format check with ruff id: guard-ruff continue-on-error: true @@ -486,29 +655,10 @@ jobs: continue-on-error: true run: uv run -- python _project/scripts/timing_policy_check.py --strict - - name: Restore fast-lane develop baseline count - # Not a guard itself (no `id: guard-*`/`continue-on-error` -- see the - # naming-convention comment above): a cache miss here is not a - # failure, it's the normal "no exact baseline yet" state that the delta - # guard below fails open on. The key is the PR event's actual develop - # base SHA; a prefix restore would compare the PR against an unrelated - # develop tip when merges or baseline jobs complete out of order. - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: fast-lane-count.txt - key: fast-lane-count-develop-${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} - - - name: Fast lane ceiling delta vs develop - # Additive to guard-timing-policy above, never a replacement: that - # step enforces the absolute max_fast_tests ceiling unconditionally. - # This guard fails open (exit 0, DELTA_CHECK_SKIPPED) when the cache - # restore above found no baseline -- see - # docs/operations/fast-lane-budget.md and the EXCLUDED_STEPS entry - # in tests/system/test_ci_lint_parity.py (the restored cache has no - # local equivalent). - id: guard-fast-lane-delta + - name: Fast lane marker and path guards + id: guard-fast-lane-markers continue-on-error: true - run: uv run -- python _project/scripts/timing_policy_check.py --delta-check --develop-count-file fast-lane-count.txt + run: uv run -- python _project/scripts/fast_lane_ceiling_check.py --strict - name: UAT spec LOC table drift check # Mirrors the release-branch Lint gate (.github/workflows/lint.yml). The @@ -578,7 +728,7 @@ jobs: id: guard-duplicate-delta continue-on-error: true env: - BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + BASE_REF: ${{ github.event.pull_request.base.sha }} run: make duplicate-check-delta - name: Complexity policy and report @@ -605,9 +755,6 @@ jobs: set -euo pipefail printf '%s\n' "$STEPS_CONTEXT" > "$RUNNER_TEMP/steps_context.json" - # FAIL CLOSED: the whole gate rests on this step's exit code, so a - # jq parse failure or an empty guard set must fail the job -- never - # fall through to "all passed" with zero guards inspected. ids=$(jq -r 'keys[] | select(startswith("guard-"))' "$RUNNER_TEMP/steps_context.json" | sort) || { echo "aggregator: jq failed to parse the steps context" >&2 exit 1 @@ -646,8 +793,9 @@ jobs: code-test: name: test (ubuntu-latest, 3.12) needs: ci-paths - if: ${{ needs.ci-paths.outputs.needs-code-ci == 'true' }} + if: ${{ needs.ci-paths.outputs.code-tests-needed == 'true' }} runs-on: ubuntu-latest + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -669,6 +817,12 @@ jobs: run: | uv sync --group dev + - name: Summarize Make interface changes + continue-on-error: true + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + run: python make/interface_summary.py --base-ref "$BASE_REF" --summary "$GITHUB_STEP_SUMMARY" + - name: Run cross-surface mutation target drift guard run: | uv run -- python -m pytest \ @@ -775,21 +929,77 @@ jobs: fail_ci_if_error: false # Don't fail CI if Codecov upload fails (token may not be configured) token: ${{ secrets.CODECOV_TOKEN }} - medium-test: - name: medium-test + # Core: heavy tier (merge queue, plus soundness/packaging carve-outs from heavy_tier_needed.py). + medium-collect: + name: medium-collect needs: ci-paths - if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' }} + if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + # Full history: the canary live-tree tests diff historical + # regression commits, which a shallow checkout cannot resolve. + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Collect the complete medium tier + shell: bash + run: | + set -euo pipefail + mkdir -p t2-artifacts + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest --collect-only -q -n 0 \ + -m "medium and not (slow or stress or resource_heavy or live_integration)" \ + > t2-artifacts/medium-collect.txt + count=$(grep -Eo '^[0-9]+/[0-9]+ tests collected' t2-artifacts/medium-collect.txt | tail -1 | cut -d/ -f1) + test -n "$count" + uv run -- python scripts/release_canary_sharding.py collect \ + --input t2-artifacts/medium-collect.txt \ + --nodeids-output t2-artifacts/medium-nodeids.txt \ + --summary-output t2-artifacts/medium-collection.json \ + --expected-count "$count" --shard-count 4 \ + --workflow ci.yml --job medium-collect \ + --marker-expression "medium and not (slow or stress or resource_heavy or live_integration)" \ + --checked-ref "${{ github.ref }}" --checked-sha "${{ github.sha }}" + + - name: Upload medium collection evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t2-medium-nodeids-${{ github.sha }} + path: t2-artifacts/ + if-no-files-found: error + retention-days: 30 + + medium-test: + name: medium-test (shard ${{ matrix.shard_index }}) + needs: [ci-paths, medium-collect] + if: ${{ !cancelled() && needs.medium-collect.result == 'success' && needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} runs-on: ubuntu-latest - # Sized to observed p95 + >=30% headroom, not to a value that never needs - # thinking about: this timeout is the only backstop against a hung medium - # tier. 2026-07-25: p95 29.9 min over the last 20 runs (8 of 19 within 48s - # of the old 30 cap) -> 29.9 * 1.3 = 38.9 -> 40. - # `make dev-loop-metrics` warns at 75% of this value. - # Rule, censoring caveat and resize history: docs/operations/fast-lane-budget.md - timeout-minutes: 40 + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + shard_index: [0, 1, 2, 3] steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + # Full history: the canary live-tree tests diff historical + # regression commits, which a shallow checkout cannot resolve. + fetch-depth: 0 - name: Set up Python 3.12 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -802,17 +1012,77 @@ jobs: - name: Install dependencies run: uv sync --group dev + - name: Download medium collection evidence + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: t2-medium-nodeids-${{ github.sha }} + path: t2-artifacts + + - name: Partition the complete medium tier + env: + SHARD_INDEX: ${{ matrix.shard_index }} + run: | + uv run -- python scripts/release_canary_sharding.py shard \ + --input t2-artifacts/medium-nodeids.txt \ + --nodeids-output "t2-artifacts/shard-${SHARD_INDEX}-nodeids.txt" \ + --summary-output "t2-artifacts/shard-${SHARD_INDEX}-summary.json" \ + --shard-index "$SHARD_INDEX" --shard-count 4 \ + --workflow ci.yml --job medium-test \ + --marker-expression "medium and not (slow or stress or resource_heavy or live_integration)" \ + --collection-summary t2-artifacts/medium-collection.json --checked-sha "${{ github.sha }}" + - name: Run medium speed tier - run: make test-medium + shell: bash + env: + SHARD_INDEX: ${{ matrix.shard_index }} + run: | + set -euo pipefail + test -s "t2-artifacts/shard-${SHARD_INDEX}-nodeids.txt" + mapfile -t node_ids < "t2-artifacts/shard-${SHARD_INDEX}-nodeids.txt" + sample_resources() { + while sleep 30; do + echo "[resources $(date -u +%H:%M:%S)] $(free -m | awk '/Mem:/ {print "used_mb=" $3 " available_mb=" $7}') load=$(cut -d' ' -f1-3 /proc/loadavg)" + ps -eo pid,rss,etimes,comm --sort=-rss | awk 'NR <= 4 {print " " $0}' + done + } + sample_resources & + sampler=$! + trap 'pkill -P "${sampler}" 2>/dev/null || true; kill "${sampler}" 2>/dev/null || true' EXIT + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest \ + -m "medium and not (slow or stress or resource_heavy or live_integration)" \ + --tb=short --timeout=60 -n 5 --durations=25 \ + -p scripts.pytest_shard_evidence \ + --assigned-nodeids "t2-artifacts/shard-${SHARD_INDEX}-nodeids.txt" \ + --shard-evidence "t2-artifacts/shard-${SHARD_INDEX}-execution.json" \ + --checked-sha "${{ github.sha }}" "${node_ids[@]}" + + - name: Record kernel memory events for the medium shard + if: always() + shell: bash + run: | + free -m + sudo dmesg -T 2>/dev/null | grep -iE 'out of memory|oom-kill|killed process' | tail -20 || true + + - name: Upload medium shard evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t2-medium-shard-${{ matrix.shard_index }}-${{ github.sha }} + path: t2-artifacts/ + if-no-files-found: error + retention-days: 30 correctness-gate: - name: correctness-gate + name: correctness-gate (${{ matrix.partition }}) needs: ci-paths - if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' }} + if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} runs-on: ubuntu-latest - # Nine enforced gates plus setup must fit: the FlightData promotion added - # the ninth gate step to this job. + # Preserve every gate; split measured work across standard Linux runners. timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + partition: [a, b] steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -834,47 +1104,83 @@ jobs: run: uv sync --group dev - name: Run bounded real-result correctness gate + if: matrix.partition == 'b' run: make test-correctness-gate - name: Run TPC-Havoc variant-equivalence gate + if: matrix.partition == 'b' run: make tpchavoc-equivalence-report - name: Run TPC-Havoc DataFrame variant-equivalence gate + if: matrix.partition == 'a' run: make tpchavoc-dataframe-equivalence-report - name: Run SSB cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' run: make ssb-cross-surface-equivalence-report - name: Run AMPLab cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' run: make amplab-cross-surface-equivalence-report - name: Run CoffeeShop cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' run: make coffeeshop-cross-surface-equivalence-report - name: Run ClickBench cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' run: make clickbench-cross-surface-equivalence-report - name: Run JoinOrder-synthetic cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' run: make joinorder-synthetic-cross-surface-equivalence-report - name: Run H2O-DB cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' run: make h2odb-cross-surface-equivalence-report - name: Run Read Primitives cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' run: make read-primitives-cross-surface-equivalence-report - name: Run FlightData cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' run: make flightdata-cross-surface-equivalence-report - name: Run Data Vault cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' run: make datavault-cross-surface-equivalence-report - plan-capture-gate: - name: plan-capture-gate + - name: Run NYC Taxi cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make nyctaxi-cross-surface-equivalence-report + + - name: Run TSBS DevOps cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make tsbs-devops-cross-surface-equivalence-report + + - name: Run TPC-H Skew cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make tpch-skew-cross-surface-equivalence-report + + - name: Run TPC-H cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make tpch-cross-surface-equivalence-report + + - name: Run TPC-DS cross-surface SQL<->DataFrame equivalence gate (Polars and DataFusion) + if: matrix.partition == 'b' + run: make tpcds-cross-surface-equivalence-report + + - name: Run TPC-DS cross-surface SQL<->DataFrame equivalence gate (pandas) + if: matrix.partition == 'a' + run: make tpcds-pandas-cross-surface-equivalence-report + + required-local-cases: + name: required-local-cases needs: ci-paths - if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' }} + if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -895,35 +1201,20 @@ jobs: - name: Install dependencies run: uv sync --group dev - - name: Run in-process plan-capture gate - run: make plan-capture-gate + - name: Run the required local-engine cases + run: make test-required-local-cases - postgres-integration: - name: postgres-integration + plan-capture-gate: + name: plan-capture-gate needs: ci-paths - if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' }} + if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 15 - # Non-blocking: service container startup can be flaky; failures surfaced as - # warnings rather than a gate block to avoid spurious PR failures. - continue-on-error: true - services: - postgres: - image: postgres:17 - env: - POSTGRES_USER: benchbox - POSTGRES_PASSWORD: benchbox - POSTGRES_DB: benchbox_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 5s - --health-timeout 5s - --health-retries 10 + timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 - name: Set up Python 3.12 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -933,71 +1224,19 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + - name: Create virtual environment + run: uv venv + - name: Install dependencies run: uv sync --group dev - - name: Wait for PostgreSQL service to be reachable - run: | - # The live tests skip via skip_unless_docker_service when localhost:5432 - # is unreachable, and pytest exits 0 on an all-skipped run. That would - # turn this non-blocking sample green without ever exercising PostgreSQL - # plan capture, indistinguishable from a real pass in the PR checks UI. - # Fail the step outright if the service container never came up so the - # continue-on-error job surfaces as visibly red instead. - for attempt in $(seq 1 30); do - if pg_isready -h localhost -p 5432 -U benchbox >/dev/null 2>&1; then - echo "PostgreSQL is reachable." - exit 0 - fi - echo "Waiting for PostgreSQL (attempt ${attempt}/30)..." - sleep 2 - done - echo "::error::PostgreSQL service never became reachable on localhost:5432; the live integration sample cannot be skipped silently." - exit 1 - - - name: Run PostgreSQL live integration tests - run: | - # --no-header keeps the summary line parseable; -p no:cacheprovider - # avoids a stale cache masking collection. The grep guard fails the - # step when the run collected/ran nothing but skips (e.g. every test - # skipped despite a reachable service), so an all-skipped run cannot - # silently render as green in the PR checks UI. - set -o pipefail - uv run -- python -m pytest tests/integration/platforms/test_postgresql_live.py \ - -m "live_postgresql" --tb=short -v -p no:cacheprovider \ - | tee /tmp/pg_live_output.txt - if ! grep -Eq '[0-9]+ passed' /tmp/pg_live_output.txt; then - echo "::error::PostgreSQL live integration tests produced no passing tests (all skipped or none collected); refusing to report a silent pass." - exit 1 - fi - - # Second-engine equivalence SAMPLE (non-blocking, see continue-on-error - # above). The DuckDB SQL gate (correctness-gate job) stays the hard - # blocker; this samples TPC-Havoc variant equivalence one engine over to - # catch translation-induced divergence. Both canonical and variants are - # translated to the postgres dialect and compared on this same instance. - - name: Run TPC-Havoc PostgreSQL variant-equivalence sample - env: - PGHOST: localhost - PGPORT: '5432' - PGUSER: benchbox - PGPASSWORD: benchbox - PGDATABASE: benchbox_test - run: | - set -o pipefail - uv run -- python -m pytest \ - tests/integration/platforms/test_tpchavoc_postgres_equivalence.py \ - -m "live_postgresql" -n 0 --tb=short -v -p no:cacheprovider \ - | tee /tmp/pg_tpchavoc_output.txt - if ! grep -Eq '[0-9]+ passed' /tmp/pg_tpchavoc_output.txt; then - echo "::error::TPC-Havoc PostgreSQL variant-equivalence sample produced no passing tests (all skipped or none collected); refusing to report a silent pass." - exit 1 - fi + - name: Run in-process plan-capture gate + run: make plan-capture-gate datafusion-integration: name: datafusion-integration needs: ci-paths - if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' }} + if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 # Non-blocking: this is the THIRD-engine equivalence SAMPLE, not a gate. @@ -1034,87 +1273,6 @@ jobs: tests/integration/platforms/test_datafusion_tbl_load.py \ -m "slow" -n 0 --tb=short -v - clickhouse-integration: - name: clickhouse-integration - needs: ci-paths - if: ${{ needs.ci-paths.outputs.heavy-needed == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - # Non-blocking: this is the FOURTH-engine equivalence SAMPLE, not a gate. - # The DuckDB SQL gate (correctness-gate job) stays the hard blocker. - # ClickHouse runs in-process via chDB (clickhouse-local), no service container, - # and is the first sampled engine on a NATIVE non-Postgres SQLGlot dialect, so - # this samples translation-induced divergence through a different seam path. - # Unlike the other three engines it is NOT systematic-zero - it surfaces real - # engine-semantic result differences classified in CLICKHOUSE_KNOWN_DIVERGENCES. - # continue-on-error keeps it a sample, not a blocker. - continue-on-error: true - steps: - - name: Checkout code - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - - name: Set up Python 3.12 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Install dependencies - run: uv sync --group dev - - # Fourth-engine equivalence SAMPLE. Both canonical and variants are - # translated to the NATIVE clickhouse dialect and compared on the same - # in-process chDB instance (shared translation cancels out); - # CLICKHOUSE_TPCHAVOC_SKIPS variants are excluded, never marked equivalent, - # and the residue is classified against CLICKHOUSE_KNOWN_DIVERGENCES. The - # live sweep is tpchavoc + slow-marked; `-m "tpchavoc"` with a pinned path - # selects it (overriding the default fast-lane deselect). - - name: Run TPC-Havoc ClickHouse variant-equivalence sample - run: | - uv run -- python -m pytest \ - tests/integration/platforms/test_tpchavoc_clickhouse_equivalence.py \ - -m "tpchavoc" -n 0 --tb=short -v - - explorer-tokens: - name: explorer-tokens - needs: ci-paths - if: ${{ needs.ci-paths.outputs.explorer-paths-needed == 'true' }} - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - fetch-depth: 0 - - - name: Fetch base branch - run: git fetch --no-tags origin "${{ github.base_ref || 'develop' }}:refs/remotes/origin/${{ github.base_ref || 'develop' }}" - - - name: Run explorer token scan - # Bare python is intentional: the scan is stdlib-only so this gate - # does not pay a dependency-sync cost before deciding. - run: make lint-explorer-tokens - - site-theme-tokens: - name: site-theme-tokens - needs: ci-paths - if: ${{ needs.ci-paths.outputs.site-theme-needed == 'true' }} - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - fetch-depth: 0 - - - name: Fetch base branch - run: git fetch --no-tags origin "${{ github.base_ref || 'develop' }}:refs/remotes/origin/${{ github.base_ref || 'develop' }}" - - - name: Run public site theme token scan - # Bare python is intentional: the scan is stdlib-only so this gate - # does not pay a dependency-sync cost before deciding. - run: make lint-site-theme-tokens - package-smoke: name: package-smoke needs: ci-paths @@ -1158,6 +1316,10 @@ jobs: # contract whose absence let v0.3.0 ship a broken clean install. uv run --isolated --no-project --with "$wheel" -- python -c "import importlib.util; import benchbox; assert importlib.util.find_spec('pandas') is None, 'core install must not pull pandas'; print('Package installation successful (minimal import surface)')" uv run --isolated --no-project --with "$wheel" -- benchbox --help + # Wheel-truth gate for TPC-DS-OBT templates: the 0.4.1 wheel shipped + # without top-level _sources/, so the OBT template dir resolved into + # site-packages/_sources and get_queries() raised FileNotFoundError. + uv run --isolated --no-project --with "$wheel" -- python -c "from benchbox import TPCDSOBT; benchmark = TPCDSOBT(scale_factor=1.0); queries = benchmark.get_queries(); assert queries, 'expected OBT queries from wheel install'; assert benchmark.get_query(next(iter(queries))); print(f'TPC-DS-OBT wheel smoke: {len(queries)} queries')" dependency-audit: name: dependency-audit @@ -1207,6 +1369,26 @@ jobs: uv run --with pip-audit -- pip-audit fi + # Explorer. + explorer-tokens: + name: explorer-tokens + needs: ci-paths + if: ${{ needs.ci-paths.outputs.explorer-paths-needed == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + + - name: Fetch base branch + run: git fetch --no-tags origin "${{ github.base_ref || 'develop' }}:refs/remotes/origin/${{ github.base_ref || 'develop' }}" + + - name: Run explorer token scan + # Bare python is intentional: the scan is stdlib-only so this gate + # does not pay a dependency-sync cost before deciding. + run: make lint-explorer-tokens + explorer-vitest: name: explorer-vitest needs: ci-paths @@ -1256,7 +1438,7 @@ jobs: # locally, so it is cheap enough to block develop PRs. # # PROMOTED TO THE REQUIRED LANE 2026-07-05 (parity-check-promotion): the - # job is now observed by ci-required-result via PARITY_CHECK_RESULT with + # job is now observed by the explorer unit via PARITY_CHECK_RESULT with # the package-smoke skipped-counts-as-pass rule, and continue-on-error is # dropped. Both promotion criteria met: (1) one green run on a real # GitHub runner (PR #973, run 28742318825 job 85227174150 - the runner's @@ -1286,38 +1468,78 @@ jobs: - name: Verify parity fixtures match Python source run: make parity-check - publication-reconciliation: - name: publication reconciliation + explorer-e2e: needs: ci-paths - # Run for any code PR and also for decision/publication script changes, - # which are classified as safe-content and would otherwise skip code CI. - if: ${{ needs.ci-paths.outputs.needs-code-ci == 'true' || contains(needs.ci-paths.outputs.changed-paths, '_project/decisions/') || contains(needs.ci-paths.outputs.changed-paths, 'scripts/publication/') }} + name: Chromium (full suite, blocking) + if: ${{ needs.ci-paths.outputs.unit-explorer == 'true' }} runs-on: ubuntu-latest + timeout-minutes: 25 + steps: - - name: Checkout code - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - fetch-depth: 0 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - name: Set up Python 3.12 + - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: '3.12' + python-version: '3.11' - name: Install uv uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - name: Install dependencies + - name: Install Python dependencies run: uv sync --group dev - - name: Run publication plan reconciliation (live Git state) - run: | - uv run python scripts/publication/check_plan_reconciliation.py --todo-prefix independent-publication- + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: results-explorer/package-lock.json - audit-sha: - name: audit-sha + - name: Install explorer dependencies + run: npm ci + working-directory: results-explorer + + - name: Typecheck explorer e2e harness + run: npm run typecheck:e2e + working-directory: results-explorer + + - name: Audit frontend dependencies + run: npm run audit:high + working-directory: results-explorer + + - name: Cache Playwright browsers + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('results-explorer/package-lock.json') }} + + - name: Install Playwright (Chromium) + run: npx playwright install --with-deps chromium + working-directory: results-explorer + + - name: Run Chromium suite + # Shared deterministic entrypoint: regenerate fixtures, rebuild dist, + # then run the blocking Chromium project against the real server. + run: npm run test:e2e:chromium + working-directory: results-explorer + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: playwright-report-chromium + path: | + results-explorer/playwright-report + results-explorer/test-results + if-no-files-found: ignore + retention-days: 3 + + # Landing. + site-theme-tokens: + name: site-theme-tokens needs: ci-paths - if: ${{ contains(needs.ci-paths.outputs.changed-paths, '_project/audits/') || contains(needs.ci-paths.outputs.changed-paths, 'results-explorer/e2e/captures/') }} + if: ${{ needs.ci-paths.outputs.site-theme-needed == 'true' }} runs-on: ubuntu-latest steps: - name: Checkout code @@ -1326,391 +1548,675 @@ jobs: fetch-depth: 0 - name: Fetch base branch - env: - BASE_REF: ${{ github.base_ref || 'develop' }} - run: git fetch --no-tags origin "$BASE_REF:refs/remotes/origin/$BASE_REF" + run: git fetch --no-tags origin "${{ github.base_ref || 'develop' }}:refs/remotes/origin/${{ github.base_ref || 'develop' }}" + + - name: Run public site theme token scan + # Bare python is intentional: the scan is stdlib-only so this gate + # does not pay a dependency-sync cost before deciding. + run: make lint-site-theme-tokens - - name: Fetch PR head for audit ancestry (merge queue only) - # Merge-queue runs check out a squashed preview commit whose - # single-parent history never contains the PR's branch commits, so an - # audit measured on the branch tree cannot descend from HEAD. Fetch - # the PR head (parsed from the queue's gh-readonly-queue/.../pr-N-... - # branch name) and validate measurement ancestry against it instead. - # Falls back to HEAD with a warning if the PR number cannot be parsed. - if: github.event_name == 'merge_group' + # Results data. + results-data-check: + name: results-data-check + needs: ci-paths + if: ${{ needs.ci-paths.outputs.unit-results-data == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + - name: Install dependencies + run: uv sync --group dev + - name: Corpus inventory is current + run: uv run -- python scripts/generate_corpus_inventory.py --check + - name: Corpus validation + run: uv run -- python results-data/validate_corpus.py + - name: Submission validator stays in sync + # Judges this change, not external branch state: fail only when the + # change touches the validator or its checker. Standing drift against + # published-results is reported by submission-validator-drift-check.yml + # (weekly and on develop pushes), so it cannot block unrelated changes. + if: ${{ contains(needs.ci-paths.outputs.changed-paths, '.github/workflows/validate-submission.yml') || contains(needs.ci-paths.outputs.changed-paths, 'scripts/check_submission_validator_sync.py') }} + run: | + git fetch --no-tags --depth=1 origin published-results + git show FETCH_HEAD:.github/workflows/validate-submission.yml \ + > /tmp/published-validate-submission.yml + uv run -- python scripts/check_submission_validator_sync.py \ + --develop .github/workflows/validate-submission.yml \ + --published /tmp/published-validate-submission.yml + - name: Corpus, submission, and explorer-pipeline contract tests + run: | + uv run -- python -m pytest -p no:cacheprovider -o addopts='' -q \ + tests/unit/scripts/test_generate_corpus_inventory.py \ + tests/unit/scripts/test_validate_submission.py \ + tests/unit/scripts/explorer_pipeline \ + tests/unit/workflows/test_corpus_trust_boundary.py \ + tests/unit/publication \ + tests/unit/explorer + + # Docs and landing: the public-site build and exact-base visual comparison. + visual-inputs: + needs: ci-paths + name: Public-site visual inputs + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.paths.outputs.changed }} + render_changed: ${{ steps.paths.outputs.render_changed }} + source_sha: ${{ steps.paths.outputs.source_sha }} + base_sha: ${{ steps.paths.outputs.base_sha }} + baseline_candidates: ${{ steps.paths.outputs.baseline_candidates }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Classify site inputs and validate recovery source + id: paths + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + RECOVERY_SOURCE_SHA: ${{ inputs.baseline_source_sha }} + CURRENT_SHA: ${{ github.sha }} + CURRENT_REF: ${{ github.ref }} + shell: bash run: | set -euo pipefail - if [[ "${GITHUB_REF:-}" =~ /pr-([0-9]+)- ]]; then - git fetch --no-tags origin "pull/${BASH_REMATCH[1]}/head:refs/remotes/origin/audit-ancestry-pr-head" + SOURCE_SHA="$CURRENT_SHA" + if [ -n "$RECOVERY_SOURCE_SHA" ]; then + if [ "$EVENT_NAME" != workflow_dispatch ] || [ "$CURRENT_REF" != refs/heads/develop ] || [[ ! "$RECOVERY_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Recovery requires a full lowercase SHA dispatched on develop." + exit 1 + fi + git fetch origin develop + git merge-base --is-ancestor "$RECOVERY_SOURCE_SHA" origin/develop || { + echo "::error::Recovery source must be an ancestor of protected develop." + exit 1 + } + SOURCE_SHA="$RECOVERY_SOURCE_SHA" + fi + echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + BASE_SHA="$PR_BASE_SHA" + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" != pull_request ]; then + echo 'changed=true' >> "$GITHUB_OUTPUT" + echo 'render_changed=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Missing exact PR base SHA." + exit 1 + fi + git cat-file -e "$BASE_SHA^{commit}" || { + echo "::error::Exact base commit is unavailable." + exit 1 + } + # Preserve every former pull_request path-filter input. A change to + # this list must explain which rendered dependency is being removed. + # The build runs uv sync and npm ci, so the lockfiles pin the + # rendered output as much as any source file. uv.lock and + # results-explorer/package-lock.json must stay in this list. + SITE_PATHS=( + benchbox/ docs/ examples/ landing/ website/ + _project/scripts/explorer_pipeline/ + _project/scripts/explorer_publish.py + _project/scripts/results_explorer_snapshot_invariants.py + results-data/ results-explorer/ scripts/ + .github/workflows/ci.yml .github/workflows/docs.yml Makefile README.md pyproject.toml uv.lock + ) + # The build (Sphinx warnings-as-errors, autodoc) keeps the broad list + # above. The visual comparison captures only the routes below, so it + # and the baseline ancestor walk use the narrow rendered-input list. + # Keep this a subset of SITE_PATHS. + RENDER_PATHS=( + docs/ landing/ examples/ results-data/ results-explorer/ website/ + _project/scripts/explorer_pipeline/ + _project/scripts/explorer_publish.py + _project/scripts/results_explorer_snapshot_invariants.py + scripts/assemble_public_site.py scripts/generate_landing_quickstarts.py + scripts/generate_query_docs.py scripts/generate_compat_docs.py + benchbox/__init__.py benchbox/cli/ benchbox/core/platform_registry.py + benchbox/core/benchmark_registry.py benchbox/platforms/manifest.py + .github/workflows/ci.yml .github/workflows/docs.yml README.md pyproject.toml uv.lock + ) + CHANGED_PATHS=$(git diff --name-only "$BASE_SHA"...HEAD -- "${SITE_PATHS[@]}") + if [ -z "$CHANGED_PATHS" ]; then + echo 'changed=false' >> "$GITHUB_OUTPUT" + else + echo 'changed=true' >> "$GITHUB_OUTPUT" + fi + RENDER_CHANGED_PATHS=$(git diff --name-only "$BASE_SHA"...HEAD -- "${RENDER_PATHS[@]}") + if [ -z "$RENDER_CHANGED_PATHS" ]; then + echo 'render_changed=false' >> "$GITHUB_OUTPUT" else - echo "::warning::Could not parse PR number from ${GITHUB_REF:-}; audit measurement ancestry falls back to HEAD." + echo 'render_changed=true' >> "$GITHUB_OUTPUT" fi + # Baselines bound to the exact base come first. A first-parent + # ancestor is also a valid baseline source when every public-site + # input is byte-identical to the base, because it renders the same + # site; this is the premise that lets unchanged trees skip the gate. + # The walk stops at the first ancestor whose site inputs differ, so a + # differently rendered tree is never used. This covers a base whose + # predecessor changed no site inputs and therefore published nothing. + CANDIDATES="$BASE_SHA" + ANCESTORS=$(git rev-list --first-parent --max-count=25 "$BASE_SHA^" 2>/dev/null || true) + for ANCESTOR in $ANCESTORS; do + git diff --quiet "$ANCESTOR" "$BASE_SHA" -- "${RENDER_PATHS[@]}" || break + CANDIDATES="$CANDIDATES $ANCESTOR" + done + echo "baseline_candidates=$CANDIDATES" >> "$GITHUB_OUTPUT" + + docs-build: + needs: visual-inputs + if: needs.visual-inputs.outputs.changed == 'true' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ needs.visual-inputs.outputs.source_sha }} + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" - name: Install uv uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - name: Validate changed audit develop SHA stamps - shell: bash + - name: Install dependencies + run: uv sync --group dev + + - name: Cache Sphinx build artifacts + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + docs/_build + key: ${{ runner.os }}-sphinx-${{ hashFiles('docs/**', 'pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-sphinx- + + - name: Build documentation + # -W turns every Sphinx warning into a failure, including unknown + # :doc: targets, so a broken cross-reference fails the docs unit + # instead of shipping. --keep-going still reports every warning. run: | - set -euo pipefail - git diff --name-only --diff-filter=ACMRT "origin/${{ github.base_ref || 'develop' }}...HEAD" \ - | grep -E '^_project/audits/.*\.md$' > /tmp/changed-audits.txt || true - if [ ! -s /tmp/changed-audits.txt ]; then - echo "No changed audit Markdown files to validate." - exit 0 - fi - ANCESTRY_REF="HEAD" - if [ "${{ github.event_name }}" = "merge_group" ] && git rev-parse --verify --quiet origin/audit-ancestry-pr-head >/dev/null; then - ANCESTRY_REF="origin/audit-ancestry-pr-head" - fi - while IFS= read -r audit_path; do - make audit-sha-check FILE="$audit_path" AUDIT_SHA_TARGET_REF="origin/${{ github.base_ref || 'develop' }}" AUDIT_SHA_ANCESTRY_REF="$ANCESTRY_REF" - done < /tmp/changed-audits.txt + cd docs + uv run sphinx-build -b html -W --keep-going . _build/html + + # --- Results Explorer --- + # Steps gate on package.json as a sentinel so the workflow degrades + # gracefully if the explorer directory is ever removed or renamed. + # The curated release retains results-explorer/, results-data/, and the + # three publication helpers under _project/scripts/. A release that + # includes the application must carry the complete publication input set. + - name: Validate results-explorer lockfile + if: hashFiles('results-explorer/package.json') != '' + run: | + test -f results-explorer/package-lock.json || { + echo "results-explorer/package-lock.json is required for npm ci" >&2 + exit 1 + } - certification-identity: - name: certification-identity - needs: ci-paths + - name: Set up Node.js + if: hashFiles('results-explorer/package.json') != '' + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: results-explorer/package-lock.json + + - name: Install results-explorer dependencies + if: hashFiles('results-explorer/package.json') != '' + run: npm ci + working-directory: results-explorer + + - name: Verify curated Explorer publication inputs + if: hashFiles('results-explorer/package.json') != '' + run: | + test -d results-data/bundles + test -d _project/scripts/explorer_pipeline + test -f _project/scripts/explorer_publish.py + test -f _project/scripts/results_explorer_snapshot_invariants.py + + - name: Typecheck results-explorer + if: hashFiles('results-explorer/package.json') != '' + run: npm run typecheck + working-directory: results-explorer + + - name: Build explorer data (static pipeline) + if: hashFiles('results-explorer/package.json') != '' + run: uv run -- python _project/scripts/explorer_publish.py build --data-dir results-data/ --output results-explorer/public/data/ + + # Gate the built read model before it is bundled into the deployed site. + - name: Validate explorer snapshot invariants + if: hashFiles('results-explorer/package.json') != '' + run: | + test -s results-explorer/public/data/results.duckdb + uv run -- python _project/scripts/results_explorer_snapshot_invariants.py results-explorer/public/data/results.duckdb + + - name: Build results-explorer + if: hashFiles('results-explorer/package.json') != '' + run: npm run build + working-directory: results-explorer + + - name: Assemble public site + run: uv run -- python scripts/assemble_public_site.py --site-dir site + + - name: Scan assembled site for privacy leaks + run: uv run python scripts/publication/check_artifact_privacy.py site + + - name: Upload HTML artifacts + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + include-hidden-files: true + + # The visual job consumes the exact assembled tree without rebuilding it. + # This is a short-lived transport artifact, not a publication baseline. + - name: Upload assembled site for visual acceptance + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: public-site-assembled-${{ github.run_id }} + path: site + retention-days: 3 + + public-site-visual-regression: + name: Public-site visual regression + if: ${{ needs.visual-inputs.outputs.render_changed == 'true' && github.event_name == 'pull_request' }} + needs: [visual-inputs, docs-build] runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 30 steps: - - name: Checkout code - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: - fetch-depth: 0 - persist-credentials: false + ref: ${{ needs.visual-inputs.outputs.source_sha }} - - name: Record certification identity + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: results-explorer/package-lock.json + + - name: Download assembled site + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: public-site-assembled-${{ github.run_id }} + path: site + + - name: Install explorer dependencies + run: npm ci + working-directory: results-explorer + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + working-directory: results-explorer + + - name: Capture public site env: - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} - SYNTHETIC_MERGE_SHA: ${{ github.sha }} - RUN_ID: ${{ github.run_id }} - NEEDS_CODE_CI: ${{ needs.ci-paths.outputs.needs-code-ci }} - SKILL_INTEGRITY_NEEDED: ${{ needs.ci-paths.outputs.skill-integrity-needed }} - CONTENT_GUARD_NEEDED: ${{ needs.ci-paths.outputs.content-guard-needed }} - HEAVY_NEEDED: ${{ needs.ci-paths.outputs.heavy-needed }} - run: | - set -euo pipefail - python - <<'PY' - import hashlib - import json - import os - import subprocess - from pathlib import Path + E2E_PAGES_SHAPED: "1" + E2E_SITE_DIR: ${{ github.workspace }}/site + PUBLIC_SITE_VISUAL_PHASE: capture + PUBLIC_SITE_VISUAL_OUTPUT: ${{ runner.temp }}/public-site-visual-current + PUBLIC_SITE_VISUAL_SOURCE_SHA: ${{ needs.visual-inputs.outputs.source_sha }} + run: npm run test:e2e:public-site + working-directory: results-explorer - def git(*args: str) -> str: - return subprocess.check_output(["git", *args], text=True).strip() + # When no baseline is bound to the exact base, the lookup also accepts the + # nearest ancestor whose site inputs are byte-identical to the base. + - name: Download visual baseline for base or site-equivalent ancestor + id: baseline + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PUBLIC_SITE_VISUAL_BASE_SHA: ${{ needs.visual-inputs.outputs.base_sha }} + PUBLIC_SITE_VISUAL_BASELINE_CANDIDATES: ${{ needs.visual-inputs.outputs.baseline_candidates }} + PUBLIC_SITE_VISUAL_BASELINE: ${{ runner.temp }}/public-site-visual-baseline + run: node scripts/download-public-site-visual-baseline.mjs + working-directory: results-explorer - head = os.environ["HEAD_SHA"] - base = os.environ["BASE_SHA"] - merge = os.environ["SYNTHETIC_MERGE_SHA"] - files = [ - ".github/workflows/pr.yml", - ".github/workflows/results-explorer-browser.yml", - ".github/workflows/develop-ruleset-drift.yml", - ] - entries = {} - for path in files: - data = Path(path).read_bytes() if Path(path).is_file() else b"" - entries[path] = hashlib.sha256(data).hexdigest() - fingerprint = hashlib.sha256( - "\n".join(f"{path}={entries[path]}" for path in sorted(entries)).encode() - ).hexdigest() - needs_code_ci = os.environ["NEEDS_CODE_CI"] == "true" - skill_integrity_needed = os.environ["SKILL_INTEGRITY_NEEDED"] == "true" - content_guard_needed = os.environ["CONTENT_GUARD_NEEDED"] == "true" - heavy_needed = os.environ.get("HEAVY_NEEDED") == "true" - # A PR run without the heavy tier must not claim "full": only - # "full" binds prior certification, so a light-lane code run - # claims "fast" (non-binding, like skill_integrity/content). - if needs_code_ci and heavy_needed: - certification_kind = "full" - elif needs_code_ci: - certification_kind = "fast" - elif skill_integrity_needed: - certification_kind = "skill_integrity" - else: - certification_kind = "content" - selected_lanes = [ - lane - for lane, selected in ( - ("content", content_guard_needed), - ("product-code", needs_code_ci), - ("skill-integrity", skill_integrity_needed), - ) - if selected - ] - record = { - "pr_head_sha": head, - "base_sha": base, - "synthetic_merge_sha": merge, - "synthetic_merge_tree": git("rev-parse", "HEAD^{tree}"), - "workflow_fingerprint": fingerprint, - "run_id": int(os.environ["RUN_ID"]), - "certification_kind": certification_kind, - "selected_lanes": selected_lanes, - } - Path("pr-certification-identity.json").write_text( - json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" - ) - PY + - name: Compare public site with exact base + if: github.event_name == 'pull_request' + env: + E2E_PAGES_SHAPED: "1" + E2E_SITE_DIR: ${{ github.workspace }}/site + PUBLIC_SITE_VISUAL_PHASE: compare + PUBLIC_SITE_VISUAL_OUTPUT: ${{ runner.temp }}/public-site-visual-current + PUBLIC_SITE_VISUAL_BASELINE: ${{ runner.temp }}/public-site-visual-baseline + PUBLIC_SITE_VISUAL_BASE_SHA: ${{ steps.baseline.outputs.baseline_sha }} + PUBLIC_SITE_VISUAL_REQUIRE_BASELINE: "1" + PUBLIC_SITE_VISUAL_SOURCE_SHA: ${{ needs.visual-inputs.outputs.source_sha }} + PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} + APPROVED_HEAD_SHA: ${{ github.event_name == 'pull_request' && vars.APPROVED_HEAD_SHA || '' }} + APPROVAL_REASON: ${{ github.event_name == 'pull_request' && vars.APPROVAL_REASON || '' }} + run: npm run test:e2e:public-site + working-directory: results-explorer - - name: Upload certification identity - if: always() + - name: Upload protected-develop visual baseline + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/develop') || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: pr-certification-identity - path: pr-certification-identity.json - if-no-files-found: warn + name: public-site-visual-baseline-${{ needs.visual-inputs.outputs.source_sha }} + path: ${{ runner.temp }}/public-site-visual-current retention-days: 30 - ci-required-result: - name: ci-required-result - needs: [ci-paths, tpch-binary-framing, content-guard, skill-integrity, code-lint, code-test, correctness-gate, plan-capture-gate, medium-test, explorer-tokens, site-theme-tokens, explorer-vitest, audit-sha, package-smoke, dependency-audit, parity-check, publication-reconciliation] - if: always() + - name: Upload visual diagnostics + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: public-site-visual-diagnostics-${{ github.run_id }} + path: | + ${{ runner.temp }}/public-site-visual-current + results-explorer/test-results + results-explorer/playwright-report + if-no-files-found: ignore + retention-days: 3 + + site-build: + name: site-build + needs: ci-paths + if: ${{ needs.ci-paths.outputs.site-needed == 'true' }} runs-on: ubuntu-latest + timeout-minutes: 30 + steps: - - name: Aggregate required result - env: - CI_PATHS_RESULT: ${{ needs.ci-paths.result }} - TPCH_BINARY_FRAMING_RESULT: ${{ needs.tpch-binary-framing.result }} - CONTENT_RESULT: ${{ needs.content-guard.result }} - SKILL_INTEGRITY_RESULT: ${{ needs.skill-integrity.result }} - LINT_RESULT: ${{ needs.code-lint.result }} - TEST_RESULT: ${{ needs.code-test.result }} - CORRECTNESS_RESULT: ${{ needs.correctness-gate.result }} - PLAN_CAPTURE_RESULT: ${{ needs.plan-capture-gate.result }} - MEDIUM_TEST_RESULT: ${{ needs.medium-test.result }} - EXPLORER_TOKENS_RESULT: ${{ needs.explorer-tokens.result }} - SITE_THEME_TOKENS_RESULT: ${{ needs.site-theme-tokens.result }} - EXPLORER_VITEST_RESULT: ${{ needs.explorer-vitest.result }} - AUDIT_SHA_RESULT: ${{ needs.audit-sha.result }} - PACKAGE_SMOKE_RESULT: ${{ needs.package-smoke.result }} - DEPENDENCY_AUDIT_RESULT: ${{ needs.dependency-audit.result }} - PARITY_CHECK_RESULT: ${{ needs.parity-check.result }} - PUBLICATION_RECONCILIATION_RESULT: ${{ needs.publication-reconciliation.result }} - CONTENT_GUARD_NEEDED: ${{ needs.ci-paths.outputs.content-guard-needed }} - SKILL_INTEGRITY_NEEDED: ${{ needs.ci-paths.outputs.skill-integrity-needed }} - NEEDS_CODE_CI: ${{ needs.ci-paths.outputs.needs-code-ci }} - SAFE_CONTENT_ONLY: ${{ needs.ci-paths.outputs.safe-content-only }} - HEAVY_NEEDED: ${{ needs.ci-paths.outputs.heavy-needed }} - run: | - set -euo pipefail + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 - if [ "$CI_PATHS_RESULT" != "success" ]; then - echo "ci-paths=$CI_PATHS_RESULT" - exit 1 - fi + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.11" - # Third-state heavy tier: when heavy-needed is true the tier must - # have run to success; when false each heavy job must read - # "skipped" (it never ran); anything else fails. A skipped - # required check counts as passing, so the umbrella models the - # skip explicitly instead of trusting it. - check_heavy() { - local name="$1" result="$2" - if [ "$HEAVY_NEEDED" = "true" ]; then - if [ "$result" != "success" ]; then - echo "$name=$result (heavy tier was required)" - exit 1 - fi - else - if [ "$result" != "skipped" ]; then - echo "$name=$result (heavy tier was not required; expected skipped)" - exit 1 - fi - fi - } + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 - if [ "$NEEDS_CODE_CI" = "true" ]; then - check_heavy tpch-binary-framing "$TPCH_BINARY_FRAMING_RESULT" - elif [ "$TPCH_BINARY_FRAMING_RESULT" != "success" ] \ - && [ "$TPCH_BINARY_FRAMING_RESULT" != "skipped" ]; then - echo "tpch-binary-framing=$TPCH_BINARY_FRAMING_RESULT" - exit 1 - fi + - name: Install dependencies + run: uv sync --group dev - if [ "$CONTENT_GUARD_NEEDED" = "true" ] && [ "$CONTENT_RESULT" != "success" ]; then - echo "content-guard=$CONTENT_RESULT" - exit 1 - fi + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: | + results-explorer/package-lock.json + website/package-lock.json - if [ "$SKILL_INTEGRITY_NEEDED" = "true" ]; then - if [ "$SKILL_INTEGRITY_RESULT" != "success" ]; then - echo "skill-integrity=$SKILL_INTEGRITY_RESULT" - exit 1 - fi - elif [ "$SKILL_INTEGRITY_RESULT" != "skipped" ] \ - && [ "$SKILL_INTEGRITY_RESULT" != "success" ]; then - echo "unexpected skill-integrity result=$SKILL_INTEGRITY_RESULT" - exit 1 - fi + - name: Install results-explorer dependencies + run: npm ci + working-directory: results-explorer - if [ "$AUDIT_SHA_RESULT" != "success" ] \ - && [ "$AUDIT_SHA_RESULT" != "skipped" ]; then - echo "audit-sha=$AUDIT_SHA_RESULT" - exit 1 - fi + - name: Build explorer data + run: uv run -- python _project/scripts/explorer_publish.py build --data-dir results-data/ --output results-explorer/public/data/ - # package-smoke/dependency-audit are gated on the packaging path - # filter. A "skipped" result (the filter did not match) counts as - # success - a PR that never touched packaging paths must still - # pass. A PR that DID touch those paths gets a real success/failure - # result from the job itself, so this cannot silently green a - # packaging regression (pr-gate-package-and-audit-promotion.yaml). - if [ "$PACKAGE_SMOKE_RESULT" != "success" ] \ - && [ "$PACKAGE_SMOKE_RESULT" != "skipped" ]; then - echo "package-smoke=$PACKAGE_SMOKE_RESULT" - exit 1 - fi + - name: Build results-explorer + run: npm run build + working-directory: results-explorer - if [ "$DEPENDENCY_AUDIT_RESULT" != "success" ] \ - && [ "$DEPENDENCY_AUDIT_RESULT" != "skipped" ]; then - echo "dependency-audit=$DEPENDENCY_AUDIT_RESULT" - exit 1 - fi + - name: Install website dependencies + run: npm ci + working-directory: website - # parity-check is gated on the viz path filter with the same - # skipped-counts-as-pass rule (promoted 2026-07-05, - # parity-check-promotion): a non-viz PR skips it and passes, a - # viz-touching PR gets a real success/failure so a CLI-vs-explorer - # numeric parity regression now blocks merge instead of showing - # red-but-mergeable. - if [ "$PARITY_CHECK_RESULT" != "success" ] \ - && [ "$PARITY_CHECK_RESULT" != "skipped" ]; then - echo "parity-check=$PARITY_CHECK_RESULT" - exit 1 - fi + - name: Typecheck, audit and build website + run: make site-check site-build - if [ "$NEEDS_CODE_CI" = "true" ] && [ "$PUBLICATION_RECONCILIATION_RESULT" != "success" ]; then - echo "publication-reconciliation=$PUBLICATION_RECONCILIATION_RESULT" - exit 1 - fi + - name: Scan website output for privacy leaks + run: uv run python scripts/publication/check_artifact_privacy.py website/dist - if [ "$NEEDS_CODE_CI" = "false" ]; then - if [ "$SKILL_INTEGRITY_NEEDED" = "true" ]; then - echo "Skill-integrity-only PR; required skill checks passed and product CI was skipped." - else - echo "Content-only PR; applicable content checks passed and product CI was skipped." - fi - echo "safe-content-only=$SAFE_CONTENT_ONLY" - exit 0 - fi + example-validation: + needs: visual-inputs + if: needs.visual-inputs.outputs.changed == 'true' + runs-on: ubuntu-latest - # explorer-tokens is gated on explorer-paths-needed; when it runs it - # must pass. A "skipped" result is still accepted defensively for a - # path-filtered job that did not need to run. - if [ "$EXPLORER_TOKENS_RESULT" != "success" ] \ - && [ "$EXPLORER_TOKENS_RESULT" != "skipped" ]; then - echo "explorer-tokens=$EXPLORER_TOKENS_RESULT" - exit 1 - fi + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - # The public site theme scan has its own path group because its - # inputs are broader than results-explorer/src/**. A matched group - # must produce a real successful result before the required context - # can pass. - if [ "$SITE_THEME_TOKENS_RESULT" != "success" ] \ - && [ "$SITE_THEME_TOKENS_RESULT" != "skipped" ]; then - echo "site-theme-tokens=$SITE_THEME_TOKENS_RESULT" - exit 1 - fi + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" - # Explorer Vitest is path-filtered on the complete frontend/read-model - # contract. A skipped result is intentional only when the contract - # group did not match; a real failure must block the required context. - if [ "$EXPLORER_VITEST_RESULT" != "success" ] \ - && [ "$EXPLORER_VITEST_RESULT" != "skipped" ]; then - echo "explorer-vitest=$EXPLORER_VITEST_RESULT" - exit 1 - fi + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - check_heavy correctness-gate "$CORRECTNESS_RESULT" - check_heavy plan-capture-gate "$PLAN_CAPTURE_RESULT" - check_heavy medium-test "$MEDIUM_TEST_RESULT" + - name: Install dependencies + run: uv sync --group dev - if [ "$LINT_RESULT" = "success" ] \ - && [ "$TEST_RESULT" = "success" ]; then - if [ "$HEAVY_NEEDED" = "true" ]; then - echo "Code/infra PR; lint, fast tests, and the required heavy tier passed." - else - echo "Code/infra PR on the light lane; lint and fast tests passed, heavy tier deferred to the merge queue." - fi - exit 0 - fi + - name: Validate example file references + run: uv run python scripts/validate_example_references.py - echo "code-lint=$LINT_RESULT code-test=$TEST_RESULT correctness-gate=$CORRECTNESS_RESULT plan-capture-gate=$PLAN_CAPTURE_RESULT medium-test=$MEDIUM_TEST_RESULT heavy-needed=$HEAVY_NEEDED" - exit 1 + - name: Check example file syntax + run: uv run python scripts/check_example_syntax.py - - name: Record certification lane evidence - if: always() + - name: Validate visualization screenshot sync + run: uv run python scripts/validate_visualization_images.py + + - name: Check /prompts/ catalog is fresh + # Fails if landing/prompts/catalog.generated.js is stale or invalid. + # Must use the main project env (not --project _project/scripts) so + # benchbox is importable; see landing-prompts-catalog-generator + # decision and anti_patterns. + run: uv run -- python scripts/generate_landing_quickstarts.py --check + + - name: Check for unsafe patch() string paths in tests + run: uv run python scripts/check_patch_safety.py + + spellcheck: + needs: visual-inputs + if: needs.visual-inputs.outputs.changed == 'true' + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Run codespell + uses: codespell-project/actions-codespell@e3258cca84ce02b0bb36a3d41f2c18f719a0cc1a # v2.4.3 + with: + ignore_words_file: .codespell-ignore.txt + skip: "*.pyc,_build,*.json,*.lock,*.svg,*.min.js,*.min.css,_binaries,*.tpl,*.dst,*.tbl,_sources,benchmark_runs,*.dat,*.pdf,_project,_blog,htmlcov,.venv" + + # Unit result: core. Required by the merge ruleset; reports on every event and passes when untouched. + core: + name: core + needs: [ci-paths, code-lint, code-test, medium-collect, medium-test, correctness-gate, required-local-cases, plan-capture-gate, tpch-binary-framing, datafusion-integration, package-smoke, dependency-audit] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout the result helper + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 1 + persist-credentials: false + sparse-checkout: | + scripts/ci_unit_result.py + scripts/release_canary_sharding.py + sparse-checkout-cone-mode: false + + - name: Download complete medium shard evidence + if: needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + pattern: t2-medium-*-${{ github.sha }} + path: t2-evidence + merge-multiple: false + + - name: Verify complete medium shard coverage + if: needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' + run: python scripts/release_canary_sharding.py verify-medium --artifacts t2-evidence --checked-sha "${{ github.sha }}" + + - name: Aggregate core result env: - CI_PATHS_RESULT: ${{ needs.ci-paths.result }} - TPCH_BINARY_FRAMING_RESULT: ${{ needs.tpch-binary-framing.result }} - CONTENT_RESULT: ${{ needs.content-guard.result }} - SKILL_INTEGRITY_RESULT: ${{ needs.skill-integrity.result }} - LINT_RESULT: ${{ needs.code-lint.result }} - TEST_RESULT: ${{ needs.code-test.result }} - CORRECTNESS_RESULT: ${{ needs.correctness-gate.result }} - PLAN_CAPTURE_RESULT: ${{ needs.plan-capture-gate.result }} - MEDIUM_TEST_RESULT: ${{ needs.medium-test.result }} - EXPLORER_TOKENS_RESULT: ${{ needs.explorer-tokens.result }} - SITE_THEME_TOKENS_RESULT: ${{ needs.site-theme-tokens.result }} - EXPLORER_VITEST_RESULT: ${{ needs.explorer-vitest.result }} - AUDIT_SHA_RESULT: ${{ needs.audit-sha.result }} - PACKAGE_SMOKE_RESULT: ${{ needs.package-smoke.result }} - DEPENDENCY_AUDIT_RESULT: ${{ needs.dependency-audit.result }} - PARITY_CHECK_RESULT: ${{ needs.parity-check.result }} - NEEDS_CODE_CI: ${{ needs.ci-paths.outputs.needs-code-ci }} - SKILL_INTEGRITY_NEEDED: ${{ needs.ci-paths.outputs.skill-integrity-needed }} - HEAVY_NEEDED: ${{ needs.ci-paths.outputs.heavy-needed }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} - RUN_ID: ${{ github.run_id }} + NEEDS_JSON: ${{ toJson(needs) }} run: | set -euo pipefail - python - <<'PY' - import json - import os - from pathlib import Path + python scripts/ci_unit_result.py \ + --unit core \ + --needs "$NEEDS_JSON" \ + --always ci-paths \ + --expect code-lint=${{ needs.ci-paths.outputs.code-tests-needed == 'true' }} \ + --expect code-test=${{ needs.ci-paths.outputs.code-tests-needed == 'true' }} \ + --expect medium-collect=${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} \ + --expect medium-test=${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} \ + --expect correctness-gate=${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} \ + --expect required-local-cases=${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} \ + --expect plan-capture-gate=${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} \ + --expect tpch-binary-framing=${{ needs.ci-paths.outputs.heavy-needed == 'true' || needs.ci-paths.outputs.framing-needed == 'true' }} \ + --expect datafusion-integration=${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }} \ + --expect package-smoke=${{ needs.ci-paths.outputs.packaging-needed == 'true' }} \ + --expect dependency-audit=${{ needs.ci-paths.outputs.packaging-needed == 'true' }} + + # Unit result: explorer. + explorer: + name: explorer + needs: [ci-paths, explorer-tokens, explorer-vitest, parity-check, explorer-e2e, visual-inputs] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout the result helper + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 1 + persist-credentials: false + sparse-checkout: scripts/ci_unit_result.py + sparse-checkout-cone-mode: false - record = { - "run_id": int(os.environ["RUN_ID"]), - "pr_head_sha": os.environ.get("HEAD_SHA") or "", - "base_sha": os.environ.get("BASE_SHA") or "", - "needs_code_ci": os.environ.get("NEEDS_CODE_CI") or "", - "skill_integrity_needed": os.environ.get("SKILL_INTEGRITY_NEEDED") or "", - "heavy_needed": os.environ.get("HEAVY_NEEDED") or "", - "lanes": { - "ci-paths": os.environ.get("CI_PATHS_RESULT") or "", - "tpch-binary-framing": os.environ.get("TPCH_BINARY_FRAMING_RESULT") or "", - "content-guard": os.environ.get("CONTENT_RESULT") or "", - "skill-integrity": os.environ.get("SKILL_INTEGRITY_RESULT") or "", - "code-lint": os.environ.get("LINT_RESULT") or "", - "code-test": os.environ.get("TEST_RESULT") or "", - "correctness-gate": os.environ.get("CORRECTNESS_RESULT") or "", - "plan-capture-gate": os.environ.get("PLAN_CAPTURE_RESULT") or "", - "medium-test": os.environ.get("MEDIUM_TEST_RESULT") or "", - "explorer-tokens": os.environ.get("EXPLORER_TOKENS_RESULT") or "", - "site-theme-tokens": os.environ.get("SITE_THEME_TOKENS_RESULT") or "", - "explorer-vitest": os.environ.get("EXPLORER_VITEST_RESULT") or "", - "audit-sha": os.environ.get("AUDIT_SHA_RESULT") or "", - "package-smoke": os.environ.get("PACKAGE_SMOKE_RESULT") or "", - "dependency-audit": os.environ.get("DEPENDENCY_AUDIT_RESULT") or "", - "parity-check": os.environ.get("PARITY_CHECK_RESULT") or "", - }, - } - Path("pr-certification-lanes.json").write_text( - json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" - ) - PY + - name: Aggregate explorer result + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: | + set -euo pipefail + python scripts/ci_unit_result.py \ + --unit explorer \ + --needs "$NEEDS_JSON" \ + --always ci-paths \ + --expect explorer-tokens=${{ needs.ci-paths.outputs.explorer-paths-needed == 'true' }} \ + --expect explorer-vitest=${{ needs.ci-paths.outputs.explorer-vitest-needed == 'true' }} \ + --expect parity-check=${{ needs.ci-paths.outputs.viz-needed == 'true' }} \ + --expect explorer-e2e=${{ needs.ci-paths.outputs.unit-explorer == 'true' }} \ + --expect visual-inputs=${{ true }} + + # Unit result: results-data. + results-data: + name: results-data + needs: [ci-paths, results-data-check] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout the result helper + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 1 + persist-credentials: false + sparse-checkout: scripts/ci_unit_result.py + sparse-checkout-cone-mode: false - - name: Upload certification lane evidence - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + - name: Aggregate results-data result + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: | + set -euo pipefail + python scripts/ci_unit_result.py \ + --unit results-data \ + --needs "$NEEDS_JSON" \ + --always ci-paths \ + --expect results-data-check=${{ needs.ci-paths.outputs.unit-results-data == 'true' }} + + # Unit result: docs. + docs: + name: docs + needs: [ci-paths, visual-inputs, docs-build, example-validation, spellcheck] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout the result helper + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: - name: pr-certification-lanes - path: pr-certification-lanes.json - if-no-files-found: warn - retention-days: 30 + fetch-depth: 1 + persist-credentials: false + sparse-checkout: scripts/ci_unit_result.py + sparse-checkout-cone-mode: false + + - name: Aggregate docs result + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: | + set -euo pipefail + python scripts/ci_unit_result.py \ + --unit docs \ + --needs "$NEEDS_JSON" \ + --always ci-paths \ + --expect visual-inputs=${{ true }} \ + --expect docs-build=${{ needs.visual-inputs.outputs.changed == 'true' }} \ + --expect example-validation=${{ needs.visual-inputs.outputs.changed == 'true' }} \ + --expect spellcheck=${{ needs.visual-inputs.outputs.changed == 'true' }} + + # Unit result: landing. + landing: + name: landing + needs: [ci-paths, site-theme-tokens, visual-inputs, site-build] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout the result helper + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 1 + persist-credentials: false + sparse-checkout: scripts/ci_unit_result.py + sparse-checkout-cone-mode: false + + - name: Aggregate landing result + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: | + set -euo pipefail + python scripts/ci_unit_result.py \ + --unit landing \ + --needs "$NEEDS_JSON" \ + --always ci-paths \ + --expect site-theme-tokens=${{ needs.ci-paths.outputs.site-theme-needed == 'true' }} \ + --expect visual-inputs=${{ true }} \ + --expect site-build=${{ needs.ci-paths.outputs.site-needed == 'true' }} + + # Unit result: tooling. + tooling: + name: tooling + needs: [ci-paths, base-guard, content-guard, skill-integrity, audit-sha, comment-policy] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout the result helper + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 1 + persist-credentials: false + sparse-checkout: scripts/ci_unit_result.py + sparse-checkout-cone-mode: false + + - name: Aggregate tooling result + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: | + set -euo pipefail + python scripts/ci_unit_result.py \ + --unit tooling \ + --needs "$NEEDS_JSON" \ + --always ci-paths \ + --always comment-policy \ + --expect base-guard=${{ github.event_name == 'pull_request' }} \ + --expect content-guard=${{ needs.ci-paths.outputs.content-guard-needed == 'true' }} \ + --expect skill-integrity=${{ needs.ci-paths.outputs.skill-integrity-needed == 'true' }} \ + --expect audit-sha=${{ contains(needs.ci-paths.outputs.changed-paths, '_project/audits/') || contains(needs.ci-paths.outputs.changed-paths, 'results-explorer/e2e/captures/') }} diff --git a/.github/workflows/cross-platform-validation.yml b/.github/workflows/cross-platform-validation.yml index 5bf90ba0db..e331558399 100644 --- a/.github/workflows/cross-platform-validation.yml +++ b/.github/workflows/cross-platform-validation.yml @@ -1,5 +1,8 @@ name: Cross-Platform Validation +env: + BENCHBOX_TEST_LOCK_WAIT_SECONDS: "0" + # Nightly matrix: DuckDB × {DataFusion, Polars-DF, ClickHouse-local} at TPC-H SF=0.01. # # Purpose: detect silent cross-platform divergences before they land on release. diff --git a/.github/workflows/cross-surface-baseline-autodetect.yml b/.github/workflows/cross-surface-baseline-autodetect.yml index 4351cd7275..af85191777 100644 --- a/.github/workflows/cross-surface-baseline-autodetect.yml +++ b/.github/workflows/cross-surface-baseline-autodetect.yml @@ -4,7 +4,7 @@ name: Cross-Surface Baseline Auto-Prune # # #903 made every enforced cross-surface SQL<->DataFrame equivalence gate # (benchbox/core/equivalence/cross_surface.py GATES; run on every PR by -# pr.yml's correctness-gate job) FAIL a normal, blocking run when a +# ci.yml's correctness-gate job) FAIL a normal, blocking run when a # known_divergences baseline entry stops reproducing - printing "GATE FAILURE # - previously-known divergences now equivalent: [...]" and telling the # operator to prune the stale entry in a reviewed change. #935 built the @@ -17,30 +17,14 @@ name: Cross-Surface Baseline Auto-Prune # nothing: _project/scripts/cross_surface_baseline_autodetect.py only READS # each gate's own resolved-key report and invokes the EXISTING # run_gate(..., update_baseline=True) writer - see that script's module -# docstring. The blocking gate run (pr.yml, `make +# docstring. The blocking gate run (ci.yml, `make # -cross-surface-equivalence-report`) is completely unmodified by this # change and never prunes; this scheduled workflow is the ONLY path that ever # writes benchbox/core/equivalence/cross_surface_baseline.yaml. # -# Soundness note: that YAML lives under benchbox/core/equivalence/**, a -# CODEOWNERS soundness path (see _project/scripts/auto_merge_soundness_paths.py). -# auto-merge-on-open.yml already refuses to enable auto-merge for any PR whose -# diff touches that path, so the PR this workflow opens never auto-merges -# regardless of which identity authors it - and this workflow additionally -# never calls `gh pr merge --auto` itself. The maintainer reviews the diff -# (which can only ever DROP a no-longer-reproducing entry - see the writer's -# own contract in known_divergences_baseline.py) and merges by hand. -# # Optional secret (never required for correctness, only for CI signal): # CROSS_SURFACE_BASELINE_PR_TOKEN = a fine-grained PAT (this repo; contents + # pull-requests: write) or GitHub App installation token. -# Rationale (mirrors todo-db-export.yml): GitHub does not trigger downstream -# workflows for events raised by the built-in GITHUB_TOKEN, so a PR opened -# with it never runs pr.yml and the maintainer sees no CI status on the diff -# before reviewing. A non-GITHUB_TOKEN identity makes pr.yml run so that -# signal is available. Without this secret the job still runs and still opens -# the PR - just without pr.yml firing on it; the PR still never auto-merges -# either way. on: schedule: @@ -75,7 +59,7 @@ jobs: ref: develop fetch-depth: 0 # A non-default identity so this workflow's own push/PR events - # (unlike the built-in GITHUB_TOKEN's) trigger pr.yml on the + # (unlike the built-in GITHUB_TOKEN's) trigger ci.yml on the # opened PR - see the header note. Falls back to github.token when # the PAT is not provisioned; the job still runs either way. token: ${{ secrets.CROSS_SURFACE_BASELINE_PR_TOKEN || github.token }} @@ -142,10 +126,10 @@ jobs: git fetch --no-tags origin "${PRUNE_BRANCH}:refs/remotes/origin/${PRUNE_BRANCH}" 2>/dev/null || true git checkout -B "${PRUNE_BRANCH}" git commit -m "chore(equivalence): prune resolved cross-surface baseline entries" \ - -m "Auto-generated by .github/workflows/cross-surface-baseline-autodetect.yml. Only known-divergence entries that no longer reproduce on the enforced gates were removed (via the existing make cross-surface-update-baseline writer); no still-reproducing entry is touched. Soundness-path PR: review the diff, then merge by hand - see the workflow header." + -m "Auto-generated by .github/workflows/cross-surface-baseline-autodetect.yml. Only known-divergence entries that no longer reproduce on the enforced gates were removed (via the existing make cross-surface-update-baseline writer); no still-reproducing entry is touched. Soundness-path PR: oracle-review needs the Codex connector review before it can merge." git push --force-with-lease origin "${PRUNE_BRANCH}" - - name: Open or update PR (never auto-merge - soundness path) + - name: Open or update PR if: steps.diff.outputs.changed == 'true' env: GH_TOKEN: ${{ secrets.CROSS_SURFACE_BASELINE_PR_TOKEN || github.token }} @@ -164,9 +148,9 @@ jobs: echo "blocking PR gate (\`make -cross-surface-equivalence-report\`) never prunes -" echo "this scheduled workflow is the only path that writes this file." echo "" - echo "**This PR does not auto-merge.** \`benchbox/core/equivalence/**\` is a" - echo "soundness-critical CODEOWNERS path; review the diff (it can only ever REMOVE a" - echo "resolved entry - see the writer's own contract) and merge by hand." + echo "**Merge by hand after review.** \`benchbox/core/equivalence/**\` is a soundness" + echo "path, so \`oracle-review\` needs the Codex connector's review; check the diff (it" + echo "can only ever REMOVE a resolved entry - see the writer's own contract)." echo "" echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" } > "${body_file}" @@ -178,8 +162,6 @@ jobs: echo "PR #${existing} already open for ${PRUNE_BRANCH}; updating body." gh pr edit "${existing}" --body-file "${body_file}" fi - # Auto-merge is never enabled here, by design (soundness path) - - # regardless of which token authored this PR. - name: No changes if: steps.diff.outputs.changed == 'false' diff --git a/.github/workflows/develop-post-merge-gap-detector.yml b/.github/workflows/develop-post-merge-gap-detector.yml deleted file mode 100644 index d63f095202..0000000000 --- a/.github/workflows/develop-post-merge-gap-detector.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Develop post-merge gap detector - -# Instrumentation for the silent develop-post-merge push-drop class. -# -# develop-post-merge.yml is push-triggered on develop. GitHub has dropped push -# delivery for consecutive develop merges (2026-08-03 corpus-mirror gap; -# 2026-08-04 six consecutive merges with no post-merge run). The hourly -# schedule on develop-post-merge.yml re-gates the *tip* so develop is not left -# un-gated; this workflow records whether each recent develop SHA ever got a -# post-merge run at all, and fails when gaps remain so the class stays visible -# until the lookback window advances past it. -# -# Read-only. No write permissions. Does not open PRs, re-run gates, or mutate -# repository state. Manual recovery for tip coverage is still -# `gh workflow run develop-post-merge.yml` (or wait for the next hourly sweep). -# -# See docs/operations/develop-post-merge-gaps.md. - -on: - schedule: - # Daily, offset from other canaries. Instrumentation does not need hourly - # cadence; the sweep in develop-post-merge.yml already bounds tip exposure. - - cron: "47 7 * * *" - workflow_dispatch: {} - -permissions: - contents: read - # actions:read is required to list workflow runs via gh/api. The main - # develop-post-merge workflow already grants actions:read on its metrics / - # auto-revert jobs; this is not a new capability class for the repo. - actions: read - -concurrency: - group: develop-post-merge-gap-detector - cancel-in-progress: false - -jobs: - detect-gaps: - name: Detect missing develop-post-merge runs - runs-on: ubuntu-latest - steps: - - name: Check out develop - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: develop - # Need enough history for the lookback window. - fetch-depth: 0 - - - name: Fetch origin/develop tip - run: git fetch --no-tags origin develop - - - name: Run gap detector - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - # Capture JSON even when the detector exits 1 (gaps present) so the - # always()-run upload below still has a report artifact. - set +e - # --run-limit is a hard row cap while paginating unique headShas - # (commit-limit + unique-margin). Do not pass a shallow fixed limit: - # schedule tip floods would self-poison the window. - python3 scripts/detect_develop_post_merge_gaps.py \ - --live \ - --branch origin/develop \ - --commit-limit 20 \ - --unique-margin 10 \ - --run-limit 1000 \ - --json > gap-report.json - rc=$? - set -e - cat gap-report.json - exit "${rc}" - - - name: Upload gap report - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: develop-post-merge-gap-report - path: gap-report.json - retention-days: 30 - if-no-files-found: ignore diff --git a/.github/workflows/develop-post-merge.yml b/.github/workflows/develop-post-merge.yml deleted file mode 100644 index d17bf678c5..0000000000 --- a/.github/workflows/develop-post-merge.yml +++ /dev/null @@ -1,1454 +0,0 @@ -name: Develop post-merge - -# Safety net for strict-checks-off and stale-PR flows alike: once a PR lands, -# run the same lightweight local gates against the actual develop tip. This is -# intentionally narrower than test.yml's full PR matrix so it does not turn -# every squash merge into the heavyweight optional suite. -# -# Push is the primary trigger. GitHub has been observed to drop push delivery -# for consecutive develop merges (see docs/operations/develop-post-merge-gaps.md -# and the 2026-08-03 corpus-mirror gap), so a scheduled sweep is additive: it -# re-runs the lightweight tip gates within a bounded window when a per-push -# run was never created. It is not a replacement for push, and it does not -# paper over the gap with extra event types. -# -# Schedule path is gates-only and slim: lint + fast-test + explorer-tokens. -# medium-test (~40 min) and all mutation jobs (auto-revert, green-run-cleanup, -# close-orphaned-prs) stay on push / workflow_dispatch only. - -on: - push: - branches: [develop] - # Bound silent push-drop exposure. Hourly is enough to re-gate tip without - # waiting for the next real merge; the gap detector (sibling workflow) - # instruments which exact SHAs still lack a per-push run. - schedule: - - cron: "17 * * * *" - workflow_dispatch: {} - -permissions: - contents: read - -concurrency: - # Push (and manual dispatch) stay SHA-keyed so concurrent merges do not - # cancel each other. Schedule uses a fixed group so a sweep never cancels - # — and is never cancelled by — a push-triggered run for the same tip. - # cancel-in-progress is true only for schedule so a newer sweep supersedes - # a still-running older one; push runs never cancel each other. - group: ${{ github.event_name == 'schedule' && 'develop-post-merge-schedule' || format('develop-post-merge-{0}', github.sha) }} - cancel-in-progress: ${{ github.event_name == 'schedule' }} - -jobs: - lint: - needs: sweep-coverage - # Baseline cache writes must follow develop history order. A SHA-specific - # workflow-level group does not serialize two pushes, so an older run can - # save after a newer run and win the restore-prefix lookup. - # - # A covered tip skips the re-run on schedule only: the covering push run - # already wrote this SHA's fast-lane-count cache entry, so the skip - # leaves no baseline gap. Push and dispatch always run. - if: ${{ github.event_name != 'schedule' || needs.sweep-coverage.outputs.covered != 'true' }} - concurrency: - group: develop-post-merge-lint-${{ github.ref }} - cancel-in-progress: false - runs-on: ubuntu-latest - steps: - # Default branch is develop, so schedule/dispatch already resolve to the - # tip workflow + tip SHA. Pin ref explicitly so a future default-branch - # rename cannot silently re-target this sweep. - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: '3.11' - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Install dependencies - run: uv sync --group dev - - - name: Run CI lint mirror - run: make ci-lint - - - name: Persist fast-lane count for PR-lane delta guard - # Feeds the pr.yml `code-lint` job's guard-fast-lane-delta step (see - # docs/operations/fast-lane-budget.md): that guard restores this - # cache entry to diff a PR's fast-lane collect count against - # develop's own count, on top of the absolute ceiling that - # guard-timing-policy already enforces above. This step must never - # fail the post-merge job itself -- it always runs (`if: always()`, - # independent of whether `make ci-lint` passed) and never propagates - # a nonzero exit from the underlying collect. - if: always() - run: | - uv run -- python _project/scripts/timing_policy_check.py --emit-fast-count > fast-lane-count.txt || echo "unknown" > fast-lane-count.txt - cat fast-lane-count.txt - - - name: Cache fast-lane count - # Well-known key on this push's SHA, so the PR lane's restore-keys - # prefix match (fast-lane-count-develop-) always finds the most - # recent develop count. continue-on-error: a cache-service hiccup - # (or, in principle, a same-key collision) must not fail post-merge. - if: always() - continue-on-error: true - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: fast-lane-count.txt - key: fast-lane-count-develop-${{ github.sha }} - - - name: Emit lint signature - # Bare python is intentional: the signature builder is stdlib-only. - # `job.status` already reflects the lint step above by this point; - # lint has a single fallible step, so no per-step id is needed. - if: always() - shell: bash - run: | - set -euo pipefail - if [ "${{ job.status }}" = "failure" ]; then - python scripts/post_merge_signature.py build --job lint --failed-step "Run CI lint mirror" --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json - else - python scripts/post_merge_signature.py build --job lint --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json - fi - - - name: Upload lint signature - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: signature-lint - path: signature.json - retention-days: 14 - - queue-certification: - name: queue-certification - # Always runs: downstream jobs read these outputs on every event, and - # references to a skipped job's outputs do not evaluate reliably. The - # script itself returns certified=false without any API call unless this - # is a push to develop, so schedule and workflow_dispatch always run the - # full gates and the manual dispatch stays the full-run escape hatch. - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - actions: read - outputs: - certified: ${{ steps.certify.outputs.certified }} - certifying_run_id: ${{ steps.certify.outputs.certifying_run_id }} - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - - - name: Look up merge-queue certification - id: certify - env: - GH_TOKEN: ${{ github.token }} - TARGET_SHA: ${{ github.sha }} - run: | - set -euo pipefail - # Bare python is intentional: the lookup is stdlib-only and always - # exits 0 (fail open), so this step never reds the workflow itself. - # Any API error, gap, ambiguity, or ineligible event runs the full - # gates instead. - python scripts/queue_certification.py \ - --sha "$TARGET_SHA" \ - --github-output "$GITHUB_OUTPUT" \ - --summary "$GITHUB_STEP_SUMMARY" - - - name: Emit certified-skip signatures - # Third-state representation (never a failure, never a fresh green - # baseline): on a certified SHA, fast-test and medium-test do not - # execute, so this job uploads their signature artifacts with kind - # "certified" under the exact artifact names the auto-revert - # signature check expects. Baseline selection walks back past - # certified runs instead of diffing real failures against them. - if: ${{ steps.certify.outputs.certified == 'true' }} - shell: bash - run: | - set -euo pipefail - RUN_ID="${{ steps.certify.outputs.certifying_run_id }}" - for gate_job in fast-test medium-test; do - mkdir -p "certified/${gate_job}" - python scripts/post_merge_signature.py build --job "${gate_job}" --certified-by "${RUN_ID}" \ - --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out "certified/${gate_job}/signature.json" - done - - - name: Upload fast-test certified signature - if: ${{ steps.certify.outputs.certified == 'true' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: signature-fast-test - path: certified/fast-test/signature.json - retention-days: 14 - - - name: Upload medium-test certified signature - if: ${{ steps.certify.outputs.certified == 'true' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: signature-medium-test - path: certified/medium-test/signature.json - retention-days: 14 - - sweep-coverage: - name: sweep-coverage - # Always runs: downstream jobs read these outputs on every event, and - # references to a skipped job's outputs do not evaluate reliably. The - # script itself returns covered=false without any API call unless this - # is a schedule run, so push and workflow_dispatch always run the full - # gates and the manual dispatch stays the full-run escape hatch. - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - actions: read - outputs: - covered: ${{ steps.coverage.outputs.covered }} - covering_run_id: ${{ steps.coverage.outputs.covering_run_id }} - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - - - name: Check whether the tip is already covered - id: coverage - env: - GH_TOKEN: ${{ github.token }} - TARGET_SHA: ${{ github.sha }} - run: | - set -euo pipefail - # Bare python is intentional: the lookup is stdlib-only and always - # exits 0 (fail open), so this step never reds the workflow itself. - # Any API error, gap, ambiguity, or uncovered tip runs the full - # gates instead. - python scripts/sweep_coverage.py \ - --sha "$TARGET_SHA" \ - --github-output "$GITHUB_OUTPUT" \ - --summary "$GITHUB_STEP_SUMMARY" - - fast-test: - needs: [queue-certification, sweep-coverage] - # Queue-certified pushes skip the re-run: the merge queue already passed - # this exact SHA (certified by the run recorded in the step summary and - # metrics). The lint job above still runs on every push: it writes the - # fast-lane-count-develop- cache the PR guard restores. A covered - # tip skips the re-run on schedule too: a success/failure push run (or - # a queue certification) already gated this exact SHA, so the sweep - # exits early with a step-summary note. - if: ${{ (github.event_name != 'push' || needs.queue-certification.outputs.certified != 'true') && (github.event_name != 'schedule' || needs.sweep-coverage.outputs.covered != 'true') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Install dependencies - run: uv sync --group dev - - - name: Run CI fast-test mirror - env: - # Picked up by `make ci-test`'s pytest invocation without touching - # the Makefile or local developer behavior (PYTEST_ADDOPTS is only - # set in this CI step's env). - PYTEST_ADDOPTS: "--junitxml=fast-test-junit.xml" - run: make ci-test - - - name: Emit fast-test signature - # Bare python is intentional: the signature builder is stdlib-only. - # Falls back to a job-failure descriptor if pytest crashed before - # writing junit XML at all (e.g. a collection-time crash), left a - # truncated/unparseable file behind (killed mid-write), or failed for - # a reason junit never records (e.g. pytest-cov's --cov-fail-under - # gate, where every testcase passes but the step still fails) - a - # signature artifact must ALWAYS upload and must never look clean - # when the job actually failed, or the auto-revert diff would - # compare against a partial/false-clean current signature and could - # wrongly suppress. - if: always() - shell: bash - run: | - set -euo pipefail - JOB_FAILED_FLAG="" - if [ "${{ job.status }}" = "failure" ]; then - JOB_FAILED_FLAG="--job-failed" - fi - if [ -f fast-test-junit.xml ]; then - python scripts/post_merge_signature.py build --job fast-test --junit fast-test-junit.xml \ - --failed-step "Run CI fast-test mirror (job failed with no testcase-level failures, e.g. coverage threshold)" \ - $JOB_FAILED_FLAG --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json \ - || python scripts/post_merge_signature.py build --job fast-test --failed-step "Run CI fast-test mirror (junit xml unparseable)" --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json - else - python scripts/post_merge_signature.py build --job fast-test --failed-step "Run CI fast-test mirror (no junit xml produced)" --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json - fi - - - name: Upload fast-test signature - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: signature-fast-test - path: signature.json - retention-days: 14 - - explorer-tokens: - name: explorer-tokens - needs: sweep-coverage - # Re-run the token-scan gate against the merged develop tree so that a - # squash race that reintroduces raw Tailwind palette literals trips the - # auto-revert path even when each PR-time scan passed against its own - # pre-merge tree. Stdlib-only so no dependency sync is required. A - # covered tip skips the re-run on schedule only; push and dispatch - # always run. - if: ${{ github.event_name != 'schedule' || needs.sweep-coverage.outputs.covered != 'true' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - - - name: Run explorer token scan - id: token-scan - run: make lint-explorer-tokens - - - name: Run public site theme token scan - id: theme-scan - run: make lint-site-theme-tokens - - - name: Emit explorer-tokens signature - # Bare python is intentional: the signature builder is stdlib-only. - # Per-step ids above disambiguate which of the two scans failed - # (the second step is skipped, not failed, if the first one fails). - if: always() - shell: bash - run: | - set -euo pipefail - failed_step="" - if [ "${{ steps.token-scan.outcome }}" = "failure" ]; then - failed_step="Run explorer token scan" - elif [ "${{ steps.theme-scan.outcome }}" = "failure" ]; then - failed_step="Run public site theme token scan" - fi - if [ -n "${failed_step}" ]; then - python scripts/post_merge_signature.py build --job explorer-tokens --failed-step "${failed_step}" --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json - else - python scripts/post_merge_signature.py build --job explorer-tokens --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json - fi - - - name: Upload explorer-tokens signature - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: signature-explorer-tokens - path: signature.json - retention-days: 14 - - medium-test: - name: medium-test - # The medium speed tier (~81 files, `make test-medium`, Makefile:90-91) had - # no CI consumer before this job - it only ran when a developer invoked it - # locally. Measured locally at ~18 minutes (2 xdist workers), too slow for - # the develop-PR-blocking lane, so it lands here for visibility/metrics. - # See tracker item `medium-tier-ci-home`. - # - # PROMOTED 2026-07-06 (medium-tier-red-disposition-and-promotion): the - # tier ran clean (green) on the real runner - develop-post-merge run - # 28763663703, job 85283671051, "Run medium speed tier" success - once - # its dispositioned failures were fixed: timeout markers for the 6 slow - # data-gen/cross-surface tests (#977) and the pytest-ci.ini - # filterwarnings marker sync (#994, the last remaining runner failure). - # It is now a blocking post-merge lane wired into auto-revert-on-failure's - # trigger, same as lint/fast-test/explorer-tokens (continue-on-error - # dropped). The 44 tpch-extension "errors" reported earlier were - # sandbox-only (egress proxy 403 on the extension download) and do not - # occur on the runner. - # - # PR-LANE CONSUMER ADDED 2026-07-11 (medium-tier-pre-merge-lane): the tier - # now also runs pre-merge as pr.yml's `medium-test` job, path-gated on - # ci-paths' needs-code-ci output so docs/TODO-only PRs skip it. That job - # is a plain `make test-medium` run with no signature/junit emission (the - # PR lane does not feed auto-revert). This post-merge job stays the - # blocking safety net for the residual gap the PR lane cannot see: a - # stale-base squash race, where a PR's own merge-tree CI passes but the - # eventual develop tip has a semantic conflict with another PR merged in - # between (see the TODO description for the #1093/#1095/#1100 incident - # this closes half of). - # - # SLIM SCHEDULE: medium-test is intentionally omitted on the hourly - # schedule (push + workflow_dispatch only). The ~40 min tier would - # otherwise cost ~24 full medium runs/day even when tip is already - # push-covered; lint/fast-test/explorer-tokens still bound tip exposure. - # Queue-certified pushes skip the re-run alongside fast-test; the - # queue-certification job uploads the certified third-state signature. - if: ${{ github.event_name != 'schedule' && (github.event_name != 'push' || needs.queue-certification.outputs.certified != 'true') }} - needs: queue-certification - runs-on: ubuntu-latest - timeout-minutes: 40 - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Install dependencies - run: uv sync --group dev - - - name: Run medium speed tier - env: - # Picked up by `make test-medium`'s pytest invocation without - # touching the Makefile or local developer behavior. - PYTEST_ADDOPTS: "--junitxml=medium-test-junit.xml" - run: make test-medium - - - name: Emit medium-test signature - # Bare python is intentional: the signature builder is stdlib-only. - # Same missing/unparseable-junit AND job-failed-but-junit-is-clean - # fallbacks as fast-test: a signature artifact must always upload and - # must never look clean when the job actually failed, or the - # auto-revert diff could run on a partial/false-clean current - # signature. - if: always() - shell: bash - run: | - set -euo pipefail - JOB_FAILED_FLAG="" - if [ "${{ job.status }}" = "failure" ]; then - JOB_FAILED_FLAG="--job-failed" - fi - if [ -f medium-test-junit.xml ]; then - python scripts/post_merge_signature.py build --job medium-test --junit medium-test-junit.xml \ - --failed-step "Run medium speed tier (job failed with no testcase-level failures)" \ - $JOB_FAILED_FLAG --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json \ - || python scripts/post_merge_signature.py build --job medium-test --failed-step "Run medium speed tier (junit xml unparseable)" --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json - else - python scripts/post_merge_signature.py build --job medium-test --failed-step "Run medium speed tier (no junit xml produced)" --source-input "published-results=$(git ls-remote origin refs/heads/published-results | cut -f1)" --out signature.json - fi - - - name: Upload medium-test signature - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: signature-medium-test - path: signature.json - retention-days: 14 - - close-orphaned-prs: - name: close-orphaned-prs - needs: [lint, fast-test, explorer-tokens, medium-test] - # When a squash merge to develop lands one of several open PRs whose - # branches share linear history (e.g. two worktrees produced overlapping - # branches where one is a strict ancestor of the other), the squash hides - # the original commit lineage and the unmerged-but-fully-contained PRs sit - # open forever. GitHub's own auto-close heuristic does not fire on squash - # merges. - # - # Detection: find the PR associated with this squash commit and recover - # its original (un-squashed) branch tip via the GraphQL API. Any other - # open PR whose head commit is a git ancestor of that tip has all of its - # content already on develop and can be closed safely. - # - # Schedule path is gates-only: mutation jobs never run on schedule. - if: ${{ github.event_name != 'schedule' }} - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - env: - GH_TOKEN: ${{ github.token }} - MERGED_SHA: ${{ github.sha }} - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - fetch-depth: 0 - - - name: Close PRs whose branches are ancestors of the merged PR - shell: bash - run: | - set -euo pipefail - - repo_name="${GITHUB_REPOSITORY#*/}" - merged_pr_json="$(gh api graphql \ - -f owner="${GITHUB_REPOSITORY_OWNER}" \ - -f repo="${repo_name}" \ - -f oid="${MERGED_SHA}" \ - -f query=' - query($owner: String!, $repo: String!, $oid: GitObjectID!) { - repository(owner: $owner, name: $repo) { - object(oid: $oid) { - ... on Commit { - associatedPullRequests(first: 1) { - nodes { number headRefOid url } - } - } - } - } - } - ' \ - --jq '.data.repository.object.associatedPullRequests.nodes[0] // {}')" - - merged_pr_number="$(jq -r '.number // empty' <<<"${merged_pr_json}")" - merged_pr_head="$(jq -r '.headRefOid // empty' <<<"${merged_pr_json}")" - merged_pr_url="$(jq -r '.url // empty' <<<"${merged_pr_json}")" - - if [ -z "${merged_pr_head}" ]; then - echo "No PR associated with ${MERGED_SHA} (direct push?). Nothing to do." - exit 0 - fi - echo "Merged PR #${merged_pr_number} head: ${merged_pr_head}" - - # The un-squashed branch tip is not on develop's linear history, so - # fetch it explicitly via the pull ref. - if ! git cat-file -e "${merged_pr_head}^{commit}" 2>/dev/null; then - git fetch --no-tags origin \ - "+refs/pull/${merged_pr_number}/head:refs/remotes/pull/${merged_pr_number}/head" \ - --quiet || { - echo "Could not fetch merged PR's head ref; cannot detect orphans." - exit 0 - } - fi - - mapfile -t records < <(gh pr list \ - --base develop \ - --state open \ - --limit 200 \ - --json number,headRefOid,headRefName,isDraft \ - --jq '.[] | [.number, .headRefOid, .headRefName, (.isDraft|tostring)] | @tsv') - - if [ "${#records[@]}" -eq 0 ]; then - echo "No open PRs targeting develop." - exit 0 - fi - - closed_any=false - for record in "${records[@]}"; do - IFS=$'\t' read -r number oid headref isdraft <<<"${record}" - - # Skip the PR that was just merged (its branch may still appear - # open momentarily in flaky API states). - if [ "${number}" = "${merged_pr_number}" ]; then - continue - fi - - if ! git cat-file -e "${oid}^{commit}" 2>/dev/null; then - git fetch --no-tags origin \ - "+refs/pull/${number}/head:refs/remotes/pull/${number}/head" \ - --quiet 2>/dev/null \ - || { echo "PR #${number}: could not fetch head ${oid:0:12}; skipping."; continue; } - fi - - if ! git merge-base --is-ancestor "${oid}" "${merged_pr_head}"; then - echo "PR #${number}: head ${oid:0:12} is not an ancestor of merged PR #${merged_pr_number}; not orphaned." - continue - fi - - comment_body="Closing as superseded: this PR's head commit (\`${oid:0:12}\`) is an ancestor of merged PR ${merged_pr_url} (head \`${merged_pr_head:0:12}\`, squashed as ${MERGED_SHA}). Develop already contains every change from this branch." - if gh pr close "${number}" --comment "${comment_body}"; then - echo "PR #${number}: closed as orphan (head ${oid:0:12}, branch ${headref}, draft=${isdraft})." - closed_any=true - else - echo "PR #${number}: gh pr close failed; leaving open." >&2 - fi - done - - if [ "${closed_any}" = false ]; then - echo "No orphaned PRs detected." - fi - - auto-revert-on-failure: - name: auto-revert-on-failure - runs-on: ubuntu-latest - # medium-test promoted into this trigger 2026-07-06 (see the medium-test - # job's promotion note): the tier ran clean on the real runner after its - # dispositioned failures were fixed (#977, #994), so a medium-test failure - # now opens a (reviewable) revert PR, same as lint/fast-test/explorer-tokens. - needs: [lint, fast-test, explorer-tokens, medium-test, queue-certification] - # Schedule path is gates-only: never open revert PRs / issues from a sweep. - # event_name must lead so always() cannot re-enable mutation on schedule. - # A queue-certified skip never fires auto-revert: its gates report - # 'skipped', and this explicit guard keeps the trigger off even if a - # future edit changes how skipped results read here. - if: ${{ github.event_name != 'schedule' && always() && (needs.lint.result == 'failure' || needs.explorer-tokens.result == 'failure' || (needs.queue-certification.outputs.certified != 'true' && (needs.fast-test.result == 'failure' || needs.medium-test.result == 'failure'))) }} - outputs: - auto-revert-pr-opened-at: ${{ steps.open-revert.outputs.auto-revert-pr-opened-at }} - auto-revert-pr-url: ${{ steps.open-revert.outputs.auto-revert-pr-url }} - conflict-issue-url: ${{ steps.open-revert.outputs.conflict-issue-url }} - revert-suppressed: ${{ steps.revert-outcome.outputs.revert-suppressed }} - new-failure-ids: ${{ steps.signature-decision.outputs.new-failure-ids }} - incident-issue-url: ${{ steps.upsert-incident.outputs.incident-issue-url }} - permissions: - contents: write - issues: write - pull-requests: write - actions: read - env: - GH_TOKEN: ${{ github.token }} - FAILING_SHA: ${{ github.sha }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - fetch-depth: 0 - - - name: Download current run signature artifacts - # continue-on-error: a missing artifact here (e.g. a gate job - # crashed before its always()-run signature step) is one of the - # fail-open triggers handled below, not a hard job failure. - continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - pattern: signature-* - path: current-signatures - - - name: Determine signature comparison outcome - id: signature-decision - shell: bash - run: | - set -euo pipefail - - # A revert is a positive-evidence outcome, never the default for a - # missing artifact, comparison error, or unmappable failure. - should_revert="false" - # Left empty (not "[]") unless the diff below actually succeeds, - # so a fail-open run reports new_failure_ids as "no data" (null in - # the metrics JSON) rather than the misleading "computed zero". - new_failure_ids_json="" - fail_open_reason="" - previous_run_url="" - comparison_state="unknown" - - # Every external/parse command below is guarded (`if ! ...`) so a - # failure sets fail_open_reason instead of aborting the step under - # `set -e`: an aborted step would leave outputs unwritten and skip - # the incident path. Each stage is skipped once fail_open_reason is - # set, and an incomplete comparison stays red with an incident. - - # Merge the four gate jobs' per-job signature artifacts (one - # subdirectory per artifact, downloaded above) into a single - # combined signature for this run. Require ALL FOUR expected gate - # artifacts, not just "at least one": if one gate crashes/times out - # before its always()-run signature step can upload (or the upload - # itself fails), the other three still leave signature.json files - # behind, so a bare compgen glob check would silently diff a - # partial current signature and could suppress a revert the missing - # gate's real failure warranted. - EXPECTED_SIGNATURE_JOBS=(lint fast-test explorer-tokens medium-test) - missing_signatures=() - for job in "${EXPECTED_SIGNATURE_JOBS[@]}"; do - if [ ! -f "current-signatures/signature-${job}/signature.json" ]; then - missing_signatures+=("${job}") - fi - done - if [ "${#missing_signatures[@]}" -gt 0 ]; then - fail_open_reason="Missing current-run signature artifact(s) for: ${missing_signatures[*]} (gate job crashed, timed out, or was cancelled before its signature could upload)." - elif ! jq -s '{failure_ids: ([.[].failure_ids] | add | unique | sort), jobs: map({job, kind, failure_ids}), source_inputs: (map(.source_inputs // {}) | add // {})}' \ - current-signatures/*/signature.json > current-combined.json; then - fail_open_reason="Could not combine this run's signature artifacts (malformed signature JSON)." - fi - - if [ -z "${fail_open_reason}" ]; then - # Walk completed develop-post-merge runs and pick the highest - # run_number strictly below this run - that is the run to diff - # against, whatever its outcome. - # - # Known limitation: with per-SHA concurrency (cancel-in-progress - # false) two runs can be in flight at once, so the highest - # COMPLETED run_number below ours may be older than our true - # predecessor. Mis-selection errs toward reverting (an older - # baseline makes more failures look new), which is the safe - # direction. - # The baseline is restricted to runs of the same event type: - # scheduled runs omit medium tests, so a push run must never - # diff against a scheduled lane set (or vice versa). - # Certified-skip runs are walked back past: their signatures - # carry kind "certified" with no failure IDs, so diffing real - # failures against one would misblame this merge for older red. - # A certified run is never a fresh green baseline that clears an - # unrelated open incident either: only a real all-green run can - # be the baseline a revert decision diffs against. - if ! runs_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/develop-post-merge.yml/runs?branch=develop&status=completed&per_page=20")"; then - fail_open_reason="Could not list previous develop-post-merge runs via gh api." - elif ! candidate_ids="$(jq -r --argjson current "${GITHUB_RUN_NUMBER}" --arg event "${{ github.event_name }}" \ - '[.workflow_runs[] | select(.run_number < $current and .event == $event)] | sort_by(.run_number) | reverse | .[0:8] | .[].id' \ - <<<"${runs_json}")"; then - fail_open_reason="Could not parse the previous-runs listing." - elif [ -z "${candidate_ids}" ]; then - fail_open_reason="No previous completed develop-post-merge run was found on develop." - else - prev_run_id="" - previous_run_url="" - for candidate_id in ${candidate_ids}; do - rm -rf previous-signatures - if ! candidate_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${candidate_id}")"; then - fail_open_reason="Could not read candidate baseline run ${candidate_id} via gh api." - break - fi - candidate_url="$(jq -r '.html_url // empty' <<<"${candidate_json}")" - if ! gh run download "${candidate_id}" --pattern 'signature-*' --dir previous-signatures 2>prev-download.log; then - fail_open_reason="Could not download the previous run's signature artifacts (likely expired retention): $(tail -n1 prev-download.log 2>/dev/null || true)" - break - fi - is_certified=false - for sig in previous-signatures/signature-fast-test/signature.json previous-signatures/signature-medium-test/signature.json; do - if [ -f "${sig}" ] && jq -e '.kind == "certified"' "${sig}" >/dev/null 2>&1; then - is_certified=true - break - fi - done - if [ "${is_certified}" = "true" ]; then - echo "Baseline candidate ${candidate_id} is a certified skip; walking back." - continue - fi - prev_run_id="${candidate_id}" - previous_run_url="${candidate_url}" - echo "Previous run: ${previous_run_url} (id ${prev_run_id})" - break - done - if [ -z "${fail_open_reason}" ] && [ -z "${prev_run_id}" ]; then - fail_open_reason="Only certified-skip baselines were found; nothing real to diff against." - fi - fi - fi - - if [ -z "${fail_open_reason}" ]; then - if compgen -G "previous-signatures/*/signature.json" > /dev/null; then - if ! jq -s '{failure_ids: ([.[].failure_ids] | add | unique | sort), jobs: map({job, kind, failure_ids}), source_inputs: (map(.source_inputs // {}) | add // {})}' \ - previous-signatures/*/signature.json > previous-combined.json; then - fail_open_reason="Could not combine the previous run's signature artifacts (malformed signature JSON)." - fi - else - fail_open_reason="The previous run's signature artifacts were empty or missing." - fi - fi - - if [ -z "${fail_open_reason}" ]; then - if python3 scripts/post_merge_signature.py diff \ - --previous previous-combined.json \ - --current current-combined.json \ - --out diff.json; then - if ! new_failure_ids_json="$(jq -c '.new_failure_ids' diff.json)" \ - || ! new_count="$(jq 'length' <<<"${new_failure_ids_json}")"; then - new_failure_ids_json="" - fail_open_reason="Could not read the diff output (diff.json)." - elif [ "${new_count}" -eq 0 ]; then - # Current run's failures are all already present in the - # previous run's signature (identical-or-superset): develop - # is persistently red for the same reason, not because of - # this merge. - should_revert="false" - comparison_state="persistent" - else - # A new signature is necessary but not sufficient for a - # revert. Attribution must still provide positive ownership - # evidence before the revert step can run. - should_revert="true" - comparison_state="new" - fi - else - fail_open_reason="scripts/post_merge_signature.py diff exited non-zero comparing this run to ${previous_run_url}." - fi - fi - - # Never silently suppress the red state on a comparison error. An - # unresolved reason routes to an unknown-attribution incident; it - # must not manufacture ownership from temporal adjacency. - if [ -n "${fail_open_reason}" ]; then - echo "Comparison incomplete: ${fail_open_reason}" - should_revert="false" - comparison_state="unknown" - fi - - if [ "${should_revert}" = "true" ]; then - revert_suppressed="false" - else - revert_suppressed="true" - fi - - { - echo "should-revert=${should_revert}" - echo "revert-suppressed=${revert_suppressed}" - echo "fail-open-reason=${fail_open_reason}" - echo "previous-run-url=${previous_run_url}" - echo "comparison-state=${comparison_state}" - } >> "${GITHUB_OUTPUT}" - - { - echo "new-failure-ids<> "${GITHUB_OUTPUT}" - - - name: Attribute new failures to the blamed SHA - id: attribute - if: steps.signature-decision.outputs.comparison-state != 'persistent' - shell: bash - env: - NEW_FAILURE_IDS: ${{ steps.signature-decision.outputs.new-failure-ids }} - COMPARISON_STATE: ${{ steps.signature-decision.outputs.comparison-state }} - COMPARISON_FAILURE: ${{ steps.signature-decision.outputs.fail-open-reason }} - run: | - set -euo pipefail - if [ -z "${NEW_FAILURE_IDS}" ] || [ "${NEW_FAILURE_IDS}" = "null" ]; then - failure_ids_json="[]" - else - failure_ids_json="${NEW_FAILURE_IDS}" - fi - - # Capture the immutable PR association and live target before any - # mutation. A missing API result is evidence loss, not permission to - # fall back to the most recent merge. - pr_json="{}" - if ! pr_json="$(gh api graphql \ - -f owner="${GITHUB_REPOSITORY_OWNER}" \ - -f repo="${GITHUB_REPOSITORY#*/}" \ - -f oid="${FAILING_SHA}" \ - -f query='query($owner: String!, $repo: String!, $oid: GitObjectID!) { repository(owner: $owner, name: $repo) { object(oid: $oid) { ... on Commit { associatedPullRequests(first: 1) { nodes { number title url author { login } } } } } } }' \ - --jq '.data.repository.object.associatedPullRequests.nodes[0] // {}')"; then - echo "Could not resolve the PR associated with ${FAILING_SHA}; recording unknown attribution." - pr_json="{}" - fi - pr_author="$(jq -r '.author.login // empty' <<<"${pr_json}")" - owner="${pr_author:-BenchBox-dev}" - - current_target_sha="" - if ! current_target_sha="$(git ls-remote origin refs/heads/develop | cut -f1)"; then - current_target_sha="" - fi - predecessor_sha="$(git rev-parse --verify --quiet "${FAILING_SHA}^1" || true)" - # Comparison failures can leave current-combined.json absent or - # malformed. Keep this attribution step alive with a safe, - # explicit empty signature so the unknown incident is still - # recorded; an empty signature can never create ownership evidence. - empty_signature='{"failure_ids":[],"jobs":[],"source_inputs":{}}' - current_signature="${empty_signature}" - if [ -f current-combined.json ]; then - if ! current_signature="$(jq -ce 'if type == "object" and ((.failure_ids // []) | type == "array") and ((.jobs // []) | type == "array") and ((.source_inputs // {}) | type == "object") then . else error("signature object has invalid fields") end' current-combined.json 2>/dev/null)"; then - echo "Current combined signature is missing or malformed; using the safe unknown-attribution fallback." - current_signature="${empty_signature}" - fi - fi - - previous_signature="${empty_signature}" - if [ -f previous-combined.json ]; then - if ! previous_signature="$(jq -ce 'if type == "object" and ((.failure_ids // []) | type == "array") and ((.jobs // []) | type == "array") and ((.source_inputs // {}) | type == "object") then . else error("signature object has invalid fields") end' previous-combined.json 2>/dev/null)"; then - echo "Previous combined signature is malformed; using the safe evidence fallback." - previous_signature="${empty_signature}" - fi - fi - - jq -n \ - --arg failing_sha "${FAILING_SHA}" \ - --arg run_url "${RUN_URL}" \ - --arg current_target_sha "${current_target_sha}" \ - --arg predecessor_sha "${predecessor_sha}" \ - --arg previous_run_url "${{ steps.signature-decision.outputs.previous-run-url }}" \ - --arg owner "${owner}" \ - --arg comparison_state "${COMPARISON_STATE}" \ - --arg comparison_failure "${COMPARISON_FAILURE}" \ - --argjson failure_ids "${failure_ids_json}" \ - --argjson failing_pr "${pr_json}" \ - --argjson current_signature "${current_signature}" \ - --argjson previous_signature "${previous_signature}" \ - '{ - evidence_version: 2, - failing_sha: $failing_sha, - run_url: $run_url, - current_target_sha: $current_target_sha, - failure_ids: $failure_ids, - jobs: ($current_signature.jobs // []), - failing_pr: $failing_pr, - owner: $owner, - source_inputs: ($current_signature.source_inputs // {}), - predecessor_source_inputs: ($previous_signature.source_inputs // {}), - predecessor_evidence: { - run_url: $previous_run_url, - sha: $predecessor_sha, - signature: $previous_signature - }, - comparison_state: $comparison_state, - comparison_failure: $comparison_failure, - proposed_revert: {inspected: false} - }' > attribution-evidence.json - - python3 scripts/post_merge_signature.py attribute \ - --sha "${FAILING_SHA}" \ - --failure-ids <(printf '%s\n' "${failure_ids_json}") \ - --evidence attribution-evidence.json \ - --out attribution.json - action="$(jq -r '.action' attribution.json)" - echo "action=${action}" >> "${GITHUB_OUTPUT}" - echo "incident-key=$(jq -r '.incident_key' attribution.json)" >> "${GITHUB_OUTPUT}" - { - echo "incident-artifact<> "${GITHUB_OUTPUT}" - echo "Attribution action: ${action}" - - - name: Finalize revert-suppressed for metrics - id: revert-outcome - # Always runs (even when the attribute step above was skipped) so the - # job output below has a value in every case: the signature-decision - # step's initial revert-suppressed value is based on comparison only; - # the final value below also accounts for attribution. Metrics must - # report a revert as suppressed unless positive ownership evidence - # actually reaches the revert step. - if: always() - shell: bash - run: | - set -euo pipefail - revert_suppressed="${{ steps.signature-decision.outputs.revert-suppressed }}" - if [ "${{ steps.attribute.outputs.action }}" != "revert" ]; then - revert_suppressed="true" - fi - echo "revert-suppressed=${revert_suppressed}" >> "${GITHUB_OUTPUT}" - - - name: Open revert PR or conflict issue - id: open-revert - if: steps.signature-decision.outputs.should-revert == 'true' && steps.attribute.outputs.action == 'revert' - shell: bash - env: - FAIL_OPEN_REASON: ${{ steps.signature-decision.outputs.fail-open-reason }} - NEW_FAILURE_IDS: ${{ steps.signature-decision.outputs.new-failure-ids }} - run: | - set -euo pipefail - - short_sha="${FAILING_SHA:0:12}" - revert_branch="auto-revert/${short_sha}" - - if [ -n "${FAIL_OPEN_REASON}" ]; then - signature_note=" - - Signature comparison could not be completed, so this revert was opened conservatively (fail-open): ${FAIL_OPEN_REASON}" - elif [ -n "${NEW_FAILURE_IDS}" ] && [ "${NEW_FAILURE_IDS}" != "[]" ]; then - signature_note=" - - New failure IDs vs the previous develop-post-merge run: - $(jq -r '.[] | "- " + .' <<<"${NEW_FAILURE_IDS}")" - else - signature_note="" - fi - - gh label create incident:develop-red \ - --description "Develop post-merge failure with an automatic revert PR" \ - --color B60205 >/dev/null 2>&1 || true - gh label create incident:develop-red-revert-conflict \ - --description "Develop post-merge failure where automatic revert conflicted" \ - --color D93F0B >/dev/null 2>&1 || true - - existing_pr_url="$(gh pr list \ - --state open \ - --base develop \ - --head "${revert_branch}" \ - --json url \ - --jq '.[0].url // ""')" - if [ -n "${existing_pr_url}" ]; then - echo "Revert PR already exists: ${existing_pr_url}" - echo "auto-revert-pr-url=${existing_pr_url}" >> "${GITHUB_OUTPUT}" - exit 0 - fi - - repo_name="${GITHUB_REPOSITORY#*/}" - pr_json="$(gh api graphql \ - -f owner="${GITHUB_REPOSITORY_OWNER}" \ - -f repo="${repo_name}" \ - -f oid="${FAILING_SHA}" \ - -f query=' - query($owner: String!, $repo: String!, $oid: GitObjectID!) { - repository(owner: $owner, name: $repo) { - object(oid: $oid) { - ... on Commit { - associatedPullRequests(first: 1) { - nodes { - number - title - url - author { - login - } - } - } - } - } - } - } - ' \ - --jq '.data.repository.object.associatedPullRequests.nodes[0] // {}')" - - pr_number="$(jq -r '.number // empty' <<<"${pr_json}")" - pr_title="$(jq -r '.title // empty' <<<"${pr_json}")" - pr_url="$(jq -r '.url // empty' <<<"${pr_json}")" - pr_author="$(jq -r '.author.login // empty' <<<"${pr_json}")" - - if [ -z "${pr_number}" ]; then - pr_label="commit ${short_sha}" - pr_title="commit ${short_sha}" - pr_url="${RUN_URL}" - else - pr_label="#${pr_number}" - fi - - open_manual_action_issue() { - local title="$1" - local reason="$2" - - # Idempotency: if a re-run of this workflow lands on the - # fallback path again for the same SHA (workflow re-run, retry - # after manual intervention), reuse the existing open issue - # instead of opening a duplicate. Match by SHA in the body via - # the GitHub search index, scoped to the conflict label. - existing_issue_url="$(gh issue list \ - --state open \ - --label incident:develop-red-revert-conflict \ - --search "${FAILING_SHA} in:body" \ - --json url \ - --jq '.[0].url // ""')" - if [ -n "${existing_issue_url}" ]; then - echo "Conflict issue already exists: ${existing_issue_url}" - echo "conflict-issue-url=${existing_issue_url}" >> "${GITHUB_OUTPUT}" - return 0 - fi - - body_file="$(mktemp)" - cat > "${body_file}" <> "${GITHUB_OUTPUT}" - echo "Opened conflict issue: ${issue_url}" - } - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git fetch --no-tags origin develop - # Pull the existing remote revert ref (if any) so the - # --force-with-lease check below has a real expected value - # instead of relying on an implicit lease against a - # remote-tracking ref that may not exist yet. - git fetch --no-tags origin "${revert_branch}" || true - git checkout -B "${revert_branch}" origin/develop - - target_sha="$(git rev-parse --verify origin/develop || true)" - if [ -z "${target_sha}" ] || [ "${target_sha}" != "${FAILING_SHA}" ]; then - open_manual_action_issue \ - "Auto-revert not attempted for stale develop target ${short_sha}" \ - "The live develop target changed after attribution was computed (expected ${FAILING_SHA}, found ${target_sha:-missing}). Re-evaluate the failure against the current target; no stale revert was proposed." - exit 0 - fi - - # Prepare the inverse without committing so the target state and the - # exact proposed diff are visible before a retry/push/PR mutation. - if git revert --no-commit "${FAILING_SHA}"; then - proposed_revert_paths="$(git diff --name-only)" - if [ -z "${proposed_revert_paths}" ] || ! git diff --check; then - git revert --abort || git reset --hard HEAD - open_manual_action_issue \ - "Auto-revert diff inspection failed for ${short_sha}" \ - "The live target was checked, but the proposed inverse diff was empty or failed git diff --check. No revert PR was opened." - exit 0 - fi - echo "Inspected proposed revert diff on develop@${target_sha}:" - git diff --stat - git diff --name-status - git commit -m "Revert ${pr_title} (${pr_label}) - develop went red post-merge" - remote_lease="$(git rev-parse --verify --quiet "refs/remotes/origin/${revert_branch}" || true)" - if [ -n "${remote_lease}" ]; then - push_lease="--force-with-lease=${revert_branch}:${remote_lease}" - else - push_lease="--force-with-lease=${revert_branch}:" - fi - if ! git push "${push_lease}" origin "${revert_branch}"; then - open_manual_action_issue \ - "Auto-revert could not push branch for develop red at ${short_sha}" \ - "The revert commit was created locally, but pushing \`${revert_branch}\` failed. This can happen if the offending commit changes workflow files and \`GITHUB_TOKEN\` lacks the \`workflows\` permission." - exit 0 - fi - - body_file="$(mktemp)" - cat > "${body_file}" <> "${GITHUB_OUTPUT}" - echo "Opened revert PR: ${revert_pr_url}" - else - git revert --abort || true - open_manual_action_issue \ - "Auto-revert conflict for develop red at ${short_sha}" \ - "The automatic \`git revert ${FAILING_SHA}\` conflicted." - fi - - - name: Upsert develop-red incident issue - id: upsert-incident - # Persistent, unknown, stale, external-reference, transient, and - # unmappable outcomes all share one owned incident path. Only the - # positive code-regression outcome opens a revert PR. - if: steps.signature-decision.outputs.should-revert == 'false' || steps.attribute.outputs.action == 'advisory' - shell: bash - env: - PREVIOUS_RUN_URL: ${{ steps.signature-decision.outputs.previous-run-url }} - COMPARISON_STATE: ${{ steps.signature-decision.outputs.comparison-state }} - ATTRIBUTION_ACTION: ${{ steps.attribute.outputs.action }} - INCIDENT_KEY: ${{ steps.attribute.outputs.incident-key }} - ATTRIBUTION_ARTIFACT: ${{ steps.attribute.outputs.incident-artifact }} - run: | - set -euo pipefail - - gh label create incident:develop-red \ - --description "Develop post-merge failure requiring an owned action" \ - --color B60205 >/dev/null 2>&1 || true - - empty_signature='{"failure_ids":[],"jobs":[],"source_inputs":{}}' - current_signature="${empty_signature}" - if [ -f current-combined.json ]; then - if ! current_signature="$(jq -ce 'if type == "object" and ((.failure_ids // []) | type == "array") and ((.jobs // []) | type == "array") and ((.source_inputs // {}) | type == "object") then . else error("signature object has invalid fields") end' current-combined.json 2>/dev/null)"; then - echo "Current combined signature is missing or malformed; using the empty signature for the incident fallback." - current_signature="${empty_signature}" - fi - else - echo "Current combined signature is unavailable; using the empty signature for the incident fallback." - fi - current_failure_ids_json="$(jq -c '[.failure_ids[] | tostring] | unique | sort' <<<"${current_signature}")" - current_incident_key="$(printf '%s' "${current_failure_ids_json}" | sha256sum | cut -c1-16)" - incident_key="${INCIDENT_KEY}" - if [ "${COMPARISON_STATE}" = "persistent" ] || [ -z "${incident_key}" ]; then - incident_key="${current_incident_key}" - fi - artifact="${ATTRIBUTION_ARTIFACT}" - if [ -z "${artifact}" ]; then - artifact="$(jq -n \ - --arg key "${incident_key}" \ - --arg sha "${FAILING_SHA}" \ - --arg run "${RUN_URL}" \ - --arg prev "${PREVIOUS_RUN_URL}" \ - --argjson signature "${current_signature}" \ - '{schema_version: 2, incident_key: $key, failing_commit: {sha: $sha, run_url: $run}, failing_pr: {}, failure_ids: ($signature.failure_ids // []), jobs: ($signature.jobs // []), test_ids: ($signature.failure_ids // []), source_input_identities: {current: ($signature.source_inputs // {}), predecessor: {}}, predecessor_evidence: {run_url: $prev}, ownership_match: false, classification: "persistent-failure", action: "advisory", next_action: "Investigate the existing red incident; no new revert is needed.", owner: "BenchBox-dev", reasons: ["incident fallback uses the current combined failure signature"]}')" - fi - owner="$(jq -r '.owner // "BenchBox-dev"' <<<"${artifact}")" - classification="$(jq -r '.classification // "unknown"' <<<"${artifact}")" - next_action="$(jq -r '.next_action // "An owner must classify the evidence and choose fix-forward or a reviewed revert; keep develop red."' <<<"${artifact}")" - artifact_summary="$(jq -c . <<<"${artifact}")" - - # One open incident issue per persistent-red episode: reuse it - # across every run that repeats the same failure signature instead - # of opening a new issue (or a revert PR) each time. - existing_issue_url="$(gh issue list \ - --state open \ - --label incident:develop-red \ - --search "${incident_key} in:body" \ - --json url \ - --jq '.[0].url // ""')" - - comment_body="Develop post-merge incident update. - - Failing SHA: \`${FAILING_SHA}\` - Failing run: ${RUN_URL} - Classification: ${classification} - Action: ${ATTRIBUTION_ACTION:-advisory} - Owner: @${owner#@} - Next action: ${next_action} - - Incident key: \`${incident_key}\` - Evidence artifact: \`${artifact_summary}\`" - - if [ -n "${existing_issue_url}" ]; then - gh issue comment "${existing_issue_url}" --body "${comment_body}" - echo "incident-issue-url=${existing_issue_url}" >> "${GITHUB_OUTPUT}" - echo "Updated existing incident issue: ${existing_issue_url}" - else - body_file="$(mktemp)" - cat > "${body_file}" <> "${GITHUB_OUTPUT}" - echo "Opened incident issue: ${issue_url}" - fi - - green-run-cleanup: - name: green-run-cleanup - # Mirrors close-orphaned-prs' close-with-comment shape: when all four - # gates are green, any open auto-revert/* PR or develop-red incident - # issue left over from an earlier red streak is now stale - the failure - # it tracked is resolved, so clear it instead of leaving it for a human - # to notice and close by hand. - needs: [lint, fast-test, explorer-tokens, medium-test] - # Schedule path is gates-only: never close PRs/issues from a sweep. - # event_name must lead so always() cannot re-enable mutation on schedule. - if: ${{ github.event_name != 'schedule' && always() && needs.lint.result == 'success' && needs.fast-test.result == 'success' && needs.explorer-tokens.result == 'success' && needs.medium-test.result == 'success' }} - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - pull-requests: write - env: - GH_TOKEN: ${{ github.token }} - MERGED_SHA: ${{ github.sha }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event_name == 'schedule' && 'develop' || github.sha }} - - - name: Close stale auto-revert PRs and develop-red incidents - shell: bash - run: | - set -euo pipefail - - current_target_sha="$(git ls-remote origin refs/heads/develop | cut -f1)" - if [ -z "${current_target_sha}" ] || [ "${current_target_sha}" != "${MERGED_SHA}" ]; then - echo "Develop moved from ${MERGED_SHA} to ${current_target_sha:-missing}; leaving incidents and revert PRs open." - exit 0 - fi - - comment_body="Closing: develop is green again at ${RUN_URL} (\`${MERGED_SHA:0:12}\`)." - - mapfile -t pr_numbers < <(gh pr list \ - --state open \ - --base develop \ - --json number,headRefName \ - --jq '.[] | select(.headRefName | startswith("auto-revert/")) | .number') - - if [ "${#pr_numbers[@]}" -eq 0 ]; then - echo "No open auto-revert/* PRs to close." - else - for number in "${pr_numbers[@]}"; do - if gh pr close "${number}" --comment "${comment_body}"; then - echo "PR #${number}: closed (develop green)." - else - echo "PR #${number}: gh pr close failed; leaving open." >&2 - fi - done - fi - - for label in incident:develop-red incident:develop-red-revert-conflict; do - mapfile -t issue_numbers < <(gh issue list \ - --state open \ - --label "${label}" \ - --json number \ - --jq '.[].number') - - if [ "${#issue_numbers[@]}" -eq 0 ]; then - echo "No open ${label} issues to close." - continue - fi - - for number in "${issue_numbers[@]}"; do - if gh issue close "${number}" --comment "${comment_body}"; then - echo "Issue #${number} (${label}): closed (develop green)." - else - echo "Issue #${number} (${label}): gh issue close failed; leaving open." >&2 - fi - done - done - - metrics: - name: metrics - runs-on: ubuntu-latest - needs: [lint, fast-test, explorer-tokens, medium-test, auto-revert-on-failure, queue-certification] - if: ${{ always() }} - permissions: - actions: read - contents: read - env: - GH_TOKEN: ${{ github.token }} - MERGED_SHA: ${{ github.sha }} - RUN_ID: ${{ github.run_id }} - LINT_RESULT: ${{ needs.lint.result }} - FAST_TEST_RESULT: ${{ needs.fast-test.result }} - EXPLORER_TOKENS_RESULT: ${{ needs.explorer-tokens.result }} - # medium-test was promoted to a blocking gate 2026-07-06 (see its - # promotion note): needs.medium-test.result now reflects real step - # failures (continue-on-error dropped), so this var is live and does - # flip post_merge_red like the other three gates. - MEDIUM_TEST_RESULT: ${{ needs.medium-test.result }} - AUTO_REVERT_PR_OPENED_AT: ${{ needs.auto-revert-on-failure.outputs.auto-revert-pr-opened-at }} - AUTO_REVERT_PR_URL: ${{ needs.auto-revert-on-failure.outputs.auto-revert-pr-url }} - CONFLICT_ISSUE_URL: ${{ needs.auto-revert-on-failure.outputs.conflict-issue-url }} - NEW_FAILURE_IDS: ${{ needs.auto-revert-on-failure.outputs.new-failure-ids }} - REVERT_SUPPRESSED: ${{ needs.auto-revert-on-failure.outputs.revert-suppressed }} - CERTIFYING_RUN_ID: ${{ needs.queue-certification.outputs.certifying_run_id }} - steps: - - name: Write dev-loop metrics JSON - shell: bash - run: | - set -euo pipefail - - repo_name="${GITHUB_REPOSITORY#*/}" - post_merge_red=false - if [ "${LINT_RESULT}" = "failure" ] || [ "${FAST_TEST_RESULT}" = "failure" ] || [ "${EXPLORER_TOKENS_RESULT}" = "failure" ] || [ "${MEDIUM_TEST_RESULT}" = "failure" ]; then - post_merge_red=true - fi - - pr_json="$(gh api graphql \ - -f owner="${GITHUB_REPOSITORY_OWNER}" \ - -f repo="${repo_name}" \ - -f oid="${MERGED_SHA}" \ - -f query=' - query($owner: String!, $repo: String!, $oid: GitObjectID!) { - repository(owner: $owner, name: $repo) { - object(oid: $oid) { - ... on Commit { - associatedPullRequests(first: 1) { - nodes { - number - createdAt - mergedAt - labels(first: 20) { - nodes { - name - } - } - } - } - } - } - } - } - ' \ - --jq '.data.repository.object.associatedPullRequests.nodes[0] // {}')" - - jobs_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100")" - - mkdir -p dev-loop-metrics - jq -n \ - --argjson pr "${pr_json}" \ - --argjson jobs "${jobs_json}" \ - --arg post_merge_red "${post_merge_red}" \ - --arg auto_revert_pr_opened_at "${AUTO_REVERT_PR_OPENED_AT}" \ - --arg auto_revert_pr_url "${AUTO_REVERT_PR_URL}" \ - --arg conflict_issue_url "${CONFLICT_ISSUE_URL}" \ - --arg new_failure_ids "${NEW_FAILURE_IDS}" \ - --arg revert_suppressed "${REVERT_SUPPRESSED}" \ - --arg certifying_run_id "${CERTIFYING_RUN_ID}" \ - 'def null_if_empty: if . == "" then null else . end; - def ts: fromdateiso8601; - ($pr.createdAt // null) as $created_at | - ($pr.mergedAt // null) as $merged_at | - (if $post_merge_red == "true" then - ([($jobs.jobs // [])[] | - select((.name == "lint" or .name == "fast-test" or .name == "explorer-tokens" or .name == "medium-test") and .conclusion == "failure" and .completed_at != null) | - .completed_at] | min // null) - else null end) as $red_at | - { - merged_at: $merged_at, - pr_number: ($pr.number // null), - pr_open_to_merged_seconds: ( - if $created_at != null and $merged_at != null then - (($merged_at | ts) - ($created_at | ts) | floor) - else null end - ), - post_merge_red: ($post_merge_red == "true"), - develop_red_detected_at: $red_at, - auto_revert_pr_opened_at: ($auto_revert_pr_opened_at | null_if_empty), - time_to_revert_pr_seconds: ( - if ($red_at != null and $auto_revert_pr_opened_at != "") then - (($auto_revert_pr_opened_at | ts) - ($red_at | ts) | floor) - else null end - ), - next_green_develop_run_at: null, - time_to_recover_red_seconds: null, - conflict_on_merge: ([($pr.labels.nodes // [])[].name] | any(test("conflict"; "i"))), - orphaned_failed_pr: (($post_merge_red == "true") and (($pr.number // null) == null)), - ci_runner_minutes: ( - (([($jobs.jobs // [])[] | - select(.started_at != null and .completed_at != null) | - ((.completed_at | ts) - (.started_at | ts))] - | add) // 0) / 60 - ), - auto_revert_pr_url: ($auto_revert_pr_url | null_if_empty), - conflict_issue_url: ($conflict_issue_url | null_if_empty), - new_failure_ids: (($new_failure_ids | fromjson?) // null), - revert_suppressed: ($revert_suppressed == "true"), - queue_certified_by_run_id: ( - if ($certifying_run_id | test("^[0-9]+$")) then - ($certifying_run_id | tonumber) - else null end - ) - }' > "dev-loop-metrics/${MERGED_SHA}.json" - - cat "dev-loop-metrics/${MERGED_SHA}.json" - - - name: Upload metrics artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: metrics - path: dev-loop-metrics/*.json - retention-days: 90 diff --git a/.github/workflows/develop-refresh-shadow.yml b/.github/workflows/develop-refresh-shadow.yml deleted file mode 100644 index cfbf84f949..0000000000 --- a/.github/workflows/develop-refresh-shadow.yml +++ /dev/null @@ -1,421 +0,0 @@ -name: Develop refresh shadow - -# Observational only. Never a required context. Never skips another workflow. -# pull_request_target so the classifier script comes from the trusted base. -# PR commits are fetched as Git objects only; they are never checked out. - -on: - pull_request_target: - branches: - - develop - types: [opened, reopened, synchronize, ready_for_review] - -concurrency: - group: develop-refresh-shadow-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - actions: read - pull-requests: read - -jobs: - refresh-shadow: - name: refresh-shadow - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout trusted base - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event.pull_request.base.sha }} - persist-credentials: false - fetch-depth: 0 - - - name: Fetch PR commits as Git objects - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - git fetch --no-tags origin "pull/${PR_NUMBER}/head:refs/shadow/pr-head" - git fetch --no-tags origin "${{ github.event.pull_request.base.sha }}" - - - name: Classify refresh in shadow mode - env: - GH_TOKEN: ${{ github.token }} - EVENT_ACTION: ${{ github.event.action }} - EVENT_BEFORE: ${{ github.event.before }} - EVENT_AFTER: ${{ github.event.after }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_REPO_ID: ${{ github.event.pull_request.head.repo.id }} - BASE_REPO_ID: ${{ github.event.pull_request.base.repo.id }} - IS_FORK: ${{ github.event.pull_request.head.repo.fork }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - python - <<'PY' - import hashlib - import io - import json - import os - import subprocess - import time - import urllib.error - import urllib.request - import zipfile - from datetime import datetime, timezone - from pathlib import Path - - started = time.monotonic() - collected_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - def git(*args: str) -> str: - return subprocess.check_output(["git", *args], text=True).strip() - - def api(path: str, accept: str = "application/vnd.github+json"): - req = urllib.request.Request( - f"https://api.github.com{path}", - headers={ - "Accept": accept, - "Authorization": f"Bearer {os.environ['GH_TOKEN']}", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - return json.loads(resp.read().decode("utf-8")) - except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError): - return {"_error": "request-failed"} - - def is_git_sha(value: object) -> bool: - if not isinstance(value, str) or len(value) not in {40, 64}: - return False - allowed = set("0123456789abcdefABCDEF") - return all(ch in allowed for ch in value) and set(value) != {"0"} - - def commit_info(sha: str): - if not sha: - return None - try: - parents = [line for line in git("rev-parse", f"{sha}^@").splitlines() if line] - tree = git("rev-parse", f"{sha}^{{tree}}") - except subprocess.CalledProcessError: - return None - return {"sha": sha, "parents": parents, "tree": tree} - - def parse_identity(raw): - if not isinstance(raw, dict): - return None - kind = raw.get("certification_kind") - fingerprint = raw.get("workflow_fingerprint") - base = raw.get("base_sha") - head = raw.get("pr_head_sha") - if kind != "full": - return None - if not isinstance(fingerprint, str) or len(fingerprint) != 64: - return None - hex_digits = set("0123456789abcdefABCDEF") - if any(ch not in hex_digits for ch in fingerprint): - return None - if not is_git_sha(base) or not is_git_sha(head): - return None - return { - "certification_kind": "full", - "workflow_fingerprint": fingerprint.lower(), - "base_sha": base, - "pr_head_sha": head, - } - - def load_identity_artifact(artifact_id: int): - # gh follows the artifact zip redirect and drops Authorization - # on the signed URL. urllib would forward the token and fail. - completed = subprocess.run( - [ - "gh", - "api", - "-H", - "Accept: application/vnd.github+json", - f"repos/{repo}/actions/artifacts/{artifact_id}/zip", - ], - check=False, - capture_output=True, - ) - if completed.returncode != 0 or not completed.stdout.startswith(b"PK"): - return None - try: - with zipfile.ZipFile(io.BytesIO(completed.stdout)) as archive: - for name in archive.namelist(): - if name.endswith(".json"): - return parse_identity(json.loads(archive.read(name).decode("utf-8"))) - except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, ValueError): - return None - return None - - repo = os.environ["GITHUB_REPOSITORY"] - listing = api("/repos/{0}/rulesets".format(repo)) - ruleset_id = None - if isinstance(listing, list): - for entry in listing: - if isinstance(entry, dict) and entry.get("name") == "develop-squash-only": - ruleset_id = entry.get("id") - break - ruleset = ( - api("/repos/{0}/rulesets/{1}".format(repo, ruleset_id)) - if ruleset_id - else {"_error": "develop-squash-only-missing"} - ) - contexts = [] - if isinstance(ruleset, dict) and "_error" not in ruleset: - for rule in ruleset.get("rules") or []: - if rule.get("type") == "required_status_checks": - for check in (rule.get("parameters") or {}).get("required_status_checks") or []: - if check.get("context"): - contexts.append(str(check["context"])) - - head = os.environ.get("HEAD_SHA") or "" - before = os.environ.get("EVENT_BEFORE") or "" - base = os.environ.get("BASE_SHA") or "" - after = os.environ.get("EVENT_AFTER") or "" - commits = {} - for sha in (head, before, base): - info = commit_info(sha) - if info: - commits[sha] = info - - merge_tree = None - merge_tree_error = None - if before and base: - completed = subprocess.run( - ["git", "merge-tree", "--write-tree", before, base], - check=False, - text=True, - capture_output=True, - ) - if completed.returncode == 0: - merge_tree = completed.stdout.strip() or None - else: - merge_tree_error = "merge-tree-failed" - - check_payload = api(f"/repos/{repo}/commits/{before}/check-runs?per_page=100") if before else {} - raw_checks = check_payload.get("check_runs") if isinstance(check_payload, dict) else None - check_runs = [] - run_ids = set() - if isinstance(raw_checks, list): - for item in raw_checks: - html = str(item.get("html_url") or "") - run_id = None - if "/actions/runs/" in html: - try: - run_id = int(html.split("/actions/runs/")[1].split("/")[0]) - run_ids.add(run_id) - except (IndexError, ValueError): - run_id = None - check_runs.append({ - "id": item.get("id") or 0, - "name": item.get("name") or "", - "status": item.get("status") or "", - "conclusion": item.get("conclusion") or "", - "started_at": item.get("started_at") or "", - "head_sha": item.get("head_sha") or "", - "run_id": run_id, - }) - - wf_files = [ - ".github/workflows/pr.yml", - ".github/workflows/results-explorer-browser.yml", - ".github/workflows/develop-ruleset-drift.yml", - ] - entries = {} - for path in wf_files: - data = Path(path).read_bytes() if Path(path).is_file() else b"" - entries[path] = hashlib.sha256(data).hexdigest() - fingerprint = hashlib.sha256( - "\n".join(f"{path}={entries[path]}" for path in sorted(entries)).encode() - ).hexdigest() - - run_meta = {} - identity_by_head = {} - for run_id in sorted(run_ids): - run = api(f"/repos/{repo}/actions/runs/{run_id}") - if not isinstance(run, dict) or run.get("_error"): - continue - arts = api(f"/repos/{repo}/actions/runs/{run_id}/artifacts") - artifacts = arts.get("artifacts") if isinstance(arts, dict) else None - names = [] - identity = None - if isinstance(artifacts, list): - for artifact in artifacts: - name = artifact.get("name") - if name: - names.append(name) - if name == "pr-certification-identity" and artifact.get("id"): - identity = load_identity_artifact(int(artifact["id"])) - run_head = str(run.get("head_sha") or "") - # A `pull_request_target` run is dispatched from the trusted base, - # so GitHub records the BASE tip as its head_sha - never the PR - # head. Binding certification by that value cannot match, which - # left a required run (e.g. `ruleset-drift`) without a - # certification_kind and forced `prior_check_unbound`, so an - # otherwise valid exact refresh could never be shadow-eligible. - # GitHub carries the PR association on the run itself; keep it as - # a secondary key used only when the direct head match fails, so - # no run that already bound can bind differently. - associated = run.get("pull_requests") or [] - assoc_head = "" - if isinstance(associated, list) and associated and isinstance(associated[0], dict): - assoc_head = str(((associated[0].get("head") or {}).get("sha")) or "") - run_meta[run_id] = { - "assoc_head_sha": assoc_head, - "id": run.get("id") or run_id, - "name": run.get("name") or "", - "path": run.get("path") or "", - "head_sha": run_head, - "event": run.get("event") or "", - "artifact_names": names, - "identity": identity, - } - if ( - identity - and "pr-certification-identity" in names - and "pr-certification-lanes" in names - ): - identity_by_head[identity["pr_head_sha"]] = identity - - actions_runs = [] - for run_id in sorted(run_meta): - info = run_meta[run_id] - identity = identity_by_head.get(info["head_sha"]) - if not identity and info.get("assoc_head_sha"): - identity = identity_by_head.get(info["assoc_head_sha"]) - if identity: - kind = "full" - run_fingerprint = identity["workflow_fingerprint"] - run_base = identity["base_sha"] - run_head = identity.get("pr_head_sha") or info.get("assoc_head_sha") or info["head_sha"] - else: - kind = "" - run_fingerprint = "" - run_base = "" - run_head = info["head_sha"] - actions_runs.append({ - "id": info["id"], - "name": info["name"], - "path": info["path"], - "head_sha": run_head, - "event": info["event"], - "certification_kind": kind, - "workflow_fingerprint": run_fingerprint, - "base_sha": run_base, - }) - - pr = api(f"/repos/{repo}/pulls/{os.environ['PR_NUMBER']}") - current_head = "" - current_base = "" - if isinstance(pr, dict) and "_error" not in pr: - current_head = str((pr.get("head") or {}).get("sha") or "") - current_base = str((pr.get("base") or {}).get("sha") or "") - - authored = None - intervening = None - try: - if base and head: - authored = [line for line in git("diff", "--name-only", f"{base}...{head}").splitlines() if line] - if before and base: - intervening = [line for line in git("diff", "--name-only", f"{before}...{base}").splitlines() if line] - except subprocess.CalledProcessError: - authored = None - intervening = None - - request = { - "action": os.environ.get("EVENT_ACTION") or "", - "before": before, - "after": after, - "head_sha": head, - "base_sha": base, - "current_head_sha": current_head or head, - "current_base_sha": current_base or base, - "head_repo_id": int(os.environ["HEAD_REPO_ID"]) if os.environ.get("HEAD_REPO_ID") else None, - "base_repo_id": int(os.environ["BASE_REPO_ID"]) if os.environ.get("BASE_REPO_ID") else None, - "is_fork": os.environ.get("IS_FORK") == "true", - "pr_number": int(os.environ["PR_NUMBER"]), - "synthetic_merge_sha": "", - "required_contexts": contexts, - "commits": commits, - "merge_tree": merge_tree, - "merge_tree_error": merge_tree_error, - "check_runs": check_runs, - "actions_runs": actions_runs, - "current_workflow_fingerprint": fingerprint, - "authored_paths": authored, - "intervening_paths": intervening, - } - Path("/tmp/shadow-request.json").write_text(json.dumps(request), encoding="utf-8") - duration_ms = int((time.monotonic() - started) * 1000) - Path("/tmp/shadow-timing.json").write_text( - json.dumps({"collected_at": collected_at, "duration_ms": duration_ms}), - encoding="utf-8", - ) - PY - if [ ! -f scripts/pr_refresh_certification.py ]; then - printf '%s\n' '{"decision":"full_required","reasons":["missing_path_evidence"],"version":1}' > shadow-decision.json - else - python scripts/pr_refresh_certification.py --input /tmp/shadow-request.json --json-out shadow-decision.json - fi - python - <<'PY' - import json - import os - from pathlib import Path - - decision = json.loads(Path("shadow-decision.json").read_text(encoding="utf-8")) - timing = {} - timing_path = Path("/tmp/shadow-timing.json") - if timing_path.is_file(): - timing = json.loads(timing_path.read_text(encoding="utf-8")) - record = { - "decision": decision.get("decision"), - "reasons": decision.get("reasons") or [], - "pr_number": decision.get("pr_number"), - "feature_head": decision.get("feature_head"), - "tested_base_sha": decision.get("tested_base_sha"), - "parent1": decision.get("parent1"), - "parent2": decision.get("parent2"), - "required_contexts": decision.get("required_contexts") or [], - "check_run_ids": decision.get("check_run_ids") or {}, - "actions_run_ids": decision.get("actions_run_ids") or {}, - "workflow_fingerprint": decision.get("workflow_fingerprint"), - "certification_kind": decision.get("certification_kind"), - "version": decision.get("version"), - "event_action": os.environ.get("EVENT_ACTION") or "", - "collected_at": timing.get("collected_at"), - "duration_ms": timing.get("duration_ms"), - } - Path("shadow-decision.json").write_text( - json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" - ) - summary = os.environ.get("GITHUB_STEP_SUMMARY") - if summary: - reasons = ",".join(record["reasons"]) or "(none)" - Path(summary).open("a", encoding="utf-8").write( - "\n".join( - [ - "### Refresh shadow classification", - "", - f"- Decision: `{record['decision']}`", - f"- Reasons: `{reasons}`", - f"- Duration: `{record.get('duration_ms')} ms`", - "- This record is not a required check and cannot skip CI.", - "", - ] - ) - ) - PY - - - name: Upload shadow decision - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: refresh-shadow-${{ github.event.pull_request.number }}-${{ github.run_attempt }} - path: shadow-decision.json - if-no-files-found: warn - retention-days: 30 diff --git a/.github/workflows/develop-ruleset-drift.yml b/.github/workflows/develop-ruleset-drift.yml deleted file mode 100644 index deb090db40..0000000000 --- a/.github/workflows/develop-ruleset-drift.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Develop ruleset drift - -on: - pull_request_target: - branches: - - develop - types: [opened, synchronize, reopened, ready_for_review] - merge_group: - branches: - - develop - -concurrency: - group: develop-ruleset-drift-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - ruleset-drift: - name: ruleset-drift - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout trusted develop policy - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} - persist-credentials: false - - - name: Compare live governance with the trusted runbook - env: - RULESET_DRIFT_TOKEN: ${{ secrets.RULESET_DRIFT_TOKEN }} - run: | - set -euo pipefail - python scripts/ruleset_drift_check.py \ - --token "${RULESET_DRIFT_TOKEN}" \ - --require-bypass-actor-visibility \ - --output ruleset-drift.json - - - name: Upload drift evidence - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: develop-ruleset-drift-${{ github.event.pull_request.number || github.run_id }}-${{ github.run_attempt }} - path: ruleset-drift.json - if-no-files-found: warn - retention-days: 30 diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml new file mode 100644 index 0000000000..9dfa8a109a --- /dev/null +++ b/.github/workflows/docker-integration.yml @@ -0,0 +1,190 @@ +name: Docker integration + +# Owned pipeline for Docker-based integration tests: brings up the compose +# stacks under docker/ and runs the matching live-marker suites against them. +# Scheduled nightly (per-PR would burn runner time on heavy images); also +# runnable on demand via workflow_dispatch with an optional service filter. + +on: + workflow_dispatch: + inputs: + services: + description: 'Comma-separated subset to run (default: all). Choices: postgres,clickhouse,trino' + required: false + default: 'all' + schedule: + - cron: '0 7 * * *' + +concurrency: + group: docker-integration-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + validate-input: + runs-on: ubuntu-latest + outputs: + run_postgres: ${{ steps.select.outputs.run_postgres }} + run_clickhouse: ${{ steps.select.outputs.run_clickhouse }} + run_trino: ${{ steps.select.outputs.run_trino }} + steps: + - name: Validate services input and select jobs + id: select + env: + EVENT_NAME: ${{ github.event_name }} + SERVICES: ${{ inputs.services }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != workflow_dispatch ]; then + echo "run_postgres=true" >> "$GITHUB_OUTPUT" + echo "run_clickhouse=true" >> "$GITHUB_OUTPUT" + echo "run_trino=true" >> "$GITHUB_OUTPUT" + echo "Scheduled run: all services selected." + exit 0 + fi + run_postgres=false; run_clickhouse=false; run_trino=false + IFS=',' read -ra TOKENS <<< "$SERVICES" + for tok in "${TOKENS[@]}"; do + t="$(echo "$tok" | tr -d '[:space:]')" + case "$t" in + all) run_postgres=true; run_clickhouse=true; run_trino=true ;; + postgres) run_postgres=true ;; + clickhouse) run_clickhouse=true ;; + trino) run_trino=true ;; + '') + echo "::error::Empty service token in services input '$SERVICES'." + exit 1 + ;; + *) + echo "::error::Unknown service '$t'. Choices: postgres,clickhouse,trino (or all)." + exit 1 + ;; + esac + done + # Literal comparison: glob metacharacters in input cannot match. + if [ "$run_postgres" = false ] && [ "$run_clickhouse" = false ] && [ "$run_trino" = false ]; then + echo "::error::No services selected." + exit 1 + fi + echo "run_postgres=$run_postgres" >> "$GITHUB_OUTPUT" + echo "run_clickhouse=$run_clickhouse" >> "$GITHUB_OUTPUT" + echo "run_trino=$run_trino" >> "$GITHUB_OUTPUT" + echo "Selected: postgres=$run_postgres clickhouse=$run_clickhouse trino=$run_trino" + + postgres: + needs: [validate-input] + if: ${{ needs.validate-input.outputs.run_postgres == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Bring up PostgreSQL compose stack + run: docker compose -f docker/postgresql/docker-compose.yml up -d --wait + + - name: Run PostgreSQL live integration tests + run: | + set -o pipefail + uv run -- python -m pytest tests/integration/platforms/test_postgresql_live.py \ + -m "live_postgresql" --tb=short -v -p no:cacheprovider \ + | tee /tmp/pg_docker_output.txt + if ! grep -Eq '[0-9]+ passed' /tmp/pg_docker_output.txt; then + echo "::error::PostgreSQL Docker integration produced no passing tests." + exit 1 + fi + + - name: Tear down compose stack + if: always() + run: docker compose -f docker/postgresql/docker-compose.yml down -v + + clickhouse: + needs: [validate-input] + if: ${{ needs.validate-input.outputs.run_clickhouse == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Bring up ClickHouse compose stack + env: + CLICKHOUSE_MEMORY_LIMIT: 4g + run: docker compose -f docker/clickhouse/docker-compose.yml up -d --wait + + - name: Run ClickHouse live integration tests + run: | + set -o pipefail + uv run -- python -m pytest tests/integration/platforms/test_clickhouse_live.py \ + -m "live_clickhouse" --tb=short -v -p no:cacheprovider \ + | tee /tmp/ch_docker_output.txt + if ! grep -Eq '[0-9]+ passed' /tmp/ch_docker_output.txt; then + echo "::error::ClickHouse Docker integration produced no passing tests." + exit 1 + fi + + - name: Tear down compose stack + if: always() + run: docker compose -f docker/clickhouse/docker-compose.yml down -v + + trino: + needs: [validate-input] + if: ${{ needs.validate-input.outputs.run_trino == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Bring up Trino compose stack + run: docker compose -f docker/trino/docker-compose.yml up -d --wait + + - name: Run Trino live integration tests + run: | + set -o pipefail + uv run -- python -m pytest tests/integration/platforms/test_trino_live.py \ + -m "live_trino" --tb=short -v -p no:cacheprovider \ + | tee /tmp/trino_docker_output.txt + if ! grep -Eq '[0-9]+ passed' /tmp/trino_docker_output.txt; then + echo "::error::Trino Docker integration produced no passing tests." + exit 1 + fi + + - name: Tear down compose stack + if: always() + run: docker compose -f docker/trino/docker-compose.yml down -v diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 57e5ac06e9..26dd7fcf58 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,26 +1,16 @@ name: Documentation on: + # Pull-request validation lives in ci.yml. This workflow keeps + # the protected-develop baseline capture and the release Pages deploy. push: branches: [release, develop] - pull_request: - branches: [release, develop] - paths: - - "benchbox/**" - - "docs/**" - - "examples/**" - - "landing/**" - - "_project/scripts/explorer_pipeline/**" - - "_project/scripts/explorer_publish.py" - - "_project/scripts/results_explorer_snapshot_invariants.py" - - "results-data/**" - - "results-explorer/**" - - "scripts/**" - - ".github/workflows/docs.yml" - - "Makefile" - - "README.md" - - "pyproject.toml" workflow_dispatch: + inputs: + baseline_source_sha: + description: "Protected develop ancestor to capture after a dropped push (40 lowercase hex characters)" + required: false + type: string permissions: contents: read @@ -35,12 +25,102 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + visual-inputs: + name: Public-site visual inputs + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.paths.outputs.changed }} + source_sha: ${{ steps.paths.outputs.source_sha }} + base_sha: ${{ steps.paths.outputs.base_sha }} + baseline_candidates: ${{ steps.paths.outputs.baseline_candidates }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Classify site inputs and validate recovery source + id: paths + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + RECOVERY_SOURCE_SHA: ${{ inputs.baseline_source_sha }} + CURRENT_SHA: ${{ github.sha }} + CURRENT_REF: ${{ github.ref }} + shell: bash + run: | + set -euo pipefail + SOURCE_SHA="$CURRENT_SHA" + if [ -n "$RECOVERY_SOURCE_SHA" ]; then + if [ "$EVENT_NAME" != workflow_dispatch ] || [ "$CURRENT_REF" != refs/heads/develop ] || [[ ! "$RECOVERY_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Recovery requires a full lowercase SHA dispatched on develop." + exit 1 + fi + git fetch origin develop + git merge-base --is-ancestor "$RECOVERY_SOURCE_SHA" origin/develop || { + echo "::error::Recovery source must be an ancestor of protected develop." + exit 1 + } + SOURCE_SHA="$RECOVERY_SOURCE_SHA" + fi + echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + BASE_SHA="$PR_BASE_SHA" + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" != pull_request ]; then + echo 'changed=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Missing exact PR base SHA." + exit 1 + fi + git cat-file -e "$BASE_SHA^{commit}" || { + echo "::error::Exact base commit is unavailable." + exit 1 + } + # Preserve every former pull_request path-filter input. A change to + # this list must explain which rendered dependency is being removed. + # The build runs uv sync and npm ci, so the lockfiles pin the + # rendered output as much as any source file. uv.lock and + # results-explorer/package-lock.json must stay in this list. + SITE_PATHS=( + benchbox/ docs/ examples/ landing/ + _project/scripts/explorer_pipeline/ + _project/scripts/explorer_publish.py + _project/scripts/results_explorer_snapshot_invariants.py + results-data/ results-explorer/ scripts/ + .github/workflows/docs.yml Makefile README.md pyproject.toml uv.lock + ) + CHANGED_PATHS=$(git diff --name-only "$BASE_SHA"...HEAD -- "${SITE_PATHS[@]}") + if [ -z "$CHANGED_PATHS" ]; then + echo 'changed=false' >> "$GITHUB_OUTPUT" + else + echo 'changed=true' >> "$GITHUB_OUTPUT" + fi + # Baselines bound to the exact base come first. A first-parent + # ancestor is also a valid baseline source when every public-site + # input is byte-identical to the base, because it renders the same + # site; this is the premise that lets unchanged trees skip the gate. + # The walk stops at the first ancestor whose site inputs differ, so a + # differently rendered tree is never used. This covers a base whose + # predecessor changed no site inputs and therefore published nothing. + CANDIDATES="$BASE_SHA" + ANCESTORS=$(git rev-list --first-parent --max-count=25 "$BASE_SHA^" 2>/dev/null || true) + for ANCESTOR in $ANCESTORS; do + git diff --quiet "$ANCESTOR" "$BASE_SHA" -- "${SITE_PATHS[@]}" || break + CANDIDATES="$CANDIDATES $ANCESTOR" + done + echo "baseline_candidates=$CANDIDATES" >> "$GITHUB_OUTPUT" + # Build HTML documentation with warnings as errors build: + needs: visual-inputs + if: needs.visual-inputs.outputs.changed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ needs.visual-inputs.outputs.source_sha }} - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -150,11 +230,13 @@ jobs: (github.event_name == 'push' && github.ref == 'refs/heads/develop') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop') || (github.event_name == 'pull_request' && github.base_ref == 'develop') - needs: build + needs: [visual-inputs, build] runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 30 steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ needs.visual-inputs.outputs.source_sha }} - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -177,45 +259,41 @@ jobs: run: npx playwright install --with-deps chromium working-directory: results-explorer - - name: Download exact base visual baseline + - name: Capture public site + env: + E2E_PAGES_SHAPED: "1" + E2E_SITE_DIR: ${{ github.workspace }}/site + PUBLIC_SITE_VISUAL_PHASE: capture + PUBLIC_SITE_VISUAL_OUTPUT: ${{ runner.temp }}/public-site-visual-current + PUBLIC_SITE_VISUAL_SOURCE_SHA: ${{ needs.visual-inputs.outputs.source_sha }} + run: npm run test:e2e:public-site + working-directory: results-explorer + + # When no baseline is bound to the exact base, the lookup also accepts the + # nearest ancestor whose site inputs are byte-identical to the base. + - name: Download visual baseline for base or site-equivalent ancestor id: baseline if: github.event_name == 'pull_request' - continue-on-error: true env: GITHUB_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} - PUBLIC_SITE_VISUAL_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUBLIC_SITE_VISUAL_BASE_SHA: ${{ needs.visual-inputs.outputs.base_sha }} + PUBLIC_SITE_VISUAL_BASELINE_CANDIDATES: ${{ needs.visual-inputs.outputs.baseline_candidates }} PUBLIC_SITE_VISUAL_BASELINE: ${{ runner.temp }}/public-site-visual-baseline run: node scripts/download-public-site-visual-baseline.mjs working-directory: results-explorer - - name: Determine baseline mode - id: baseline-mode - env: - BASELINE_OUTCOME: ${{ steps.baseline.outcome }} - BOOTSTRAP: ${{ steps.baseline.outputs.bootstrap || 'false' }} - shell: bash - run: | - if [ -f "$RUNNER_TEMP/public-site-visual-baseline/manifest.json" ]; then - echo "require=1" >> "$GITHUB_OUTPUT" - echo "Exact base-SHA baseline found; comparison is blocking." - elif [ "$BASELINE_OUTCOME" = "failure" ] && [ "$BOOTSTRAP" != "true" ]; then - echo "::error::A protected baseline exists, but none is bound to this exact base SHA." - exit 1 - else - echo "require=0" >> "$GITHUB_OUTPUT" - echo "No protected baseline exists yet; running the one-time bootstrap capture." - fi - - - name: Capture and compare public site + - name: Compare public site with exact base + if: github.event_name == 'pull_request' env: E2E_PAGES_SHAPED: "1" E2E_SITE_DIR: ${{ github.workspace }}/site + PUBLIC_SITE_VISUAL_PHASE: compare PUBLIC_SITE_VISUAL_OUTPUT: ${{ runner.temp }}/public-site-visual-current - PUBLIC_SITE_VISUAL_BASELINE: ${{ steps.baseline-mode.outputs.require == '1' && format('{0}/public-site-visual-baseline', runner.temp) || '' }} - PUBLIC_SITE_VISUAL_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PUBLIC_SITE_VISUAL_REQUIRE_BASELINE: ${{ steps.baseline-mode.outputs.require }} - PUBLIC_SITE_VISUAL_SOURCE_SHA: ${{ github.sha }} + PUBLIC_SITE_VISUAL_BASELINE: ${{ runner.temp }}/public-site-visual-baseline + PUBLIC_SITE_VISUAL_BASE_SHA: ${{ steps.baseline.outputs.baseline_sha }} + PUBLIC_SITE_VISUAL_REQUIRE_BASELINE: "1" + PUBLIC_SITE_VISUAL_SOURCE_SHA: ${{ needs.visual-inputs.outputs.source_sha }} PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} APPROVED_HEAD_SHA: ${{ github.event_name == 'pull_request' && vars.APPROVED_HEAD_SHA || '' }} APPROVAL_REASON: ${{ github.event_name == 'pull_request' && vars.APPROVAL_REASON || '' }} @@ -228,7 +306,7 @@ jobs: (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: public-site-visual-baseline + name: public-site-visual-baseline-${{ needs.visual-inputs.outputs.source_sha }} path: ${{ runner.temp }}/public-site-visual-current retention-days: 30 @@ -244,6 +322,42 @@ jobs: if-no-files-found: ignore retention-days: 3 + public-site-visual-required: + name: Public-site visual acceptance + if: always() + needs: [visual-inputs, build, public-site-visual-regression] + runs-on: ubuntu-latest + env: + INPUT_RESULT: ${{ needs.visual-inputs.result }} + SITE_CHANGED: ${{ needs.visual-inputs.outputs.changed }} + BUILD_RESULT: ${{ needs.build.result }} + VISUAL_RESULT: ${{ needs.public-site-visual-regression.result }} + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + steps: + - name: Require comparison for affected develop trees + shell: bash + run: | + set -euo pipefail + if [ "$INPUT_RESULT" != success ]; then + echo "::error::Public-site input classification failed." + exit 1 + fi + if [ "$EVENT_NAME" = pull_request ] && [ "$BASE_REF" != develop ]; then + exit 0 + fi + if [ "$EVENT_NAME" != pull_request ]; then + exit 0 + fi + if [ "$SITE_CHANGED" = false ]; then + echo 'No public-site input changed.' + exit 0 + fi + if [ "$SITE_CHANGED" != true ] || [ "$BUILD_RESULT" != success ] || [ "$VISUAL_RESULT" != success ]; then + echo "::error::Changed public-site inputs require a successful build and exact-base visual comparison." + exit 1 + fi + # Deploy to GitHub Pages (release pushes only) # Isolated as its own job so the github-pages environment protection # rules don't gate the build job on PRs. @@ -262,6 +376,9 @@ jobs: actions: read pages: write id-token: write + outputs: + skipped: ${{ steps.independent.outputs.skipped }} + skipped_deployment_id: ${{ steps.capture-deployment.outputs.deployment_id }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -332,6 +449,9 @@ jobs: echo "active=$ACTIVE" >> "$GITHUB_OUTPUT" if [ "$ACTIVE" = "true" ]; then echo "::notice::Independent publication owns Pages; skipping the legacy release deployment" + echo "skipped=true" >> "$GITHUB_OUTPUT" + else + echo "skipped=false" >> "$GITHUB_OUTPUT" fi - name: Deploy to GitHub Pages @@ -339,9 +459,94 @@ jobs: if: steps.independent.outputs.active != 'true' uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 + - name: Capture this run's skipped deployment record + id: capture-deployment + if: steps.independent.outputs.skipped == 'true' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + # Bind the follow-on marker to THIS run's record, not merely "a record + # for this SHA": duplicate github-pages records per SHA exist (reruns, + # concurrent queued runs), so an unscoped newest-first lookup can mark + # the wrong record. The github-pages environment pre-created this run's + # record when the job started seconds ago; select the newest + # release-ref record for this SHA created after this workflow run + # started, which only this run's record can satisfy. + # Use this run's ISO-8601 start time as the selection boundary so + # both operands share a representation (deployment `created_at` + # values are ISO-8601 while the push event's `pushed_at` is an + # epoch value frozen at the original push, which also breaks + # reruns). Querying the Actions API anchors reruns to the current + # attempt, not merely the run: full reruns must not reuse a stale + # boundary from an earlier attempt. + RUN_STARTED=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}" \ + --jq '.run_started_at // .created_at // empty') + if [ -z "${RUN_STARTED:-}" ]; then + echo "::warning::Could not determine current run start time; skipping inactive marker." + echo "deployment_id=" >> "$GITHUB_OUTPUT" + exit 0 + fi + # NOTE: deployment records carry the bare branch name in `.ref` + # ("release", not "refs/heads/release") — verified against live + # github-pages records. + # NOTE: this gh version has no `--arg` flag, so the boundary is + # passed via the environment (`env.SINCE`) inside a single valid + # `--jq` argument. + export SINCE="$RUN_STARTED" + DEPLOYMENT_ID=$(gh api "repos/${{ github.repository }}/deployments?environment=github-pages&sha=${{ github.sha }}&per_page=100" \ + --jq '[.[] | select(.ref == "release") | select(.created_at >= env.SINCE)] | sort_by(.created_at) | last | .id // empty') + if [ -z "${DEPLOYMENT_ID:-}" ]; then + echo "::warning::No release-ref github-pages deployment record found for this run; skipping inactive marker." + fi + echo "deployment_id=$DEPLOYMENT_ID" >> "$GITHUB_OUTPUT" + + # Records the skipped legacy release deployment after the github-pages + # environment job finalizes. Running inside the deploy job above would let + # the environment's terminal status overwrite the inactive marker with + # `success` on job exit even though nothing was published. This follow-on + # job intentionally sets no `environment:` key and runs only on the skipped + # path (independent publication owns Pages). + record-skipped-deployment: + if: github.event_name == 'push' && github.ref == 'refs/heads/release' && needs.deploy.outputs.skipped == 'true' && needs.deploy.outputs.skipped_deployment_id != '' + needs: deploy + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + deployments: write + steps: + - name: Record skipped deployment + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ needs.deploy.outputs.skipped_deployment_id }} + shell: bash + run: | + set -euo pipefail + # The github-pages environment pre-created this exact deployment record + # (bound by the deploy job via ref + push-time window, not by bare + # newest-first lookup, because duplicate records per SHA exist). The + # independent-publication receipt gated off the real deploy, so mark + # that record `inactive` (from outside the environment job) so + # deployment history distinguishes "skipped, independent pipeline + # owns Pages" from a real deployment. + # auto_inactive=false: the default (true) would additionally mark + # every prior successful non-transient, non-production github-pages + # deployment inactive — including the live independent-publication + # deployment. Only this run's own skipped record may change. + gh api --method POST "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID/statuses" \ + -f state="inactive" \ + -F auto_inactive=false \ + -f description="Superseded: independent publication owns Pages; legacy release deployment skipped." \ + --jq '.state' > /dev/null + echo "::notice::Legacy release deployment skipped and recorded as inactive; independent publication owns Pages." + # Validate example files and documentation references # Blocking: failures should fail the workflow example-validation: + needs: visual-inputs + if: needs.visual-inputs.outputs.changed == 'true' runs-on: ubuntu-latest steps: @@ -380,6 +585,8 @@ jobs: # Check for broken links # Blocking: curated ignore list handles known flaky hosts linkcheck: + needs: visual-inputs + if: needs.visual-inputs.outputs.changed == 'true' runs-on: ubuntu-latest steps: @@ -411,6 +618,8 @@ jobs: # Spell checking # Non-blocking: may have false positives spellcheck: + needs: visual-inputs + if: needs.visual-inputs.outputs.changed == 'true' runs-on: ubuntu-latest continue-on-error: true @@ -422,30 +631,3 @@ jobs: with: ignore_words_file: .codespell-ignore.txt skip: "*.pyc,_build,*.json,*.lock,*.svg,*.min.js,*.min.css,_binaries,*.tpl,*.dst,*.tbl,_sources,benchmark_runs,*.dat,*.pdf,_project,_blog,htmlcov,.venv" - - # Docstring coverage check - # Blocking: enforce configured interrogate threshold - docstring-coverage: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install interrogate - - - name: Check docstring coverage - run: | - interrogate -c pyproject.toml --fail-under 90 benchbox/ - - - name: Generate docstring coverage badge - run: | - mkdir -p docs/_static - interrogate -c pyproject.toml --generate-badge docs/_static/ benchbox/ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3d7121f22b..94f77b309a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -42,16 +42,24 @@ jobs: - name: Validate test marker annotations run: make lint-markers - # The four steps below depend on _project/ dev tooling, which is curated + # The steps below depend on _project/ dev tooling, which is curated # out of release branches; hashFiles guards make them skip there. - name: Timing policy (wall-clock allowlist) if: hashFiles('_project/scripts/timing_policy_check.py') != '' run: uv run -- python _project/scripts/timing_policy_check.py --strict + - name: Fast lane ceiling + if: hashFiles('_project/scripts/fast_lane_ceiling_check.py') != '' + run: uv run -- python _project/scripts/fast_lane_ceiling_check.py --strict + - name: UAT spec LOC table drift check if: hashFiles('_project/scripts/uat_loc_table.py') != '' run: uv run --project _project/scripts --no-sync -- python _project/scripts/uat_loc_table.py --check + - name: Stale _project/ reference check + if: hashFiles('_project/scripts/check_project_references.py') != '' + run: uv run -- python _project/scripts/check_project_references.py + - name: Compatibility docs drift check run: make compat-docs-check diff --git a/.github/workflows/nightly-v2.yml b/.github/workflows/nightly-v2.yml new file mode 100644 index 0000000000..02f557d51d --- /dev/null +++ b/.github/workflows/nightly-v2.yml @@ -0,0 +1,956 @@ +name: Nightly T3 + +# Tier 3 nightly validation, split into independent domain jobs. Each domain +# has its own conclusion and artifacts, and the final `report` job keeps one +# GitHub issue per domain label (t3:docker, t3:perf, ...): it opens or updates +# the issue while the domain is red and closes it on the next green run. +# Domains, labels, and the owner response policy are in +# docs/operations/nightly-t3.md. +# +# Every domain job installs its own environment and reads only `contents`. +# Only `report` holds `issues: write`. +# +# Live cloud tests run only when the repository variable +# BENCHBOX_T3_CLOUD_ENABLED is exactly 'true' (default: unset, so skipped). + +env: + BENCHBOX_TEST_LOCK_WAIT_SECONDS: "0" + +on: + schedule: + # 07:30 UTC, off-peak and clear of the other scheduled workflows + # (docker 07:00, orphaned-commit 07:00, gap detector 07:47). + - cron: "30 7 * * *" + workflow_dispatch: + inputs: + run_drift: + description: "Run the t3:drift checks (scheduled runs run them on Mondays only)" + type: boolean + required: false + default: true + +concurrency: + group: nightly-t3-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # ---------------------------------------------------------------- t3:docker + # Ported from docker-integration.yml: compose-backed live suites. + docker: + name: "t3:docker | ${{ matrix.service }}" + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - service: postgres + compose: docker/postgresql/docker-compose.yml + tests: tests/integration/platforms/test_postgresql_live.py + marker: live_postgresql + - service: clickhouse + compose: docker/clickhouse/docker-compose.yml + tests: tests/integration/platforms/test_clickhouse_live.py + marker: live_clickhouse + - service: trino + compose: docker/trino/docker-compose.yml + tests: tests/integration/platforms/test_trino_live.py + marker: live_trino + env: + # Read by the ClickHouse compose file only; the other stacks ignore it. + CLICKHOUSE_MEMORY_LIMIT: 4g + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Bring up compose stack + run: docker compose -f "${{ matrix.compose }}" up -d --wait + + - name: Run live integration tests + run: | + set -o pipefail + uv run -- python -m pytest "${{ matrix.tests }}" \ + -m "${{ matrix.marker }}" --tb=short -v -p no:cacheprovider \ + | tee "docker-${{ matrix.service }}-output.txt" + if ! grep -Eq '[0-9]+ passed' "docker-${{ matrix.service }}-output.txt"; then + echo "::error::${{ matrix.service }} Docker integration produced no passing tests." + exit 1 + fi + + - name: Collect compose logs + if: failure() + run: docker compose -f "${{ matrix.compose }}" logs --no-color > "docker-${{ matrix.service }}-compose.log" || true + + - name: Tear down compose stack + if: always() + run: docker compose -f "${{ matrix.compose }}" down -v + + - name: Upload docker artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-docker-${{ matrix.service }} + path: | + docker-${{ matrix.service }}-output.txt + docker-${{ matrix.service }}-compose.log + if-no-files-found: ignore + retention-days: 14 + + # ----------------------------------------------------------------- t3:cloud + # Ported from nightly.yml's cloud-live-plan-capture. Credentialed, so it is + # opt-in: nothing runs until a maintainer sets BENCHBOX_T3_CLOUD_ENABLED=true. + # The selected classes run trivial SELECT 1 queries only; never widen them to + # whole live modules, which load data and break the billing guardrails. + cloud: + name: "t3:cloud | live plan capture" + if: ${{ vars.BENCHBOX_T3_CLOUD_ENABLED == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }} + SNOWFLAKE_USERNAME: ${{ secrets.SNOWFLAKE_USERNAME }} + SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }} + SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_WAREHOUSE }} + SNOWFLAKE_DATABASE: ${{ secrets.SNOWFLAKE_DATABASE }} + SNOWFLAKE_SCHEMA: ${{ secrets.SNOWFLAKE_SCHEMA }} + BIGQUERY_PROJECT: ${{ secrets.BIGQUERY_PROJECT }} + BIGQUERY_DATASET: ${{ secrets.BIGQUERY_DATASET }} + REDSHIFT_HOST: ${{ secrets.REDSHIFT_HOST }} + REDSHIFT_PORT: ${{ secrets.REDSHIFT_PORT || 5439 }} + REDSHIFT_USER: ${{ secrets.REDSHIFT_USER }} + REDSHIFT_PASSWORD: ${{ secrets.REDSHIFT_PASSWORD }} + REDSHIFT_DATABASE: ${{ secrets.REDSHIFT_DATABASE }} + MOTHERDUCK_TOKEN: ${{ secrets.MOTHERDUCK_TOKEN }} + MOTHERDUCK_DATABASE: ${{ secrets.MOTHERDUCK_DATABASE || 'benchbox_test' }} + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: | + uv sync --group dev --extra snowflake --extra bigquery --extra redshift --extra duckdb + + - name: Write GCP service account key to temp file + # BigQuery needs a file-based credential. The secret holds the raw JSON + # key (not base64). It is never echoed or uploaded; it lives only in a + # runner temp file with restrictive permissions for this run. + env: + GCP_SERVICE_ACCOUNT_KEY: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} + run: | + if [ -n "${GCP_SERVICE_ACCOUNT_KEY}" ]; then + key_file="${RUNNER_TEMP}/gcp-service-account-key.json" + printf '%s' "${GCP_SERVICE_ACCOUNT_KEY}" > "${key_file}" + chmod 600 "${key_file}" + echo "GOOGLE_APPLICATION_CREDENTIALS=${key_file}" >> "${GITHUB_ENV}" + else + echo "No GCP service account key configured; BigQuery tests will skip." + fi + + - name: Run Snowflake, BigQuery, Redshift, and MotherDuck plan-capture live tests + run: | + uv run -- python -m pytest \ + "tests/integration/platforms/test_snowflake_live.py::TestLiveSnowflakeQueryPlanCapture" \ + "tests/integration/platforms/test_bigquery_live.py::TestLiveBigQueryQueryPlanCapture" \ + "tests/integration/platforms/test_redshift_live.py::TestLiveRedshiftQueryPlanCapture" \ + "tests/integration/platforms/test_motherduck_live.py::TestLiveMotherDuckQueryPlanCapture" \ + -m "live_snowflake or live_bigquery or live_redshift or live_motherduck" --tb=short -v -p no:cacheprovider \ + --junitxml=junit-cloud.xml + + - name: Upload cloud artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-cloud + path: junit-cloud.xml + if-no-files-found: ignore + retention-days: 14 + + # ------------------------------------------------------------------ t3:perf + # Ported from perf-smoke.yml: DuckDB TPC-H SF=0.01 power phase against the + # committed baseline, failing on a regression above 10%. + perf: + name: "t3:perf | DuckDB TPC-H SF=0.01" + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Set isolated benchmark output + run: echo "BENCHBOX_OUTPUT_DIR=$RUNNER_TEMP/perf-smoke" >> "$GITHUB_ENV" + + - name: Run DuckDB TPC-H SF=0.01 power phase + run: | + uv run benchbox run \ + --platform duckdb \ + --benchmark tpch \ + --scale 0.01 \ + --phases power \ + --non-interactive + + - name: Locate current result file + id: current + run: | + results=() + while IFS= read -r result; do + results+=("$result") + done < <(find "$BENCHBOX_OUTPUT_DIR/results" -maxdepth 1 -name 'tpch_sf001_duckdb_sql_*.json' -print) + if [[ "${#results[@]}" != "1" ]]; then + echo "Expected one current result file, found ${#results[@]}." >&2 + exit 1 + fi + echo "path=${results[0]}" >> "$GITHUB_OUTPUT" + + - name: Compare against baseline (fail on >10% regression) + run: | + uv run benchbox compare \ + _project/baselines/perf_smoke_duckdb_tpch_001.json \ + "${{ steps.current.outputs.path }}" \ + --fail-on-regression 10% + + - name: Upload result artifact + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-perf-result + path: | + ${{ env.BENCHBOX_OUTPUT_DIR }}/results/*.json + _project/baselines/perf_smoke_duckdb_tpch_001.json + retention-days: 14 + + # ---------------------------------------------------------------- t3:matrix + # Wheel build and install smoke across the supported Python versions and + # operating systems. The install check is the same minimal-surface gate as + # nightly.yml's test-package job; the generator step runs one bundled TPC-H + # dbgen and one TPC-DS dsdgen from the installed wheel on each runner. + matrix: + name: "t3:matrix | ${{ matrix.os }} py${{ matrix.python-version }}" + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.11', '3.12', '3.13', '3.14'] + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Build package + run: uv build + + - name: Test package installation + shell: bash + run: | + set -euo pipefail + wheel_count=$(find dist -maxdepth 1 -name '*.whl' | wc -l | tr -d '[:space:]') + if [ "$wheel_count" != "1" ]; then + echo "::error::Expected exactly one wheel, found $wheel_count" + find dist -maxdepth 1 -type f -print + exit 1 + fi + wheel=$(find "$PWD/dist" -maxdepth 1 -name '*.whl' -print -quit) + tmpdir=$(mktemp -d) + cd "$tmpdir" + # Minimal-surface gate: a clean core install must import benchbox AND + # must not pull pandas (optional engine/DataFrame dep). + uv run --isolated --no-project --with "$wheel" -- python -c "import importlib.util; import benchbox; assert importlib.util.find_spec('pandas') is None, 'core install must not pull pandas'; print('Package installation successful (minimal import surface)')" + uv run --isolated --no-project --with "$wheel" -- benchbox --help + + - name: Run bundled dbgen and dsdgen from the installed wheel + shell: bash + run: | + set -euo pipefail + wheel=$(find "$PWD/dist" -maxdepth 1 -name '*.whl' -print -quit) + tmpdir=$(mktemp -d) + cd "$tmpdir" + uv run --isolated --no-project --with "$wheel" -- python - <<'PY' + import tempfile + from pathlib import Path + + from benchbox import TPCDS, TPCH + + with tempfile.TemporaryDirectory() as tmp: + for cls, table in ((TPCH, "nation.tbl"), (TPCDS, "call_center.dat")): + out = Path(tmp) / cls.__name__.lower() + cls(scale_factor=0.01, output_dir=out).generate_data() + produced = out / table + assert produced.is_file() and produced.stat().st_size > 0, f"{cls.__name__} produced no {table}" + print(f"{cls.__name__}: {table} {produced.stat().st_size} bytes") + PY + + - name: Install raw binary framing test dependencies + if: matrix.python-version == '3.12' + run: uv sync --group dev + + - name: Verify bundled dbgen binaries emit clean framing + if: matrix.python-version == '3.12' + run: uv run -- python -m pytest tests/unit/core/tpch/test_tpch_dbgen_framing_binaries.py -m slow -v + + - name: Upload build artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-matrix-dist-${{ matrix.os }}-py${{ matrix.python-version }} + path: dist/ + if-no-files-found: ignore + retention-days: 7 + + # --------------------------------------------------------------- t3:browser + # Ported from results-explorer-browser.yml: the Firefox and WebKit `@smoke` + # subsets through the shared Playwright entrypoints. + browser: + name: "t3:browser | ${{ matrix.browser }}" + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + browser: [firefox, webkit] + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install Python dependencies + run: uv sync --group dev + + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: results-explorer/package-lock.json + + - name: Install explorer dependencies + run: npm ci + working-directory: results-explorer + + - name: Cache Playwright browsers + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('results-explorer/package-lock.json') }} + + - name: Install Playwright browser + run: npx playwright install --with-deps ${{ matrix.browser }} + working-directory: results-explorer + + - name: Run browser smoke + # Shared deterministic entrypoint: regenerate fixtures, rebuild dist, + # then run the browser project scoped to the smoke subset by the + # Playwright config. + run: npm run test:e2e:${{ matrix.browser }} + working-directory: results-explorer + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-browser-${{ matrix.browser }} + path: | + results-explorer/playwright-report + results-explorer/test-results + if-no-files-found: ignore + retention-days: 7 + + # ------------------------------------------------------------- t3:extension + # Ported from extension-smoke.yml. Intentionally no extension cache: the + # point is to detect upstream community-extension rebuilds. + extension: + name: "t3:extension | duckdb-datasketches" + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install duckdb only + run: uv sync --no-dev --no-install-project + + - name: Run datasketches smoke + run: | + set -o pipefail + uv run -- python scripts/duckdb_datasketches_smoke.py 2>&1 | tee datasketches-smoke.txt + + - name: Upload extension artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-extension + path: datasketches-smoke.txt + if-no-files-found: ignore + retention-days: 14 + + # --------------------------------------------------------------- t3:install + # Ported from release-canary.yml's pypi-latest-installability job. + install: + name: "t3:install | pypi-latest installability" + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # Deliberately no checkout step: the point is to install the published + # PyPI artifact, not build or import against local source. + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install and import PyPI-latest benchbox (no local source in sys.path) + shell: bash + run: | + set -euo pipefail + # Run from a scratch directory with no benchbox/ source tree nearby. + # Running inside a checkout would import the local source instead of + # the installed wheel and pass even when the PyPI artifact is broken. + # See docs/operations/release-guide.md "Recovering from a broken PyPI + # release". + workdir=$(mktemp -d) + cd "$workdir" + uv run --isolated --no-project --with benchbox -- python -c "import importlib.util; import benchbox; assert importlib.util.find_spec('pandas') is None, 'core install must not pull pandas'; print(f'PyPI-latest BenchBox {benchbox.__version__} installed successfully (minimal import surface)')" | tee "$GITHUB_WORKSPACE/pypi-latest-install.txt" + + - name: Upload install artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-install + path: pypi-latest-install.txt + if-no-files-found: ignore + retention-days: 14 + + # ----------------------------------------------------------------- t3:drift + # Scheduled drift canaries, ported from corpus-drift-check.yml, + # pricing-data-drift-check.yml, submission-validator-drift-check.yml, and + # cross-surface-baseline-autodetect.yml. They are report-only: nothing is + # pushed, and no PR is opened. Scheduled runs execute them on Mondays (UTC); + # a manual dispatch runs them unless `run_drift` is unchecked. + drift-gate: + name: "t3:drift | gate" + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.select.outputs.run }} + steps: + - name: Decide whether to run drift checks + id: select + env: + EVENT_NAME: ${{ github.event_name }} + RUN_DRIFT: ${{ inputs.run_drift }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = workflow_dispatch ]; then + run="$RUN_DRIFT" + elif [ "$(date -u +%u)" = "1" ]; then + run=true + else + run=false + fi + echo "run=$run" >> "$GITHUB_OUTPUT" + echo "Drift checks selected: $run" + + drift: + name: "t3:drift | corpus, pricing, validator, baseline" + needs: [drift-gate] + if: ${{ needs.drift-gate.outputs.run == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + SUMMARY_PATH: t3-drift/cross-surface-baseline-summary.json + BASELINE_PATH: benchbox/core/equivalence/cross_surface_baseline.yaml + steps: + - name: Check out develop + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: develop + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Fetch published-results + run: git fetch --no-tags --depth=1 origin published-results:refs/remotes/origin/published-results + + # Every check below runs even when an earlier one failed, so one run + # reports all drift; the job still fails if any check failed. + - name: Corpus drift (develop vs published-results, directional) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + PUB=origin/published-results + DEV=HEAD + + # Fail clearly rather than silently passing an empty comparison. + if ! git cat-file -e "${PUB}:results-data" 2>/dev/null; then + echo "::error::published-results has no results-data/ tree" + exit 2 + fi + + # Compare the shared corpus content, not the derived inventory. Two-tree + # compare (no dots): published-results is an orphan branch with no + # merge-base with develop. + PATHSPECS=( + 'results-data/bundles' + 'results-data/CORPUS_NOTES.md' + 'results-data/SEED_CORPUS_SPEC.md' + 'results-data/README.md' + 'results-data/validate_corpus.py' + ) + + # A = published, B = develop. Diff filter is relative to A to B. + # A: develop only, so develop is ahead. + # D: published only, so a wipe-based mirror would delete public content. + # M: content differs, so develop is ahead with updates. + DEVELOP_AHEAD=$(git diff --name-only --diff-filter=A "${PUB}" "${DEV}" -- "${PATHSPECS[@]}" || true) + PUBLISHED_ONLY=$(git diff --name-only --diff-filter=D "${PUB}" "${DEV}" -- "${PATHSPECS[@]}" || true) + CONTENT_CHANGED=$(git diff --name-only --diff-filter=M "${PUB}" "${DEV}" -- "${PATHSPECS[@]}" || true) + STALE_PATHS=$(printf '%s\n%s\n' "${CONTENT_CHANGED}" "${DEVELOP_AHEAD}" | sed '/^$/d' || true) + + if [[ -n "${PUBLISHED_ONLY}" ]]; then + PO_COUNT=$(printf '%s\n' "${PUBLISHED_ONLY}" | sed '/^$/d' | wc -l | tr -d ' ') + echo "::notice::published-results has ${PO_COUNT} path(s) not on develop (accepted archive submissions; not drift)." + printf '%s\n' "${PUBLISHED_ONLY}" | head -40 + fi + + if [[ -z "${STALE_PATHS}" ]]; then + echo "No develop-ahead corpus drift." + exit 0 + fi + + COUNT=$(printf '%s\n' "${STALE_PATHS}" | wc -l | tr -d ' ') + echo "::error::published-results corpus is ${COUNT} path(s) behind develop (develop-ahead or content-changed)." + printf '%s\n' "${STALE_PATHS}" | head -40 + echo "Mirror develop-ahead paths with the non-destructive union overlay:" + echo " gh workflow run sync-results-data-to-published.yml --ref develop" + echo "Never use a wipe-based full mirror: published-only community submissions would be deleted." + exit 1 + + - name: Corpus privacy scan (published-results tip) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + PUB=origin/published-results + if ! git cat-file -e "${PUB}:results-data/bundles" 2>/dev/null; then + echo "::error::published-results has no results-data/bundles tree" + exit 2 + fi + + uv run -- python - <<'PY' + from __future__ import annotations + + import json + import subprocess + import sys + + from benchbox.core.results.anonymization import find_public_path_leaks + + pub = "origin/published-results" + listed = subprocess.check_output( + ["git", "ls-tree", "-r", "--name-only", pub, "results-data/"], + text=True, + ) + json_paths = [p for p in listed.splitlines() if p.endswith(".json")] + if not json_paths: + print("::error::published-results results-data/ has no JSON files to scan", file=sys.stderr) + sys.exit(2) + + offenders: list[str] = [] + unreadable: list[str] = [] + for path in json_paths: + try: + raw = subprocess.check_output(["git", "show", f"{pub}:{path}"]) + payload = json.loads(raw) + except (subprocess.CalledProcessError, UnicodeDecodeError, json.JSONDecodeError) as exc: + unreadable.append(f"{path}: {type(exc).__name__}") + continue + leaks = find_public_path_leaks(payload) + if leaks: + offenders.append(f"{path}: {', '.join(sorted(set(leaks))[:5])}") + + if unreadable: + print(f"::error::{len(unreadable)} published corpus file(s) could not be parsed (failing closed):", file=sys.stderr) + print("\n".join(unreadable[:20]), file=sys.stderr) + sys.exit(1) + if offenders: + print(f"::error::{len(offenders)} published corpus file(s) expose private paths:", file=sys.stderr) + print("\n".join(offenders[:20]), file=sys.stderr) + sys.exit(1) + print(f"Privacy scan clean: {len(json_paths)} published JSON surface(s), 0 path leaks.") + PY + + - name: Submission validator drift (develop vs published-results) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + PUB=origin/published-results + if ! git cat-file -e "${PUB}:.github/workflows/validate-submission.yml" 2>/dev/null; then + echo "::error::published-results has no .github/workflows/validate-submission.yml" + exit 2 + fi + git show "${PUB}:.github/workflows/validate-submission.yml" > "${RUNNER_TEMP}/published-validate-submission.yml" + uv run -- python scripts/check_submission_validator_sync.py \ + --develop .github/workflows/validate-submission.yml \ + --published "${RUNNER_TEMP}/published-validate-submission.yml" + + - name: Cross-surface baseline drift (resolved entries still listed) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + mkdir -p "$(dirname "${SUMMARY_PATH}")" + # Same detector and writer as the weekly prune workflow, but report + # only: the writer edits the checkout, and any resulting diff means + # the baseline lists divergences that no longer reproduce. + uv run -- python _project/scripts/cross_surface_baseline_autodetect.py \ + --json-out "${SUMMARY_PATH}" + if ! git diff --exit-code -- "${BASELINE_PATH}"; then + echo "::error::Resolved cross-surface baseline entries are still listed in ${BASELINE_PATH}. Run 'make cross-surface-update-baseline BENCHMARK=' and commit the pruned baseline." + exit 1 + fi + + - name: Restore checkout after baseline check + if: ${{ !cancelled() }} + run: git checkout -- "${BASELINE_PATH}" + + - name: Pricing data drift (re-pull vendor APIs) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + uv run -- python scripts/generate_pricing_data.py --refresh + if git diff --exit-code -- benchbox/core/cost/pricing_data.yaml \ + benchbox/core/cost/pricing_vendor_evidence.yaml; then + echo "Vendor pricing matches the recorded evidence." + else + echo "::error::Upstream vendor prices moved. Review the diff above, then run 'make pricing-data' locally after confirming the new list prices and commit the refreshed evidence and tables together." + exit 1 + fi + + - name: Upload drift artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-drift + path: ${{ env.SUMMARY_PATH }} + if-no-files-found: ignore + retention-days: 30 + + # -------------------------------------------------------------- t3:quarantine + # Runs tests carrying the `quarantine` marker, which keeps them out of the + # per-PR tiers while still tracking them. Until the marker is registered in + # pytest.ini the job passes with a notice instead of failing on an unknown + # marker. + # TODO: drop the registration guard once the quarantine marker is registered + # on develop and every quarantined test carries owner, expiry, and issue + # metadata. + quarantine: + name: "t3:quarantine | quarantined tests" + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + BENCHBOX_TEST_TIER: t3 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Run quarantined tests + run: | + set -uo pipefail + mkdir -p t3-quarantine + uv run -- python -m pytest --markers > t3-quarantine/markers.txt + if ! grep -q '^@pytest.mark.quarantine' t3-quarantine/markers.txt; then + echo "::notice::The quarantine marker is not registered yet; nothing to run." + exit 0 + fi + rc=0 + uv run -- python -m pytest tests -m quarantine -n 0 --tb=short --timeout=1200 \ + --junitxml=t3-quarantine/junit-quarantine.xml || rc=$? + # Exit code 5 means no quarantined tests were collected, which is the healthy state. + if [ "$rc" = "5" ]; then + echo "No quarantined tests." + exit 0 + fi + exit "$rc" + + - name: Upload quarantine artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-quarantine + path: t3-quarantine/ + if-no-files-found: ignore + retention-days: 14 + + linkcheck: + name: "t3:linkcheck | external documentation links" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Check documentation links + run: uv run -- sphinx-build -b linkcheck docs docs/_build/linkcheck + + - name: Upload linkcheck report + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-linkcheck + path: docs/_build/linkcheck/ + if-no-files-found: ignore + retention-days: 14 + + # ---------------------------------------------------------- t3:durations + # Measures per-test durations for the fast and slow non-credentialed tiers. + # Uploads the pytest --durations output and JUnit reports as artifacts. When + # the duration tooling is present, it also regenerates the duration file + # (into the artifact directory, never into the checkout) so a maintainer can + # commit it. + durations-refresh: + name: "t3:durations | duration refresh" + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + BENCHBOX_TEST_TIER: t3 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Measure fast tier durations + run: | + set -uo pipefail + mkdir -p t3-durations + statuses=(0 0) + uv run -- python -m pytest tests -m "fast and not (slow or stress or resource_heavy or live_integration)" \ + --tb=short --timeout=120 --durations=0 --junitxml=t3-durations/junit-fast.xml \ + 2>&1 | tee t3-durations/durations-fast.txt || statuses=("${PIPESTATUS[@]}") + if [ "${statuses[1]}" != "0" ]; then exit "${statuses[1]}"; fi + rc=${statuses[0]} + # Keep both samples and their artifacts before reporting failed tests. + printf '%s\n' "$rc" > t3-durations/pytest-fast.exit + if [ "$rc" -gt 1 ]; then exit "$rc"; fi + + - name: Measure slow tier durations + run: | + set -uo pipefail + statuses=(0 0) + uv run -- python -m pytest tests -m "slow and not (stress or resource_heavy or live_integration)" \ + -n 0 --tb=short --timeout=1200 --durations=0 --junitxml=t3-durations/junit-slow.xml \ + 2>&1 | tee t3-durations/durations-slow.txt || statuses=("${PIPESTATUS[@]}") + if [ "${statuses[1]}" != "0" ]; then exit "${statuses[1]}"; fi + rc=${statuses[0]} + printf '%s\n' "$rc" > t3-durations/pytest-slow.exit + if [ "$rc" -gt 1 ]; then exit "$rc"; fi + + - name: Regenerate duration file + run: | + set -euo pipefail + if [ ! -f _project/scripts/update_test_durations.py ]; then + echo "::notice::No duration tooling in this checkout; publishing raw --durations output only." + exit 0 + fi + uv run -- python _project/scripts/update_test_durations.py \ + --input t3-durations/junit-fast.xml \ + --input t3-durations/junit-slow.xml \ + --output t3-durations/test_durations.json + + - name: Upload duration artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t3-durations + path: t3-durations/ + if-no-files-found: ignore + retention-days: 30 + + - name: Require successful sampled tests + if: ${{ always() }} + run: | + set -euo pipefail + failed=0 + for tier in fast slow; do + verdict="t3-durations/pytest-${tier}.exit" + if [ ! -f "$verdict" ] || [ "$(cat "$verdict")" != "0" ]; then + echo "::error::${tier} duration sample failed or has no successful pytest verdict." + failed=1 + fi + done + exit "$failed" + + # ------------------------------------------------------------------ report + # One issue per red domain label. Opens the issue when a domain fails, + # comments on it while the domain stays red, and closes it when the domain + # passes. Skipped domains (cloud disabled, drift off-day) change nothing. + report: + name: Report T3 results + needs: + - docker + - cloud + - perf + - matrix + - browser + - extension + - install + - drift + - quarantine + - linkcheck + - durations-refresh + if: ${{ always() && !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + env: + GH_TOKEN: ${{ github.token }} + NEEDS_JSON: ${{ toJSON(needs) }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + DOC_URL: ${{ github.server_url }}/${{ github.repository }}/blob/develop/docs/operations/nightly-t3.md + steps: + - name: Open, update, or close one issue per domain + shell: bash + run: | + set -euo pipefail + for job in docker cloud perf matrix browser extension install drift quarantine linkcheck durations-refresh; do + case "$job" in + durations-refresh) domain=durations ;; + *) domain="$job" ;; + esac + label="t3:${domain}" + result="$(printf '%s' "$NEEDS_JSON" | jq -r --arg j "$job" '.[$j].result')" + echo "${label}: ${result}" + + existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --label "$label" \ + --limit 1 --json number --jq '.[0].number // empty')" + + case "$result" in + failure) + comment="Nightly T3 run ${GITHUB_RUN_ID} failed for \`${label}\` on $(date -u +%F): ${RUN_URL}" + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$GITHUB_REPOSITORY" --body "$comment" + echo "Updated issue #${existing}" + else + gh label create "$label" --repo "$GITHUB_REPOSITORY" \ + --description "Nightly T3 ${domain} domain is red" --color B60205 >/dev/null 2>&1 || true + body="The nightly T3 \`${domain}\` domain failed. + + - Run: ${RUN_URL} + - Commit: ${GITHUB_SHA} + - Artifacts: attached to the run (names start with \`t3-${domain}\` where the domain uploads any) + + The owner fixes or reverts within one working day. See ${DOC_URL} for the response policy. This issue stays open while the domain is red and closes when a nightly run passes." + gh issue create --repo "$GITHUB_REPOSITORY" --label "$label" \ + --title "Nightly T3 ${domain} failing" --body "$body" + fi + ;; + success) + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$GITHUB_REPOSITORY" \ + --body "Nightly T3 run ${GITHUB_RUN_ID} passed for \`${label}\`: ${RUN_URL}" + gh issue close "$existing" --repo "$GITHUB_REPOSITORY" --reason completed + echo "Closed issue #${existing}" + fi + ;; + *) + echo "No issue change for ${label} (${result})." + ;; + esac + done diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 08d16609c5..d4e73a6e28 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,5 +1,9 @@ name: Nightly Validation +env: + BENCHBOX_TEST_LOCK_WAIT_SECONDS: "0" + BENCHBOX_TEST_TIER: t1 + on: workflow_dispatch: schedule: @@ -14,7 +18,11 @@ permissions: jobs: test: + name: >- + test (${{ matrix.os }}, ${{ matrix.python-version }})${{ + matrix.os == 'windows-latest' && ' (quarantine #2568)' || '' }} runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.os == 'windows-latest' }} strategy: fail-fast: false matrix: @@ -61,7 +69,15 @@ jobs: - name: Run fast tests run: | # -p pytest_cov re-enables coverage plugin (disabled by default in pytest.ini) - uv run -- python -m pytest tests -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=120 -p pytest_cov --cov=benchbox --cov-report=xml --cov-report=term-missing --cov-fail-under=70 + uv run -- python -m pytest tests -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=120 -p pytest_cov --cov=benchbox --cov-report=xml --cov-report=term-missing --cov-fail-under=70 --junitxml=junit-fast.xml + + - name: Upload fast test duration report + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: fast-junit-report + path: junit-fast.xml + if-no-files-found: ignore - name: Upload coverage to Codecov if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' @@ -73,9 +89,13 @@ jobs: slow-tier-observation: name: Slow tier observation + needs: test + if: always() runs-on: ubuntu-latest timeout-minutes: 90 continue-on-error: true + env: + BENCHBOX_TEST_TIER: t3 steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -96,7 +116,34 @@ jobs: - name: Observe non-credentialed slow tests run: | - uv run -- python -m pytest tests -m "slow and not (stress or resource_heavy or live_integration)" -n 0 --tb=short --timeout=1200 --durations=20 + uv run -- python -m pytest tests -m "slow and not (stress or resource_heavy or live_integration)" -n 0 --tb=short --timeout=1200 --durations=20 --junitxml=junit-t3-slow.xml + + - name: Download fast test duration report + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: fast-junit-report + path: duration-inputs + continue-on-error: true + + - name: Refresh test duration artifact + if: success() + run: | + test -f duration-inputs/junit-fast.xml + test -f junit-t3-slow.xml + uv run -- python _project/scripts/update_test_durations.py \ + --input duration-inputs/junit-fast.xml \ + --input junit-t3-slow.xml + + - name: Upload duration report and artifact + if: always() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: t3-test-durations + path: | + junit-t3-slow.xml + duration-inputs/junit-fast.xml + tests/fixtures/test_durations.json + if-no-files-found: ignore duckdb-preview: name: DuckDB 2.0 preview compatibility @@ -308,7 +355,13 @@ jobs: retention-days: 14 integration-smoke: + name: >- + integration-smoke (${{ matrix.os }}, ${{ matrix.python-version }})${{ + matrix.os == 'windows-latest' && ' (quarantine #2569)' || '' }} runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.os == 'windows-latest' }} + env: + BENCHBOX_TEST_TIER: t2 strategy: fail-fast: false matrix: @@ -340,6 +393,8 @@ jobs: integration-table-formats: runs-on: ubuntu-latest timeout-minutes: 20 + env: + BENCHBOX_TEST_TIER: t2 steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -363,6 +418,8 @@ jobs: postgres-integration: runs-on: ubuntu-latest timeout-minutes: 15 + env: + BENCHBOX_TEST_TIER: t3 services: postgres: image: postgres:17 @@ -433,7 +490,7 @@ jobs: # compute on Redshift and MotherDuck), so this job stays inside free-tier / # trial budgets. Unlike the postgres-integration gate above, an all-skipped # run stays green: until the cloud secrets below are configured, there are - # no credentials and every test skips. Never move this to pr.yml: + # no credentials and every test skips. Never move this to ci.yml: # PR-triggered cloud queries from arbitrary branches incur uncontrolled # billing. # Expected secret names match the env vars the conftest fixtures read: @@ -445,6 +502,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 env: + BENCHBOX_TEST_TIER: t3 SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }} SNOWFLAKE_USERNAME: ${{ secrets.SNOWFLAKE_USERNAME }} SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }} @@ -514,6 +572,8 @@ jobs: integration: runs-on: ubuntu-latest timeout-minutes: 20 + env: + BENCHBOX_TEST_TIER: t2 steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -642,7 +702,42 @@ jobs: run: npm test -- --reporter=verbose working-directory: results-explorer + website-audit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + + - name: Detect website + id: website + shell: bash + run: | + if [ -f website/package.json ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "website is absent; skipping website audit." + fi + + - name: Set up Node.js + if: steps.website.outputs.present == 'true' + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 + with: + node-version: '22' + + - name: Install website dependencies + if: steps.website.outputs.present == 'true' + run: npm ci + working-directory: website + + - name: Audit website dependencies + if: steps.website.outputs.present == 'true' + run: npm run audit:high + working-directory: website + scheduled-workflow-liveness: + name: "scheduled-workflow-liveness (quarantine #2570)" # Guard for the release-canary-scheduled-activation TODO's failure class: # a workflow that declares `on.schedule` but is not on the DEFAULT branch # never registers and never runs — nothing red appears anywhere (this is @@ -657,6 +752,7 @@ jobs: # a workflow stayed dark until a release carried it there; that gap is gone.) runs-on: ubuntu-latest timeout-minutes: 10 + continue-on-error: true permissions: actions: read contents: read @@ -670,7 +766,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Bare python3 is intentional: stdlib-only, no uv sync needed for a - # pure API check (same precedent as pr.yml's path classifier). The + # pure API check (same precedent as ci.yml's path classifier). The # schedule detection regex is scoped to the `on:` block to stay # stdlib-only without a YAML parser. run: | @@ -877,7 +973,10 @@ jobs: # cell managing its own compose stack # (see tests/uat/configs/uat-throughput-postgresql-nightly.yaml). runs-on: ubuntu-latest - timeout-minutes: 60 + # Cell budgets: DuckDB 900s + Postgres 2400s = 55 min worst case, plus + # ~25 min headroom for checkout, deps, integration tests, Docker + # startup/cleanup, and both assert steps. + timeout-minutes: 80 env: # Throughput@Size floor activation: repository configuration variables # flow into the DuckDB assert step below. Unset vars evaluate to empty, @@ -929,15 +1028,14 @@ jobs: run: | make uat-sweep CONFIG=tests/uat/configs/uat-throughput-duckdb-nightly.yaml - - name: Run Postgres throughput UAT cell (TPC-H SF1, run-official --streams 3) + - name: "Run Postgres throughput UAT cell (TPC-H SF1, run-official --streams 3) (quarantine #2571)" # w4: the same stream-count contract on a client/server engine. Unlike # DuckDB's SHARED_CURSOR fast path, PostgreSQL opts into # INDEPENDENT_CONNECTION (a fresh connection per stream), so this cell # proves N concurrent independent sessions end to end via the # production CLI. The cell manages its own compose stack # (cleanup.docker_manage_platforms); no service container is needed. - # Gated on the sweep's own exit code like the DuckDB cell above: no - # `continue-on-error` here either. + continue-on-error: true run: | make uat-sweep CONFIG=tests/uat/configs/uat-throughput-postgresql-nightly.yaml @@ -963,13 +1061,6 @@ jobs: validate_stream_count, validate_stream_success, ) - - # Narrow window: the timestamp captured immediately before the sweep - # step above, not a full-hour lookback -- see that step's comment. - # The capture step writes UTC (date -u); strptime yields a naive - # datetime whose .timestamp() would otherwise be read as LOCAL, so - # anchor it to UTC explicitly -- correct on any runner TZ, not just - # ubuntu-latest's UTC. started_after = datetime.datetime.strptime( os.environ['THROUGHPUT_UAT_STARTED_AT'], '%Y-%m-%dT%H:%M:%S' ).replace(tzinfo=timezone.utc) @@ -995,20 +1086,11 @@ jobs: print(f'::error::{reason}') sys.exit(1) print(f'OK: stream count and per-stream success both verified ({result_path})') - # Throughput@Size floor (relative model -- see - # docs/operations/uat-framework.md 'Throughput performance floor'). - # Additive to the stream-count hard gate above: a floor failure - # never masks or replaces a stream-count failure. With no baseline - # configured this only reports the observed value for future - # baseline accumulation and never fails: a threshold set before a - # stable baseline exists manufactures false failures. tpc_metrics = (result_json.get('summary') or {}).get('tpc_metrics') or {} observed = tpc_metrics.get('throughput_at_size') print(f'::notice::Throughput@Size observed: {observed!r} (duckdb/tpch-sf1)') import math floor_median = os.environ.get('THROUGHPUT_FLOOR_MEDIAN') - # `or` (not a get-default): a defined-but-empty variable must fall - # back too, otherwise float('') fails the gate on valid input. floor_drop = os.environ.get('THROUGHPUT_FLOOR_MAX_DROP_FRACTION') or '0.2' if floor_median: try: @@ -1029,11 +1111,12 @@ jobs: print('::notice::No THROUGHPUT_FLOOR_MEDIAN configured; floor check in observe-only mode') " - - name: Assert the Postgres cell stream count and per-stream success + - name: "Assert the Postgres cell stream count and per-stream success (quarantine #2571)" # w4 defense-in-depth, mirroring the DuckDB assert above: runs under # `if: !cancelled()` so a failed sweep still gates instead of skipping. # Globs the postgresql result (not duckdb) within the same run window. if: ${{ !cancelled() }} + continue-on-error: true run: | uv run -- python -c " import datetime, os, sys @@ -1117,11 +1200,9 @@ jobs: pull-requests: read issues: write # green_unmerged_sweep.py lists check runs (commits/{sha}/check-runs) for - # every candidate PR, and with --check-post-merge also lists workflow runs - # (actions/workflows/develop-post-merge.yml/runs). A job-level permissions - # map sets every unlisted scope to "no access", so without these two the - # scheduled sweep 403s before it can classify PRs or update its tracking - # issue. + # every candidate PR. A job-level permissions map sets every unlisted + # scope to "no access", so without these two the scheduled sweep 403s + # before it can classify PRs or update its tracking issue. checks: read actions: read steps: @@ -1139,41 +1220,40 @@ jobs: - name: Run green-unmerged sweep env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: uv run -- python _project/scripts/green_unmerged_sweep.py --apply --check-post-merge - - fast-lane-ratchet-check: - # Auto-ratchet signal for the fast-lane test-count ceiling (see - # fast-lane-decouple-ceiling-contention-2 and - # docs/operations/fast-lane-budget.md): runs the same fast-lane collect - # pr.yml's guard-timing-policy step uses and, when headroom drops below - # the warning threshold, files/updates ONE marker-tagged tracking issue - # with the current numbers and the exact edit to make -- it does NOT - # push a branch or open a PR itself (GITHUB_TOKEN-authored PRs get no - # required checks; same known constraint green_unmerged_sweep.py's own - # docstring notes). A ceiling bump stays a deliberate human/agent PR; - # this job only surfaces the need before the next test-adding PR - # collides with the cap. + run: uv run -- python _project/scripts/green_unmerged_sweep.py --apply + + ruleset-drift: + name: ruleset-drift runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - issues: write + timeout-minutes: 5 + continue-on-error: true steps: - name: Checkout code uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: develop + persist-credentials: false - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Install dependencies - run: uv sync --group dev - - - name: Run fast-lane ceiling ratchet check + - name: Compare live governance with the runbook env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: uv run -- python _project/scripts/fast_lane_ratchet_check.py --apply + RULESET_DRIFT_TOKEN: ${{ secrets.RULESET_DRIFT_TOKEN }} + run: | + set -euo pipefail + python scripts/ruleset_drift_check.py \ + --token "${RULESET_DRIFT_TOKEN}" \ + --require-bypass-actor-visibility \ + --output ruleset-drift.json + + - name: Upload drift evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: nightly-ruleset-drift-${{ github.run_id }}-${{ github.run_attempt }} + path: ruleset-drift.json + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/oracle-review.yml b/.github/workflows/oracle-review.yml new file mode 100644 index 0000000000..85de4c5938 --- /dev/null +++ b/.github/workflows/oracle-review.yml @@ -0,0 +1,103 @@ +name: oracle-review + +run-name: oracle-review (${{ github.event.action || github.event_name }}) + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + pull_request_review: + types: [submitted, dismissed] + merge_group: + types: [checks_requested] + workflow_dispatch: + inputs: + pr: + description: Pull request number on the selected feature branch + required: true + type: string + +permissions: + actions: read + contents: read + issues: read + pull-requests: read + +concurrency: + group: oracle-review-${{ github.event.pull_request.number || inputs.pr || github.event.merge_group.head_sha }} + cancel-in-progress: true + +jobs: + oracle-review: + name: oracle-review + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Resolve the dispatched pull request + if: github.event_name == 'workflow_dispatch' + id: dispatch-pr + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr }} + REPO: ${{ github.repository }} + EXPECTED_HEAD: ${{ github.sha }} + CURRENT_REF: ${{ github.ref }} + shell: bash + run: | + set -euo pipefail + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] && [[ "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]] && [ "$CURRENT_REF" != refs/heads/develop ] || { + echo "oracle-review: error: dispatch requires a PR number and its current feature-branch commit" >&2 + exit 2 + } + PULL=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json number,state,baseRefName,baseRefOid,headRefName,headRefOid,isCrossRepository) + printf '%s\n' "$PULL" | jq -e --arg pr "$PR_NUMBER" --arg head "$EXPECTED_HEAD" --arg ref "$CURRENT_REF" ' + .number == ($pr | tonumber) and .state == "OPEN" and .baseRefName == "develop" and + .isCrossRepository == false and ("refs/heads/" + .headRefName) == $ref and .headRefOid == $head and + (.baseRefOid | type == "string" and test("^[0-9a-f]{40}$")) + ' > /dev/null + BASE_SHA=$(printf '%s\n' "$PULL" | jq -er '.baseRefOid') + SNAPSHOT=$(printf '%s\n' "$PULL" | jq -ceS 'del(.baseRefOid)') + printf 'base_sha=%s\nsnapshot=%s\n' "$BASE_SHA" "$SNAPSHOT" >> "$GITHUB_OUTPUT" + + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + ref: ${{ steps.dispatch-pr.outputs.base_sha || github.event.pull_request.base.sha || github.event.merge_group.base_sha || 'develop' }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + + - name: Require the Codex connector's review + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }} + PR_SNAPSHOT: ${{ steps.dispatch-pr.outputs.snapshot }} + MERGE_GROUP_REF: ${{ github.event.merge_group.head_ref }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + check_dispatch_head() { + CURRENT_PULL=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json number,state,baseRefName,headRefName,headRefOid,isCrossRepository | jq -ceS .) + [ "$CURRENT_PULL" = "$PR_SNAPSHOT" ] || { + echo "oracle-review: error: dispatched PR identity or head changed" >&2 + exit 2 + } + } + if [ -z "$PR_NUMBER" ]; then + [[ "$MERGE_GROUP_REF" =~ /pr-([0-9]+)- ]] || { echo "oracle-review: error: no pull request number in '$MERGE_GROUP_REF'" >&2; exit 2; } + PR_NUMBER="${BASH_REMATCH[1]}" + fi + if [ "$EVENT_NAME" = workflow_dispatch ]; then + check_dispatch_head + fi + python _project/scripts/oracle_review_check.py --repo "$REPO" --pr "$PR_NUMBER" + if [ "$EVENT_NAME" = workflow_dispatch ]; then + check_dispatch_head + fi diff --git a/.github/workflows/orphaned-commit-detector.yml b/.github/workflows/orphaned-commit-detector.yml deleted file mode 100644 index 956aea0730..0000000000 --- a/.github/workflows/orphaned-commit-detector.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Orphaned Commit Detector - -# Scheduled backstop for the auto-merge-on-open stranding class of bug: a commit -# pushed to a feature branch AFTER its only PR already squash-merged and closed -# is never covered by any pull_request event, so its content silently never -# reaches develop (see _project/analysis/auto-merge-stranding-forensic.md). This -# happened three times in the results-explorer/publication remediation and was -# only caught by a manual adversarial re-review. This job catches the next one -# automatically. -# -# It compares, for every branch whose PR merged, the branch tip against the PR's -# pre-squash head reported by the GitHub API — squash-proof, no patch-id guess. -# Historical/resolved cases are allowlisted in -# _project/analysis/known-stranded-commits.txt; only a NEW orphan fails the run. - -on: - schedule: - # Daily, 07:00 UTC. Deliberately not weekly. - # - # The push trigger below cannot cover every case: for a `push` event GitHub - # runs the workflow file *from the pushed commit*, so an orphaning push to a - # branch created before that trigger landed - and never rebased - runs the - # branch's own older workflow and detects nothing. Those orphans are only - # ever caught here. - # - # This run is the one that reads develop's workflow and allowlist and scans - # every branch, so it is the true backstop. Weekly left a stale-branch - # orphan invisible for up to seven days; daily bounds it to one. - - cron: "0 7 * * *" - workflow_dispatch: {} - # Run on EVERY develop push, not just changes to the detector itself. - # - # The weekly schedule above is a backstop, not a guard: it leaves up to seven - # days in which develop is silently missing a change that everyone believes - # landed. That is not hypothetical - PR #1503 auto-merged carrying only its - # first commit, and the orphan (b5c56ea9) was found by hand days before the - # schedule would have fired. A develop push is exactly the moment a squash - # merge can strand a commit, so check then. - # - # The job is read-only and finishes in ~1 minute; running it per merge is the - # difference between catching a stranded commit in one build and in one week. - # Every branch, not just develop. - # - # A develop-only trigger cannot catch this race, and that is worth spelling - # out because it looks like it should. The stranding sequence is: PR merges - # (develop push fires here) -> THEN the later commit is pushed to the feature - # branch. At the moment the develop push runs, the orphan does not exist yet, - # so the run that looks best-placed to catch it is structurally too early. - # The push that creates the orphan is the push to the feature branch, so that - # is the event to watch. - # - # The develop push is still useful - it catches orphans stranded by an - # earlier merge - so both are covered by simply not filtering branches. - # - # Cost: a read-only ~1 minute job per push. That is the price of catching a - # stranded commit at the moment it is created rather than up to a week later. - push: - -permissions: - contents: read - pull-requests: read - -jobs: - detect: - name: Detect orphaned commits on dead branches - runs-on: ubuntu-latest - steps: - # Check out the pushed commit, NOT a hardcoded develop. - # - # The detector reads its allowlist and its own source from the checkout, - # but compares branches against the remote, so the ref only decides - # *which version of the allowlist* is in force. Pinning develop meant a - # push that adds an allowlist entry was still judged by develop's older - # allowlist - so a branch acknowledging an orphan could never make its own - # run pass, and the check stayed red until after it merged. On the - # schedule trigger this resolves to develop's head anyway. - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - fetch-depth: 0 - - - name: Fetch all remote branches - run: git fetch --no-tags origin "+refs/heads/*:refs/remotes/origin/*" - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.11" - - - name: Run detector - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: uv run -- python _project/scripts/detect_orphaned_commits.py diff --git a/.github/workflows/phase3-promotion-review.yml b/.github/workflows/phase3-promotion-review.yml deleted file mode 100644 index c9eb930c75..0000000000 --- a/.github/workflows/phase3-promotion-review.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Phase 3 Promotion Review - -# Generate the Phase 2 -> Phase 3 promotion-metrics handoff on a calendar, -# not when a human remembers. Runs quarterly on the FIRST MONDAY of each -# quarter month and opens a PR with the report for the maintainer to -# interpret. The PR never auto-merges -- the one-paragraph-per-metric human -# judgement is the point (see -# `_project/analysis/phase-3-promotion-metrics.md`). -# -# Cadence is machine-checkable: the cron fires at 09:00 UTC on days 1-7 of -# Jan/Apr/Jul/Oct (every such day), and the `guard` job proceeds only when -# that day is a Monday. Days 1-7 always contain exactly one Monday, so this -# fires once per quarter without relying on day-of-month / day-of-week OR -# semantics. `workflow_dispatch` is available for manual recovery if the cron -# is missed. - -on: - schedule: - - cron: '0 9 1-7 1,4,7,10 *' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: phase3-promotion-review - cancel-in-progress: false - -env: - # If this workflow file ever lands on `main` (scheduled workflows only run - # from the default branch), a scheduled run's implicit checkout ref is - # `main`, not `develop` -- and `scripts/phase2_metrics.py` / - # `_project/analysis/phase-3-promotion-metrics.md` only exist on `develop` - # (release-cut strips `_project/` and dev-tooling scripts from `main`). - # Same pattern as release-canary.yml's RELEASE_CANARY_REF. - PHASE3_REVIEW_REF: develop - -jobs: - # First-Monday guard. Scheduled runs proceed only on Monday; manual - # dispatch always proceeds. - guard: - runs-on: ubuntu-latest - outputs: - proceed: ${{ steps.check.outputs.proceed }} - steps: - - id: check - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "Manual dispatch -- proceeding." - echo "proceed=true" >> "$GITHUB_OUTPUT" - elif [ "$(date -u +%u)" = "1" ]; then - echo "Scheduled run on Monday within the first 7 days of the quarter month -- proceeding." - echo "proceed=true" >> "$GITHUB_OUTPUT" - else - echo "Scheduled run, but $(date -u +%A) is not Monday -- skipping (cron fires days 1-7; this guard enforces first-Monday)." - echo "proceed=false" >> "$GITHUB_OUTPUT" - fi - - review: - needs: guard - if: needs.guard.outputs.proceed == 'true' - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: ${{ env.PHASE3_REVIEW_REF }} - fetch-depth: 0 # full history so we can branch and push - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.12" - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Install dependencies - run: uv sync --group dev - - - name: Configure git identity - run: | - git config user.name "benchbox-bot" - git config user.email "benchbox-bot@users.noreply.github.com" - - - name: Generate promotion-metrics report - id: gen - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - REVIEW_DATE="$(date -u +%Y-%m-%d)" - REPORT="_project/handoffs/phase-3-review-${REVIEW_DATE}.md" - echo "review_date=${REVIEW_DATE}" >> "$GITHUB_OUTPUT" - echo "report=${REPORT}" >> "$GITHUB_OUTPUT" - # Markdown default; the script appends the "Trend vs " diff - # against the most recent prior phase-3-review-*.md automatically. - uv run -- python scripts/phase2_metrics.py --output "${REPORT}" - - - name: Open promotion-review PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REVIEW_DATE: ${{ steps.gen.outputs.review_date }} - REPORT: ${{ steps.gen.outputs.report }} - run: | - if [ -z "$(git status --porcelain -- "${REPORT}")" ]; then - echo "Report ${REPORT} is unchanged -- nothing to open a PR for." - exit 0 - fi - BRANCH="auto/phase-3-review-${REVIEW_DATE}" - git switch --force-create "${BRANCH}" - git add "${REPORT}" - git commit -m "chore(phase-3): quarterly promotion review ${REVIEW_DATE}" - git push --force-with-lease origin "${BRANCH}" - - BODY=$(cat < Phase 3 promotion-metrics review for ${REVIEW_DATE}. - - **This PR does not auto-merge.** Per the procedure in - \`_project/analysis/phase-3-promotion-metrics.md\`, the maintainer: - - 1. Reviews each metric and writes a one-paragraph interpretation - (sustained signal vs artifact) for any breached threshold. - 2. Reads the "Trend vs " section for the two-consecutive-quarters - hysteresis signal. - 3. Promotes the dependent Phase 3 design TODOs only if the decision - rule is met, then merges; otherwise merges as the quarter's audit - record (or closes if regenerating). - - Generated by \`scripts/phase2_metrics.py\` via - \`.github/workflows/phase3-promotion-review.yml\`. - EOF - ) - - # Idempotent for the manual-recovery path: re-dispatch on the same - # day updates the existing PR rather than failing on a duplicate. - EXISTING=$(gh pr list --base develop --head "${BRANCH}" --state open --json number --jq '.[0].number // empty') - if [ -n "${EXISTING}" ]; then - echo "PR #${EXISTING} already open for ${BRANCH}; updating body." - gh pr edit "${EXISTING}" --body "${BODY}" - else - gh pr create \ - --base develop \ - --head "${BRANCH}" \ - --title "chore(phase-3): quarterly promotion review ${REVIEW_DATE}" \ - --body "${BODY}" - fi diff --git a/.github/workflows/pr-base-guard.yml b/.github/workflows/pr-base-guard.yml deleted file mode 100644 index 40e42768c9..0000000000 --- a/.github/workflows/pr-base-guard.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: PR base guard - -# Every other PR workflow filters on `branches: [develop]` (or release / -# published-results), so a PR whose base is another feature branch triggers -# NOTHING - not pr.yml, not ci-required-result, not the browser lane. The PR -# page then looks calm rather than alarming: there are no failing checks -# because there are no checks at all, and the content reaches develop later -# attributed to the parent's PR, having never been validated on its own. -# -# This workflow carries no `branches:` filter, so it is the one check such a -# PR does get. On a normal base it passes in seconds; on a stacked base it -# fails loudly and says what to do. -# -# It must never become a required context that cannot report - the failure it -# exists to fix. That is why the filter is absent rather than set to a wide -# list: a list can go stale as branches are added, absence cannot. - -on: - pull_request: - # `edited` is included so retargeting an existing PR re-evaluates. Without - # it, a PR opened against develop and later repointed at a feature branch - # would keep its stale green result. - types: [opened, reopened, synchronize, edited] - -permissions: - contents: read - -jobs: - base-branch: - name: PR base branch is an integration branch - runs-on: ubuntu-latest - steps: - - name: Check base branch - env: - BASE_REF: ${{ github.base_ref }} - run: | - set -euo pipefail - case "$BASE_REF" in - develop|release|published-results) - echo "Base branch '$BASE_REF' is an integration branch; the normal CI lanes apply." - ;; - *) - echo "::error::PR base branch is '$BASE_REF', not develop, release or published-results." - echo "" - echo "Every PR workflow in this repository filters on those branches, so this PR" - echo "runs no CI at all - no pr.yml, no ci-required-result, no browser lane. Its" - echo "content reaches develop only when the parent merges, at which point it has" - echo "never been validated on its own and is attributed to the parent's PR." - echo "" - echo "develop squash-merges, so a stacked chain also needs a rebase and force-push" - echo "after every parent merge. Retarget this PR at develop, or fold the change" - echo "into the parent PR." - exit 1 - ;; - esac diff --git a/.github/workflows/publication-canaries.yml b/.github/workflows/publication-canaries.yml index ffb3888019..663a2f19d7 100644 --- a/.github/workflows/publication-canaries.yml +++ b/.github/workflows/publication-canaries.yml @@ -74,8 +74,6 @@ jobs: timeout-minutes: 15 permissions: contents: read - env: - EVIDENCE_DIR: ${{ runner.temp }}/publication-evidence steps: - name: Checkout repository uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -91,8 +89,14 @@ jobs: - name: Install dependencies run: uv sync --group dev + # The runner context is unavailable in job-level env, so the evidence + # directory is exported here for every later step. This runs even when + # dependency installation failed, matching the always() consumers below. - name: Prepare diagnostic report directory - run: mkdir -p diagnostic-reports + if: always() + run: | + mkdir -p diagnostic-reports + echo "EVIDENCE_DIR=$RUNNER_TEMP/publication-evidence" >> "$GITHUB_ENV" - name: Run 4-way reconciliation and drift checks id: reconciliation diff --git a/.github/workflows/publication-lane-docs.yml b/.github/workflows/publication-lane-docs.yml deleted file mode 100644 index 7537a786f6..0000000000 --- a/.github/workflows/publication-lane-docs.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Publication Lane - Docs and Prose - -on: - push: - branches: - - develop - - release - paths: - - "docs/**" - - "landing/**" - - "_blog/**" - - "benchbox/**" - - "scripts/assemble_public_site.py" - - "scripts/publication/verify_lane_isolation.py" - - ".github/workflows/publication-lane-docs.yml" - - "pyproject.toml" - - "uv.lock" - pull_request: - branches: - - develop - - release - paths: - - "docs/**" - - "landing/**" - - "_blog/**" - - "benchbox/**" - - "scripts/assemble_public_site.py" - - "scripts/publication/verify_lane_isolation.py" - - ".github/workflows/publication-lane-docs.yml" - - "pyproject.toml" - - "uv.lock" - workflow_dispatch: - -concurrency: - group: publication-lane-docs-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build-docs-lane: - name: Build prose site and release API docs - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout repository - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - # Full history: the PR isolation gate diffs origin/${base}...HEAD - # (three-dot needs a merge base). A shallow checkout fails that diff - # with "no merge base" (CI 2022 lane red), failing closed for the - # wrong reason. - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.11" - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Install dependencies - run: uv sync --group dev - - - name: Build Sphinx documentation - run: | - cd docs - uv run sphinx-build -b html --keep-going . _build/html - - - name: Assemble prose site artifact - run: | - mkdir -p lane_artifacts/prose_site - uv run python scripts/assemble_public_site.py --site-dir lane_artifacts/prose_site --prose-only - - - name: Package release API documentation artifact - run: | - mkdir -p lane_artifacts/api_docs - if [ -f docs/_build/html/api.html ]; then - cp docs/_build/html/api.html lane_artifacts/api_docs/ - fi - if [ -d docs/_build/html/_modules ]; then - cp -r docs/_build/html/_modules lane_artifacts/api_docs/ - fi - if [ -d docs/_build/html/_static ]; then - mkdir -p lane_artifacts/api_docs/_static - cp -r docs/_build/html/_static/* lane_artifacts/api_docs/_static/ - fi - if [ -d docs/_build/html/reference/python-api ]; then - mkdir -p lane_artifacts/api_docs/reference/python-api - cp -r docs/_build/html/reference/python-api lane_artifacts/api_docs/reference/ - fi - if [ ! -f lane_artifacts/api_docs/api.html ]; then - echo "ERROR: api_docs artifact missing api.html" >&2 - exit 1 - fi - if [ -z "$(ls -A lane_artifacts/api_docs 2>/dev/null)" ]; then - echo "ERROR: api_docs artifact is empty" >&2 - exit 1 - fi - - - name: Verify lane isolation - run: | - set -euo pipefail - CHANGED_FILE="${RUNNER_TEMP}/lane-changed-paths.txt" - if [ "${{ github.event_name }}" = "pull_request" ]; then - git fetch origin "${{ github.base_ref }}" - git diff --name-only "origin/${{ github.base_ref }}...HEAD" > "$CHANGED_FILE" - if [ ! -s "$CHANGED_FILE" ]; then - echo "ERROR: empty changed-paths for pull_request isolation check" >&2 - exit 1 - fi - uv run python scripts/publication/verify_lane_isolation.py --lane site --changed-paths-file "$CHANGED_FILE" - elif [ "${{ github.event_name }}" = "push" ]; then - BEFORE="${{ github.event.before }}" - if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then - git diff --name-only "${BEFORE}...HEAD" > "$CHANGED_FILE" - if [ -s "$CHANGED_FILE" ]; then - uv run python scripts/publication/verify_lane_isolation.py --lane site --changed-paths-file "$CHANGED_FILE" - else - uv run python scripts/publication/verify_lane_isolation.py --lane site - fi - else - uv run python scripts/publication/verify_lane_isolation.py --lane site - fi - else - uv run python scripts/publication/verify_lane_isolation.py --lane site - fi - - - name: Upload prose site artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: prose_site - path: lane_artifacts/prose_site - retention-days: 90 - if-no-files-found: error - - - name: Upload API docs artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: api_docs - path: lane_artifacts/api_docs - retention-days: 90 - if-no-files-found: error diff --git a/.github/workflows/publication-lane-explorer.yml b/.github/workflows/publication-lane-explorer.yml deleted file mode 100644 index 05426fa405..0000000000 --- a/.github/workflows/publication-lane-explorer.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Results Explorer Publication Lane - -on: - push: - branches: [ release, develop ] - paths: - - 'results-explorer/**' - - 'scripts/publication/**' - - '.github/workflows/publication-lane-explorer.yml' - pull_request: - branches: [ release, develop ] - paths: - - 'results-explorer/**' - - 'scripts/publication/**' - - '.github/workflows/publication-lane-explorer.yml' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: publication-lane-explorer-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -jobs: - build-explorer-app: - name: Build and package Results Explorer application - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Check out repository - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - - name: Set up Node.js - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: results-explorer/package-lock.json - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.11" - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Install Python dependencies - run: uv sync --group dev - - - name: Install Explorer dependencies - run: npm ci - working-directory: results-explorer - - - name: Typecheck Explorer SPA - run: npm run typecheck - working-directory: results-explorer - - - name: Build Results Explorer SPA - run: npm run build - working-directory: results-explorer - - - name: Run Explorer unit tests - run: npm run test - working-directory: results-explorer - - - name: Run Python contract tests - run: uv run python -m pytest tests/unit/scripts/explorer_pipeline/test_duckdb_browser_contract.py tests/unit/scripts/test_explorer_build_contract.py -q - - - name: Validate Explorer compatibility and generate content-addressed manifest - run: uv run python scripts/publication/check_explorer_compat.py --artifact results-explorer/dist --generate-manifest - - - name: Verify Explorer artifact bundle - run: uv run python scripts/publication/check_explorer_compat.py --artifact results-explorer/dist --require-manifest - - - name: Upload explorer_app artifact bundle - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: explorer_app-${{ github.sha }} - path: results-explorer/dist - retention-days: 30 diff --git a/.github/workflows/publication-transaction.yml b/.github/workflows/publication-transaction.yml index 584446a2c8..488a005bbb 100644 --- a/.github/workflows/publication-transaction.yml +++ b/.github/workflows/publication-transaction.yml @@ -143,6 +143,7 @@ jobs: --artifact-metadata candidate-validation/artifact.json \ --run-metadata candidate-validation/run.json \ --expected-develop-sha "$EXPECTED_DEVELOP_SHA" \ + --develop-max-behind-commits 50 \ --expected-published-results-sha "$EXPECTED_PUBLISHED_RESULTS_SHA" \ --expected-generation "$EXPECTED_GENERATION" \ "${PARENT_ARGS[@]}" \ @@ -274,7 +275,6 @@ jobs: CANDIDATE_ARTIFACT_ID: ${{ needs.prepare.outputs.candidate_artifact_id }} EXPECTED_SITE_DIGEST: ${{ needs.prepare.outputs.candidate_site_tree_sha256 }} EXPECTED_ARCHIVE_DIGEST: ${{ needs.prepare.outputs.candidate_archive_sha256 }} - EXPECTED_DEVELOP_SHA: ${{ needs.prepare.outputs.develop_sha }} EXPECTED_PUBLISHED_RESULTS_SHA: ${{ needs.prepare.outputs.published_results_sha }} EXPECTED_GENERATION: ${{ needs.prepare.outputs.generation }} EXPECTED_PARENT_SHA: ${{ needs.prepare.outputs.candidate_parent_sha }} @@ -287,6 +287,11 @@ jobs: test -n "$PRODUCER_RUN_ID" || { echo "::error::candidate artifact has no producing run"; exit 1; } gh api "repos/${{ github.repository }}/actions/runs/$PRODUCER_RUN_ID" > candidate-validation/run.json gh api "repos/${{ github.repository }}/actions/artifacts/$CANDIDATE_ARTIFACT_ID/zip" > candidate-validation/candidate.zip + # Re-resolve the protected tip after the approval wait: the + # candidate stays valid while built from the tip or a recent + # ancestor of it, so queued approvals survive merge-queue landings. + git fetch --no-tags origin develop:refs/remotes/origin/develop + CURRENT_DEVELOP_SHA=$(git rev-parse refs/remotes/origin/develop) PARENT_ARGS=() [ -z "$EXPECTED_PARENT_SHA" ] || PARENT_ARGS+=(--expected-parent-sha "$EXPECTED_PARENT_SHA") [ -z "$EXPECTED_PARENT_GENERATION" ] || PARENT_ARGS+=(--expected-parent-generation "$EXPECTED_PARENT_GENERATION") @@ -296,7 +301,8 @@ jobs: --artifact-id "$CANDIDATE_ARTIFACT_ID" \ --artifact-metadata candidate-validation/artifact.json \ --run-metadata candidate-validation/run.json \ - --expected-develop-sha "$EXPECTED_DEVELOP_SHA" \ + --expected-develop-sha "$CURRENT_DEVELOP_SHA" \ + --develop-max-behind-commits 50 \ --expected-published-results-sha "$EXPECTED_PUBLISHED_RESULTS_SHA" \ --expected-generation "$EXPECTED_GENERATION" \ "${PARENT_ARGS[@]}" \ diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index e46b3d3f65..5842bf62aa 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -15,6 +15,7 @@ permissions: contents: read env: + BENCHBOX_TEST_LOCK_WAIT_SECONDS: "0" RELEASE_CANARY_REF: develop RELEASE_CANARY_SHARD_COUNT: 6 @@ -121,6 +122,30 @@ jobs: ref: ${{ needs.collect-credential-free-non-fast.outputs.checked_sha }} fetch-depth: 0 + - name: Record setup start + # Runs right after checkout (checkout wipes untracked workspace files, + # so nothing can be recorded earlier). The setup interval therefore + # covers artifact download, Python/uv install, uv sync, and + # partitioning; checkout itself stays recoverable from step timings. + # The stamp lives outside the uploaded artifact directory; only the + # summary JSON carries the durations. + # + # The stamp is monotonic seconds since boot, not wall-clock time. An + # NTP adjustment during the job would otherwise publish skewed or + # negative durations, and these durations are what shard sizing is + # supposed to be built on. This step runs before Python and uv are + # installed, so benchbox.utils.clock.mono_time() is not reachable yet + # and the monotonic source has to come straight from the kernel. + shell: bash + run: | + setup_mono="" + if [ -r /proc/uptime ]; then + # `|| true`: read returns non-zero at EOF on an input without a + # trailing newline, and errexit is still active in this step. + read -r setup_mono _ < /proc/uptime || true + fi + echo "$setup_mono" > "${GITHUB_WORKSPACE}/.canary-setup-start-mono" + - name: Download collected node IDs uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: @@ -157,8 +182,29 @@ jobs: SHARD_INDEX: ${{ matrix.shard_index }} run: | set -o pipefail + junit_xml="release-canary-artifacts/shard-${SHARD_INDEX}-junit.xml" + # Monotonic seconds since boot, for the same reason as the setup + # stamp: these two stamps bound test_seconds, and a wall clock that + # NTP moves mid-job would publish a skewed or negative duration. + # Python is installed by this point, but the stamp is read from the + # kernel so it shares one time base with the setup stamp. + mono_stamp() { + local uptime_value="" + if [ -r /proc/uptime ]; then + read -r uptime_value _ < /proc/uptime || true + fi + echo "$uptime_value" + } + tests_start=$(mono_stamp) + echo "$tests_start" > "${GITHUB_WORKSPACE}/.canary-tests-start-mono" mapfile -t node_ids < "release-canary-artifacts/shard-${SHARD_INDEX}-nodeids.txt" rc=0 + # GitHub runs `shell: bash` under errexit, so a failing pytest would + # abort this step at the pipeline and skip the exit-code capture and + # every piece of evidence below. errexit stays off from here on: this + # step's result is decided by `exit "$rc"` alone, and a failure to + # record evidence must not change that result. + set +e if [ "${#node_ids[@]}" -eq 0 ]; then echo "No tests assigned to shard ${SHARD_INDEX}." | tee "release-canary-artifacts/shard-${SHARD_INDEX}-pytest.log" else @@ -167,8 +213,11 @@ jobs: -n 0 \ --tb=short \ --maxfail=5 \ + --junitxml="$junit_xml" \ "${node_ids[@]}" \ 2>&1 | tee "release-canary-artifacts/shard-${SHARD_INDEX}-pytest.log" + # Read PIPESTATUS in a single assignment: reading the two elements + # separately resets it after the first read. pipeline_status=("${PIPESTATUS[@]}") rc=${pipeline_status[0]} tee_rc=${pipeline_status[1]} @@ -176,6 +225,36 @@ jobs: rc="$tee_rc" fi fi + tests_end=$(mono_stamp) + echo "$tests_end" > "${GITHUB_WORKSPACE}/.canary-tests-end-mono" + # The JUnit report is best-effort evidence: a missing file must never + # change the shard result, so fall back to a marked empty report. + # Record which of the two happened, because a fallback report is + # present but carries no durations; a consumer sizing shards from + # these numbers must be able to tell them apart. + # + # A non-empty file is not sufficient. pytest exits 5 when it + # collected nothing to run -- every node ID deselected by the marker + # expression, or a typo'd path -- and still writes a well-formed, + # non-empty report with no in it. That report is genuine + # evidence that pytest ran, so keep it, but it carries no durations + # and must not be published as a capture. + junit_captured="true" + if [ ! -s "$junit_xml" ]; then + junit_captured="false" + cat > "$junit_xml" < + + + + + No per-test durations were captured for shard ${SHARD_INDEX}. + + EOF + elif ! grep -q " "${GITHUB_WORKSPACE}/.canary-junit-captured" echo "pytest_exit_code=$rc" >> "$GITHUB_OUTPUT" exit "$rc" @@ -194,6 +273,51 @@ jobs: if [ -f "$shard_nodeids" ]; then assigned_count=$(wc -l < "$shard_nodeids" | tr -d ' ') fi + # Setup covers post-checkout preparation (artifact download, uv sync, + # partitioning); test time covers pytest only. The stamps are + # monotonic seconds since boot, so the differences are elapsed time + # even if the runner's wall clock moves. A missing stamp degrades to + # an empty field, never to a failed summary. + setup_start="" + tests_start="" + tests_end="" + if [ -f "${GITHUB_WORKSPACE}/.canary-setup-start-mono" ]; then + setup_start=$(cat "${GITHUB_WORKSPACE}/.canary-setup-start-mono") + fi + if [ -f "${GITHUB_WORKSPACE}/.canary-tests-start-mono" ]; then + tests_start=$(cat "${GITHUB_WORKSPACE}/.canary-tests-start-mono") + fi + if [ -f "${GITHUB_WORKSPACE}/.canary-tests-end-mono" ]; then + tests_end=$(cat "${GITHUB_WORKSPACE}/.canary-tests-end-mono") + fi + # Durations are whole elapsed seconds, computed only when both stamps + # are present. awk subtracts the floats and int() truncates, which is + # what the previous integer arithmetic did and what "elapsed seconds" + # should mean; rounding would silently inflate short intervals. + setup_seconds="" + test_seconds="" + if [ -n "$setup_start" ] && [ -n "$tests_start" ]; then + setup_seconds=$(awk -v a="$setup_start" -v b="$tests_start" 'BEGIN{printf "%d", int(b - a)}') + fi + if [ -n "$tests_start" ] && [ -n "$tests_end" ]; then + test_seconds=$(awk -v a="$tests_start" -v b="$tests_end" 'BEGIN{printf "%d", int(b - a)}') + fi + junit_xml="release-canary-artifacts/shard-${SHARD_INDEX}-junit.xml" + junit_present="false" + if [ -s "$junit_xml" ]; then + junit_present="true" + fi + # junit_present says a report file exists; junit_captured says it + # holds real per-test durations. A shard whose test step never ran + # leaves no marker, and anything other than an exact "true" degrades + # to false, so the summary never claims durations it cannot back. + junit_captured="false" + if [ -f "${GITHUB_WORKSPACE}/.canary-junit-captured" ]; then + junit_captured=$(cat "${GITHUB_WORKSPACE}/.canary-junit-captured") + fi + if [ "$junit_captured" != "true" ]; then + junit_captured="false" + fi cat > "release-canary-artifacts/shard-${SHARD_INDEX}-summary.json" <- + uv run -- python scripts/assemble_public_site.py + --routes deploy/routes.yml + --ref-root release=../release + --ref-root trunk=. + --site-dir ../site-build + --work-dir ../routes-work + --receipt-out ../out/route-assembly.json + + - name: Restore the retained generation + if: inputs.mode == 'rollback' + env: + GH_TOKEN: ${{ github.token }} + PYTHONPATH: trunk + run: | + set -euo pipefail + python -m scripts.site_deploy fetch-run --run-id "$ROLLBACK_RUN_ID" --receipt-sha256 "$(jq -r '.rollback.receipt_sha256' out/resolved.json)" --receipt-dir restored/receipt --tree-dir restored/tree + args=(--receipt restored/receipt/receipt.json --restored-tree restored/tree --phase "$ROLLBACK_PHASE" --resolved out/resolved.json --site-dir site-build --out-dir out) + if [ "$ROLLBACK_PHASE" = "ui-first" ]; then + current_run=$(jq -r '.deployed.run_id' out/resolved.json) + python -m scripts.site_deploy fetch-run --run-id "$current_run" --receipt-sha256 "$(jq -r '.deployed.receipt_sha256' out/resolved.json)" --receipt-dir current/receipt --tree-dir current/tree + args+=(--current-tree current/tree --current-receipt current/receipt/receipt.json) + fi + python -m scripts.site_deploy rollback-prepare "${args[@]}" + + - name: Fetch the deployed snapshot + run: | + set -euo pipefail + if [ "$(jq -r '.deployed != null' out/resolved.json)" = "true" ]; then + curl -fsSL --retry 3 https://benchbox.dev/results/data/results.duckdb -o deployed.duckdb + fi + + - name: Run gates + working-directory: trunk + run: | + set -euo pipefail + args=(--repo-root . --site-dir ../site-build --resolved ../out/resolved.json --assembly ../out/route-assembly.json --out-dir ../out) + if [ -f ../deployed.duckdb ]; then + args+=(--deployed-snapshot ../deployed.duckdb) + fi + if [ "$MODE" = "rollback" ]; then + args+=(--corpus-sha "$(jq -r '.corpus_sha' ../out/restore.json)" --ui-version "$(jq -r '.ui_version' ../out/restore.json)") + fi + uv run -- python -m scripts.site_deploy gates "${args[@]}" + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + if: always() + with: + name: site-deploy-build-${{ github.run_id }}-${{ github.run_attempt }} + path: | + out + !out/gate-work + include-hidden-files: true + if-no-files-found: ignore + retention-days: 90 + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: site-deploy-artifact-${{ github.run_id }}-${{ github.run_attempt }} + path: site-build + include-hidden-files: true + retention-days: 90 + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + if: inputs.mode == 'preview' + with: + name: site-deploy-legacy-${{ github.run_id }}-${{ github.run_attempt }} + path: site-legacy + include-hidden-files: true + retention-days: 7 + + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 + if: inputs.mode != 'preview' + with: + path: site-build + include-hidden-files: true + + deploy: + needs: [resolve, build] + if: inputs.mode != 'preview' && needs.resolve.outputs.action == 'deploy' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + contents: read + actions: read + deployments: read + pages: write + id-token: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.11" + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: ${{ needs.resolve.outputs.resolved_artifact }} + path: out + + - name: Recheck the deployed generation + env: + GH_TOKEN: ${{ github.token }} + run: python -m scripts.site_deploy recheck --resolved out/resolved.json + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 + + probe: + needs: [resolve, build, deploy] + if: needs.deploy.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + deployments: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + persist-credentials: false + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.11" + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: ${{ needs.build.outputs.build_artifact }} + path: out + + - name: Probe production and write the receipt + id: finalize + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_NAME: ${{ needs.build.outputs.site_artifact }} + run: python -m scripts.site_deploy finalize --out-dir out --base-url https://benchbox.dev --artifact-name "$ARTIFACT_NAME" + + - name: Upload the receipt + id: receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + if: always() + with: + name: site-deploy-receipt-${{ github.run_id }}-${{ github.run_attempt }} + path: out/receipt + if-no-files-found: error + retention-days: 90 + + - name: Record the receipt on the deployment + if: always() && steps.finalize.outcome == 'success' && steps.receipt_upload.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + run: python -m scripts.site_deploy record --out-dir out --base-url https://benchbox.dev + + preview: + needs: [resolve, build] + if: inputs.mode == 'preview' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + persist-credentials: false + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.11" + + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: ${{ needs.build.outputs.build_artifact }} + path: out + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: ${{ needs.build.outputs.site_artifact }} + path: site-build + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: ${{ needs.build.outputs.legacy_artifact }} + path: site-legacy + + - name: Install dependencies + run: uv sync --group dev + + - name: Run the preview drill + run: uv run -- python -m scripts.site_deploy preview --site-dir site-build --legacy-site site-legacy --out-dir out --production-url https://benchbox.dev + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + if: always() + with: + name: site-deploy-preview-${{ github.run_id }}-${{ github.run_attempt }} + path: | + out/preview-drill.json + out/parity.json + out/production-parity.json + out/preview-receipts + out/gates.json + out/resolved.json + if-no-files-found: ignore + retention-days: 90 diff --git a/.github/workflows/soundness-drain.yml b/.github/workflows/soundness-drain.yml deleted file mode 100644 index 659f9b12b0..0000000000 --- a/.github/workflows/soundness-drain.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Soundness-PR Drain Digest - -# Soundness-path PRs correctly WITHHOLD auto-merge (auto-merge-on-open.yml + -# _project/scripts/auto_merge_soundness_paths.py): the owner must review and -# merge them by hand. That gate is intentional and this workflow does not -# touch it -- no auto-merge, no auto-approve. What was missing is an -# observability signal: those PRs can park silently and grow conflicts -# (#1116, #1142 sat for days before anyone noticed). This job runs -# _project/scripts/soundness_drain_report.py, which: -# - classifies open develop PRs that are green on required checks, -# awaiting the owner (auto-merge off + soundness-path touch, or -# review-requested-from-owner), and idle > 24h; -# - syncs the `awaiting-owner` label to match that set; -# - upserts ONE pinned issue ("Soundness-PR drain queue") with the digest, -# but ONLY when the queue is non-empty -- silent otherwise, so this is -# one digest a day, never a per-PR/per-event ping. -# -# See docs/operations/soundness-drain.md for the full design and the label -# semantics. - -on: - schedule: - # Daily, 05:00 UTC -- off-peak, distinct from the other daily/weekly - # scheduled jobs (release-canary 08:00, nightly 06:00, cross-platform - # 03:00, extension-smoke 06:00). - - cron: "0 5 * * *" - workflow_dispatch: {} - -permissions: - contents: read - pull-requests: write - issues: write - -jobs: - drain-digest: - name: Report parked soundness-path PRs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - fetch-depth: 1 - - - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.11" - - - name: Run drain report (observe + apply label/digest only) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: uv run -- python _project/scripts/soundness_drain_report.py --apply diff --git a/.github/workflows/soundness-merge-digest.yml b/.github/workflows/soundness-merge-digest.yml new file mode 100644 index 0000000000..66d35912b2 --- /dev/null +++ b/.github/workflows/soundness-merge-digest.yml @@ -0,0 +1,46 @@ +name: Soundness Merge Digest + +on: + schedule: + - cron: "23 5 * * *" + workflow_dispatch: + inputs: + bootstrap: + description: Record the current develop head as the first checkpoint + type: boolean + default: false + +permissions: + actions: read + contents: read + issues: write + pull-requests: read + +concurrency: + group: soundness-merge-digest + cancel-in-progress: false + +jobs: + digest: + name: Digest soundness-path merges + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out develop + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + ref: develop + fetch-depth: 0 + + - name: Report and record the digest + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + BOOTSTRAP: ${{ inputs.bootstrap }} + run: | + set -euo pipefail + args=(--repo "${REPOSITORY}" --ref origin/develop --apply) + if [[ "${BOOTSTRAP}" == "true" ]]; then + args+=(--bootstrap) + fi + python3 _project/scripts/soundness_merge_digest.py "${args[@]}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f83174a451..8ff276dc08 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,8 @@ name: Tests +env: + BENCHBOX_TEST_LOCK_WAIT_SECONDS: "0" + on: push: branches: diff --git a/.github/workflows/tpcds-official-qualification.yml b/.github/workflows/tpcds-official-qualification.yml new file mode 100644 index 0000000000..96c6d82c58 --- /dev/null +++ b/.github/workflows/tpcds-official-qualification.yml @@ -0,0 +1,66 @@ +name: TPC-DS Official Qualification Report + +on: + schedule: + - cron: '0 12 * * 0' + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: tpcds-official-qualification-report + cancel-in-progress: false + +jobs: + qualification-report: + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + POLARS_MAX_THREADS: '2' + OMP_NUM_THREADS: '2' + steps: + - name: Set benchmark output directory + run: echo "BENCHBOX_OUTPUT_DIR=${RUNNER_TEMP}/benchmark_runs" >> "$GITHUB_ENV" + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + - name: Install local engine dependencies + run: uv sync --group dev --extra duckdb --extra dataframe-polars --extra dataframe-pandas --extra dataframe-datafusion + - name: Audit all qualification substitution tables + run: | + mkdir -p "${{ runner.temp }}/tpcds-qualification" + uv run --with pypdf==6.0.0 -- python scripts/audit_tpcds_qualification.py --pdf _sources/tpc-ds/specification/specification_4.0.0.pdf --parameters benchbox/core/tpcds/dataframe_queries/qualification_values.json --output "${{ runner.temp }}/tpcds-qualification/parameter-audit.json" + - name: Compare SF1 qualification values + id: report + continue-on-error: true + run: >- + uv run -- python -m benchbox.core.tpcds.qualification.runner + --output-dir "${{ runner.temp }}/tpcds-qualification" + --query-seconds 120 --overall-seconds 1200 + - name: Summarize qualification diagnostics + if: always() + env: + REPORT_OUTCOME: ${{ steps.report.outcome }} + run: | + echo "TPC-DS SF1 qualification diagnostic outcome: ${REPORT_OUTCOME}. Strict printed-answer mismatches and execution failures remain in the artifacts; this advisory report does not certify TPC compliance." >> "$GITHUB_STEP_SUMMARY" + if [ -f "${{ runner.temp }}/tpcds-qualification/summary.jsonl" ]; then + cat "${{ runner.temp }}/tpcds-qualification/summary.jsonl" >> "$GITHUB_STEP_SUMMARY" + fi + - name: Upload qualification evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: tpcds-official-qualification + path: | + ${{ runner.temp }}/tpcds-qualification/inputs.json + ${{ runner.temp }}/tpcds-qualification/parameter-audit.json + ${{ runner.temp }}/tpcds-qualification/*.jsonl + ${{ runner.temp }}/tpcds-qualification/*.log + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/tpcds-platform-identity.yml b/.github/workflows/tpcds-platform-identity.yml new file mode 100644 index 0000000000..adc2554249 --- /dev/null +++ b/.github/workflows/tpcds-platform-identity.yml @@ -0,0 +1,108 @@ +name: TPC-DS Platform Identity + +# Each platform ships its own dsdgen and dsqgen binary. scripts/bundled_binary_manifest.py pins the +# bytes of those binaries; this workflow checks what they produce. Each runner generates TPC-DS data +# at scale factor 0.01 and records per-table row counts and checksums, the dsqgen -LOG values for +# every query at a pinned seed, and a hash of the rendered SQL. A final job compares the manifests and +# fails on any difference, so a DataFrame-versus-SQL divergence that appears on one platform and not +# another can be traced to the data or the parameters. The parameters are also recorded at SF 100, +# where dsqgen needs no data and draws such as the Q46 city list are not degenerate. +# +# Linux and macOS must agree. Windows runs as an advisory cell: its manifest and comparison never fail +# the workflow. The other bundled binaries (darwin-x86_64, linux-arm64, windows-arm64) are not run. +# +# Not a required check: it runs on a schedule, on demand, and on pull requests that touch the bundled +# TPC-DS binaries, the code that selects and runs them, or this check. + +on: + schedule: + - cron: "30 4 * * *" + workflow_dispatch: + pull_request: + paths: + - "benchbox/_binaries/tpc-ds/**" + - "benchbox/core/tpcds/c_tools.py" + - "benchbox/core/tpcds/parameter_log.py" + - "benchbox/core/tpcds/queries.py" + - "benchbox/core/tpcds/streams.py" + - "benchbox/core/tpcds/benchmark/**" + - "benchbox/core/tpcds/generator/**" + - "benchbox/utils/tpc_compilation.py" + - "scripts/tpcds_platform_identity.py" + - ".github/workflows/tpcds-platform-identity.yml" + +permissions: + contents: read + +concurrency: + group: tpcds-platform-identity-${{ github.ref }} + cancel-in-progress: true + +jobs: + manifest: + name: TPC-DS manifest (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + continue-on-error: ${{ matrix.os == 'windows-latest' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync + + - name: Build the manifest + run: uv run -- python scripts/tpcds_platform_identity.py manifest --out "manifest-${{ runner.os }}-${{ runner.arch }}.json" + + - name: Upload the manifest + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: tpcds-manifest-${{ runner.os }}-${{ runner.arch }} + path: manifest-*.json + if-no-files-found: error + retention-days: 30 + + compare: + name: Compare TPC-DS manifests + needs: manifest + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + + - name: Download the manifests + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + pattern: tpcds-manifest-* + path: manifests + merge-multiple: true + + - name: Compare Linux and macOS + run: python scripts/tpcds_platform_identity.py compare manifests/manifest-Linux-*.json manifests/manifest-macOS-*.json + + - name: Compare with Windows (advisory) + continue-on-error: true + run: | + if ls manifests/manifest-Windows-*.json >/dev/null 2>&1; then + python scripts/tpcds_platform_identity.py compare manifests/manifest-*.json + else + echo "No Windows manifest was produced; see the Windows manifest job." + fi diff --git a/.github/workflows/trunk.yml b/.github/workflows/trunk.yml new file mode 100644 index 0000000000..ac762699ea --- /dev/null +++ b/.github/workflows/trunk.yml @@ -0,0 +1,390 @@ +name: Trunk + +env: + BENCHBOX_TEST_LOCK_WAIT_SECONDS: "0" + +on: + push: + branches: [develop] + workflow_dispatch: + +concurrency: + group: trunk-${{ github.ref }} + cancel-in-progress: false + queue: max + +permissions: + contents: read + +jobs: + fast: + name: fast (ubuntu-latest, 3.12) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Create virtual environment + run: uv venv + + - name: Install dependencies + run: uv sync --group dev + + - name: Run fast tests + run: | + uv run -- python -m pytest tests -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=120 + + medium-collect: + name: medium-collect + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Collect the complete medium tier + shell: bash + run: | + set -euo pipefail + mkdir -p t2-artifacts + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest --collect-only -q -n 0 \ + -m "medium and not (slow or stress or resource_heavy or live_integration)" \ + > t2-artifacts/medium-collect.txt + count=$(grep -Eo '^[0-9]+/[0-9]+ tests collected' t2-artifacts/medium-collect.txt | tail -1 | cut -d/ -f1) + test -n "$count" + uv run -- python scripts/release_canary_sharding.py collect \ + --input t2-artifacts/medium-collect.txt \ + --nodeids-output t2-artifacts/medium-nodeids.txt \ + --summary-output t2-artifacts/medium-collection.json \ + --expected-count "$count" --shard-count 4 \ + --workflow trunk.yml --job medium-collect \ + --marker-expression "medium and not (slow or stress or resource_heavy or live_integration)" \ + --checked-ref "${{ github.ref }}" --checked-sha "${{ github.sha }}" + + - name: Upload medium collection evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t2-medium-nodeids-${{ github.sha }} + path: t2-artifacts/ + if-no-files-found: error + retention-days: 30 + + medium-test: + name: medium-test (shard ${{ matrix.shard_index }}) + needs: medium-collect + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + shard_index: [0, 1, 2, 3] + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Download medium collection evidence + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: t2-medium-nodeids-${{ github.sha }} + path: t2-artifacts + + - name: Partition the complete medium tier + env: + SHARD_INDEX: ${{ matrix.shard_index }} + run: | + uv run -- python scripts/release_canary_sharding.py shard \ + --input t2-artifacts/medium-nodeids.txt \ + --nodeids-output "t2-artifacts/shard-${SHARD_INDEX}-nodeids.txt" \ + --summary-output "t2-artifacts/shard-${SHARD_INDEX}-summary.json" \ + --shard-index "$SHARD_INDEX" --shard-count 4 \ + --workflow trunk.yml --job medium-test \ + --marker-expression "medium and not (slow or stress or resource_heavy or live_integration)" \ + --collection-summary t2-artifacts/medium-collection.json --checked-sha "${{ github.sha }}" + + - name: Run medium speed tier + shell: bash + env: + SHARD_INDEX: ${{ matrix.shard_index }} + run: | + set -euo pipefail + test -s "t2-artifacts/shard-${SHARD_INDEX}-nodeids.txt" + mapfile -t node_ids < "t2-artifacts/shard-${SHARD_INDEX}-nodeids.txt" + sample_resources() { + while sleep 30; do + echo "[resources $(date -u +%H:%M:%S)] $(free -m | awk '/Mem:/ {print "used_mb=" $3 " available_mb=" $7}') load=$(cut -d' ' -f1-3 /proc/loadavg)" + ps -eo pid,rss,etimes,comm --sort=-rss | awk 'NR <= 4 {print " " $0}' + done + } + sample_resources & + sampler=$! + trap 'pkill -P "${sampler}" 2>/dev/null || true; kill "${sampler}" 2>/dev/null || true' EXIT + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest \ + -m "medium and not (slow or stress or resource_heavy or live_integration)" \ + --tb=short --timeout=60 -n 5 --durations=25 \ + -p scripts.pytest_shard_evidence \ + --assigned-nodeids "t2-artifacts/shard-${SHARD_INDEX}-nodeids.txt" \ + --shard-evidence "t2-artifacts/shard-${SHARD_INDEX}-execution.json" \ + --checked-sha "${{ github.sha }}" "${node_ids[@]}" + + - name: Record kernel memory events for the medium shard + if: always() + shell: bash + run: | + free -m + sudo dmesg -T 2>/dev/null | grep -iE 'out of memory|oom-kill|killed process' | tail -20 || true + + - name: Upload medium shard evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: t2-medium-shard-${{ matrix.shard_index }}-${{ github.sha }} + path: t2-artifacts/ + if-no-files-found: error + retention-days: 30 + + correctness-gate: + name: correctness-gate (${{ matrix.partition }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + partition: [a, b] + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Create virtual environment + run: uv venv + + - name: Install dependencies + run: uv sync --group dev + + - name: Run bounded real-result correctness gate + if: matrix.partition == 'b' + run: make test-correctness-gate + + - name: Run TPC-Havoc variant-equivalence gate + if: matrix.partition == 'b' + run: make tpchavoc-equivalence-report + + - name: Run TPC-Havoc DataFrame variant-equivalence gate + if: matrix.partition == 'a' + run: make tpchavoc-dataframe-equivalence-report + + - name: Run SSB cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' + run: make ssb-cross-surface-equivalence-report + + - name: Run AMPLab cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' + run: make amplab-cross-surface-equivalence-report + + - name: Run CoffeeShop cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make coffeeshop-cross-surface-equivalence-report + + - name: Run ClickBench cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' + run: make clickbench-cross-surface-equivalence-report + + - name: Run JoinOrder-synthetic cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' + run: make joinorder-synthetic-cross-surface-equivalence-report + + - name: Run H2O-DB cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make h2odb-cross-surface-equivalence-report + + - name: Run Read Primitives cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' + run: make read-primitives-cross-surface-equivalence-report + + - name: Run FlightData cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make flightdata-cross-surface-equivalence-report + + - name: Run Data Vault cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'b' + run: make datavault-cross-surface-equivalence-report + + - name: Run NYC Taxi cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make nyctaxi-cross-surface-equivalence-report + + - name: Run TSBS DevOps cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make tsbs-devops-cross-surface-equivalence-report + + - name: Run TPC-H Skew cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make tpch-skew-cross-surface-equivalence-report + + - name: Run TPC-H cross-surface SQL<->DataFrame equivalence gate + if: matrix.partition == 'a' + run: make tpch-cross-surface-equivalence-report + + - name: Run TPC-DS cross-surface SQL<->DataFrame equivalence gate (Polars and DataFusion) + if: matrix.partition == 'b' + run: make tpcds-cross-surface-equivalence-report + + - name: Run TPC-DS cross-surface SQL<->DataFrame equivalence gate (pandas) + if: matrix.partition == 'a' + run: make tpcds-pandas-cross-surface-equivalence-report + + tpcds-parity-draws: + name: tpcds-parity-draws + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Create virtual environment + run: uv venv + + - name: Install dependencies + run: uv sync --group dev + + - name: Compare every TPC-DS backend twice on three other Power draws + run: make tpcds-cross-surface-draws-report + + required-local-cases: + name: required-local-cases + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Create virtual environment + run: uv venv + + - name: Install dependencies + run: uv sync --group dev + + - name: Run the required local-engine cases + run: make test-required-local-cases + + dist-artifact: + name: dist-artifact + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + actions: read + steps: + - name: Checkout code + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + + - name: Verify source bundled binary manifest + run: uv run --locked --no-dev -- python scripts/bundled_binary_manifest.py + + - name: Build wheel and sdist + run: | + set -euo pipefail + uv build + wheel_count=$(find dist -maxdepth 1 -name '*.whl' | wc -l | tr -d '[:space:]') + sdist_count=$(find dist -maxdepth 1 -name '*.tar.gz' | wc -l | tr -d '[:space:]') + if [ "$wheel_count" != "1" ] || [ "$sdist_count" != "1" ]; then + echo "::error::Expected exactly one wheel and one sdist, found $wheel_count wheel(s) and $sdist_count sdist(s)" + find dist -maxdepth 1 -type f -print + exit 1 + fi + uv run --locked --no-dev -- python scripts/verify_distribution_binaries.py dist/*.whl dist/*.tar.gz + (cd dist && sha256sum -- *.whl *.tar.gz > SHA256SUMS) + cat dist/SHA256SUMS + + - name: Bind distributions to the producer attempt + env: + GH_TOKEN: ${{ github.token }} + run: python -I -S scripts/release_artifact_consumer.py producer --dist dist + + - name: Upload dist artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: dist-${{ github.sha }}-attempt-${{ github.run_attempt }} + path: | + dist/*.whl + dist/*.tar.gz + dist/SHA256SUMS + dist/producer-receipt.json + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/validate-release-pr.yml b/.github/workflows/validate-release-pr.yml index 5fe1d36989..db98174583 100644 --- a/.github/workflows/validate-release-pr.yml +++ b/.github/workflows/validate-release-pr.yml @@ -1,5 +1,8 @@ name: Validate release PR base +env: + BENCHBOX_TEST_LOCK_WAIT_SECONDS: "0" + on: pull_request: branches: diff --git a/.github/workflows/validate-submission-comment.yml b/.github/workflows/validate-submission-comment.yml index a7e7384071..9a949b79da 100644 --- a/.github/workflows/validate-submission-comment.yml +++ b/.github/workflows/validate-submission-comment.yml @@ -42,8 +42,10 @@ jobs: comment: name: Post validation comment from trusted context runs-on: ubuntu-latest - # Only PR-triggered validation runs carry a comment artifact. - if: github.event.workflow_run.event == 'pull_request' + # Only PR-triggered validation runs carry a comment artifact. The + # validator fires on pull_request_target (fork-safe base context), so the + # workflow_run event name is 'pull_request_target', not 'pull_request'. + if: github.event.workflow_run.event == 'pull_request_target' steps: - name: Download validation comment artifact id: download diff --git a/.gitignore b/.gitignore index 4b3df29dc9..f0c6fc61cd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ lib/ lib64/ # TypeScript source lib dirs are not Python lib artifacts !results-explorer/src/lib/ +!/website/src/lib/ parts/ sdist/ var/ @@ -34,6 +35,8 @@ nosetests.xml test-results.xml coverage.xml coverage.json +t2-artifacts/ +t2-evidence/ *.cover *.py,cover .hypothesis/ @@ -231,6 +234,7 @@ _project/reports/worktree-lifecycle/ ci-log.txt /site/ +/make/inventory.json /.claude/worktrees # Node - declared in results-explorer/package.json; never vendor at repo root. @@ -245,3 +249,6 @@ node_modules/ /.agents/skills/ /.claude/skills/.system/ /.claude/skills/blog/ +/site-inventory/ +/website/.astro/ +/website/e2e-output/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b4097810..f47d225971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,235 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.2] - 2026-10-04 + +### Before you upgrade + +- **TPC-DS queries now use parameters for the data's scale factor.** Standard + TPC-DS runs at a scale factor other than 1 used to take their query + parameters from scale factor 1. They now use values generated for the scale + you run, so up to 12 queries can differ: Q9, Q44, Q46 and Q68 at 0.01 and + 0.1; ten queries at scale factor 10 (adds Q1, Q16, Q27, Q34, Q36, Q73); + and twelve at scale factor 100 and above (adds Q33, Q43). + Runs at scale factor 1, and power and throughput runs, are unchanged. Don't + compare these queries one-for-one with results recorded before this change + at other scale factors. +- **DataFrame runs now use the same query parameters as SQL runs.** TPC-DS + DataFrame runs bind the values dsqgen draws for the SQL power run of each + stream, instead of fixed defaults. Seeded TPC-H DataFrame runs bind the + values qgen draws for the stream's seed (`seed + 1000 * stream`); before, + every DataFrame run used qgen's defaults whatever `--seed` said. DataFrame + results record the bound parameter set in `config.query_parameters`. Don't + compare DataFrame results with 0.4.1 query by query. +- **TPC-H answer-file checks apply only to qgen's default parameters.** The + official answer files hold results for `qgen -d`, so exact row counts now + apply only to unseeded runs at scale factor 1. Seeded runs, including + `--seed 17039360`, check Q11, Q16, Q18 and Q20 against range bounds. Before, + a SQL power run with that seed reported false failures on those queries. +- **A failed query validation now fails the run.** This includes warm-up + queries. The result is no longer marked clean, and the CLI reports failure. + Query counts and timings still describe the measured executions only. +- **DataFrame platforms refuse `--phases throughput`.** Before, the run + silently repeated power iterations, ignored `--streams`, and reported the + throughput phase as not run. It now stops before generating data and + suggests `--phases power` or a SQL platform. +- **FlightData needs real data.** At scale factor 0.1 and above, FlightData + now fails when a BTS month can't be downloaded, instead of quietly using + synthetic rows. Set the `allow_synthetic_fallback` option to keep the old + behavior. Results record which months are real and which are synthetic. + BenchBox also skips the 1990-1999 months missing from the BTS archive and + uses older months instead. Existing FlightData caches lack the month + records, so the next run downloads and generates the data again. +- **Databricks turns off the result cache by default and checks it.** Each + session sets `use_cached_result = false` and reads the setting back. If the + warehouse doesn't confirm it, the connection fails rather than measuring + cached results, and a custom Spark setting that turns the cache back on is + rejected. To measure with the cache, set the `disable_result_cache` + platform option to `false`; the result then records that the cache was on. +- **Databricks resets tables in place.** When BenchBox rebuilds a schema, it + now empties and replaces the tables instead of dropping them, and every + `COPY INTO` forces a reload. Dropped tables count against the Unity Catalog + table quota for about seven days, so repeated runs could exhaust it. If the + in-place reset fails, the run now stops with the error instead of dropping + the schema. This change doesn't free quota already used; that frees itself + over time. +- **Power phase durations and cost per hour can change.** Each power-test + phase now records its wall-clock time, and cost per hour uses that time. + On platforms with overhead between queries, such as Snowflake, the figure + can differ from 0.4.1. Per-query latencies are unchanged. +- **Some results change because they were wrong before.** SQLite now computes + the TPC-H Q6 discount bounds exactly, so its Q6 answer matches other + engines. The write primitives catalog moves to version 2: batch inserts keep + their decimal scale on Snowflake and Databricks, and BigQuery, Snowflake, + and Databricks get their own SQL where needed. TPC-Havoc Q16-Q18 DataFrame + variants now run real query rewrites instead of replaying the baseline + result, so their timings change. NYC Taxi on Polars now numbers weekdays + from Monday = 0, as documented. Many DataFusion DataFrame answers change; + see Fixed. +- **Stricter submissions and integrity checks.** A TPC-Havoc submission must + include every variant, each with a successful measurement or a documented + skip. A result in which every query was skipped no longer passes integrity + certification. +- **Withdrawn cloud results.** Nine TPC-H results from the first BigQuery, + Databricks, and Snowflake runs were incorrect. They are removed from + Results Explorer and comparisons. + +### Added + +- **ParadeDB and Citus platforms (experimental).** Use `--platform paradedb` + or `--platform citus` to benchmark these PostgreSQL extensions. On Citus, + the `distribution_column` option distributes benchmark tables across + workers. +- **DuckLake on Google Cloud Storage and Azure.** DuckLake's data path now + accepts `gs://`, `gcs://`, `az://`, `azure://`, and `abfss://` locations as + well as S3. +- **NYC Taxi For-Hire Vehicle data.** NYC Taxi can load FHV trip data and run + three FHV queries. Yellow taxi data remains the default. +- **All 113 Join Order Benchmark queries in the synthetic suite.** + `joinorder_synthetic` grows from 13 to 113 queries, each with pandas and + Polars versions. +- **More tuning templates.** BenchBox ships checked Snowflake tuning templates + for TPC-H and TPC-DS. SSB joins the tuning profile, and the DuckDB SSB + template adds sorting on `LO_CUSTKEY`. +- **Render TPC-DS queries from chosen values.** + `DSQGenBinary.generate_with_parameters` now applies the values you pass. + Before, it had no effect. `DSQGenBinary.generate_parameter_log` returns the + values dsqgen substituted for a given seed and scale. BenchBox also ships + the qualification values from Appendix B of the TPC-DS 4.0.0 specification + for all 99 queries, in + `benchbox/core/tpcds/dataframe_queries/qualification_values.json`. + +### Changed + +- **Vector Search is stable.** It now has an integrity specification. It + remains SQL-only. +- **Faster loads.** BigQuery loads through Cloud Storage now submit one load + job per table instead of one per file. ClickHouse Cloud loads each table's + staged files with one `INSERT`. PostgreSQL streams a table's files that + share a format through one `COPY` session, and a failure rolls back that + group of files together. Snowflake no longer retries optional + query-history lookups, which removes a delay after each query. +- **TPC-Havoc runs on more engines.** Variants that were skipped now run + through dialect rewrites on BigQuery, Databricks, Snowflake, + PostgreSQL-family engines, ClickHouse, and DataFusion. A TPC-Havoc + `--dry-run` saves the same SQL a live run would use, and fails on an invalid + query selection instead of saving an empty preview. +- **TPC-DS Q90 returns NULL when the PM count is zero.** Every SQL dialect and + both DataFrame families now guard this division the same way. +- **Faster pandas TPC-DS Q78.** It takes about 0.3 seconds at scale factor 0.1, + down from about 14 seconds. + +### Fixed + +- **TPC-DS DataFrame answers match SQL.** All 103 TPC-DS statements (the 99 + templates plus the second statements of Q14, Q23, Q24 and Q39) now return + the same answers as DuckDB SQL on Polars, pandas and DataFusion. The fixes + cover NULL handling in sorts, grouping keys, sums, averages, ranks and set + operations; ROLLUP subtotal order in Q36 and Q86; half-up rounding and + returned sales in Q78; per-item averages in Q32 and Q92; and parameters + that implementations ignored, such as the aggregate functions in Q25 and + Q35. Brand names containing `#` no longer break Q53 and Q63. The second + statements of Q14, Q23, Q24 and Q39 are new on the DataFrame surface. +- **DataFusion DataFrame results were wrong for some expressions.** An + aggregate over an expression, such as `sum(price * (1 - discount))`, was + computed over the bare column, and a CASE over aggregates was rewritten + into arithmetic. Integer division returned an integer, `rank()` without + `.over()` returned the input values, and ROLLUP levels were combined out of + column order. DataFusion now plans these expressions itself. This fixes + TPC-H Q1, Q3, Q5, Q10, Q14 and Q15 on DataFusion, and many TPC-DS queries. +- **DataFrame runs no longer crash when two BenchBox processes share the + Parquet cache.** The cache was rewritten in place, so a run that loaded data + while another run was scanning it could crash that run with a Polars + `Invalid argument (os error 22)` panic, a SIGBUS or a "must end with PAR1" + error. Cache files and manifests are now written to a temporary file and + renamed into place. Routine pruning preserves writer files regardless of age; + an explicit cache clear removes files abandoned by a crashed run. +- **Dask TPC-DS queries order NULLs like the reference.** Q19, Q34, Q71 and Q73 + sort one key ascending and another descending. On `dask-df` every key put + NULLs last, so rows with a NULL in a descending key were out of order. They + now match the other DataFrame platforms. +- **Anonymized results hide more cloud identifiers.** Databricks SQL + warehouse IDs, BigQuery datasets, Unity Catalog catalog, schema and volume + names, and saved cloud output locations are now replaced. Before, they + could appear in anonymized result files. +- **TPC-DS One Big Table on cloud platforms.** On Snowflake and BigQuery, the + benchmark could run all its queries against an empty table and report + success. It now loads the table, and its source DDL translates correctly for + cloud dialects. +- **Generated data reaches cloud staging.** AI Primitives, Data Vault, + TPC-DS OBT, transaction and write primitives, TPC-DI, TPC-H Skew, and TSBS + no longer drop the cloud upload target and generate data only locally. +- **Data Vault and TSBS DevOps on cloud platforms.** Their queries translate + to Snowflake, BigQuery, and Databricks SQL. Data Vault now runs on BigQuery, + and 16 TPC-Havoc variants that failed to compile there now work. +- **Write and transaction primitives on cloud platforms.** Write primitives + run on BigQuery, Snowflake, and Databricks, including SCD2 staging on + Databricks. Transaction primitives run on Snowflake, BigQuery, and + Databricks, with documented skips for features those engines lack. Trino + skips sketch operations it doesn't support. +- **Read and metadata primitives on cloud platforms.** Read primitives have + Snowflake and BigQuery versions of queries those engines rejected. Metadata + primitives run their EXPLAIN and constraint queries on Snowflake. Queries + that Databricks can't run are skipped with a reason. +- **Other platforms.** Exasol no longer quotes every identifier, so + BenchBox's own SQL finds its tables. Presto and Trino load files with the + delimiter the dataset declares rather than guessing from the file name. +- **TPC-H Q17 on empty input.** It returns one NULL row on Polars, + DataFusion, PySpark, and LakeSail, as SQL does. +- **SQLite throughput streams.** Concurrent streams running the same query + no longer read each other's rows. +- **`--show-plans` prints query plans.** The flag was accepted but never + reached the platform adapter. +- **Tuning wizard saves table layouts.** Clustering, partitioning, + distribution, and sort-key choices for Snowflake, BigQuery, Redshift, + DuckDB, and ClickHouse were confirmed but not saved. They are now saved. +- **Data generation and loading.** Freshly generated, sharded Read Primitives + data now loads on the first run. TPC-H Skew no longer runs out of memory at + scale factor 10 on a 16 GB machine. Metadata Primitives no longer tries to + load data files it doesn't create. Data Vault ETL reads compressed source + files without parallel scans, which compressed files can't support. +- **Working directory restored after library checks.** A failed native import + during dependency checks, including chDB, could leave BenchBox in another + directory and break later file reads. +- **Results Explorer.** The in-browser database recovers from startup buffer + errors by switching to a single-threaded mode. Comparison links that name + missing results no longer stall the page. Navigation highlights the right + section on local and run pages. +- **Runs fail when no data loads.** A stale or empty datagen manifest, or a + missing `zstd` command, used to leave the loader with nothing to load, so + every table held zero rows and the run still validated and exported a + result. Loading now fails when the data source names no tables, and + validation fails when no table loads a positive number of rows. TPC-DS + generation no longer writes a manifest that lists no tables, and a manifest + with no tables or no files is rejected and regenerated. A missing `zstd` + command is reported before any table loads. +- **Repeated no-tuning runs reuse their database.** Every second no-tuning run + rebuilt the database because validation asked for tuning metadata that + no-tuning runs never write. +- **The ORDER BY equivalence check fails closed.** When a query's sort terms + could not be evaluated over the returned rows, the check passed. It now + reports the cell as divergent. Queries with no ORDER BY, SQL that does not + parse, and terms with no output column keep the unordered comparison. +- **TPC-DS data generation on Windows honours its options.** The Windows + `dsdgen` ignored `-` options, so a request for scale factor 0.01 generated + scale factor 1 and ignored table and chunk selection. BenchBox now passes + the `/` prefix on Windows. Output on other platforms is unchanged. +- **TPC-DS OBT loads its queries from an installed wheel.** `get_queries()` + raised `FileNotFoundError` from the 0.4.1 wheel because the templates were + read from a source tree the wheel does not ship. +- **TPC-H addresses on macOS match the reference data.** The bundled macOS + `dbgen` was built with optimization and produced different `S_ADDRESS` and + `C_ADDRESS` values from the reference data at every scale factor, so the + correctness gate failed Q2, Q10 and Q15. The rebuilt binaries produce the + reference values. Regenerate TPC-H data made on macOS with earlier versions. +- **Tuning output describes what happened.** The console no longer prints + "Unified tuning configuration applied" or "Tuning metadata saved" unless + the work succeeded, and it reports how many statements ran, failed or were + dropped. A fresh database with no benchmark tables no longer logs a tuning + metadata error. The Results Explorer reads "Checked; not corroborated" when + a run carries a post-load check receipt, and its Tuned badges follow the + recorded validation status. + ## [0.4.1] - 2026-09-24 ### Before you upgrade @@ -838,7 +1067,8 @@ benchbox run --platform polars-df --benchmark tpch --scale 0.01 - **Discussions**: [Ask questions and request features](https://github.com/BenchBox-dev/BenchBox/discussions) - **PyPI**: [pypi.org/project/benchbox](https://pypi.org/project/benchbox/) -[Unreleased]: https://github.com/BenchBox-dev/BenchBox/compare/v0.4.1...HEAD +[Unreleased]: https://github.com/BenchBox-dev/BenchBox/compare/v0.4.2...HEAD +[0.4.2]: https://github.com/BenchBox-dev/BenchBox/compare/v0.4.1...v0.4.2 [0.4.1]: https://github.com/BenchBox-dev/BenchBox/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/BenchBox-dev/BenchBox/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/BenchBox-dev/BenchBox/compare/v0.3.0...v0.3.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3717e2f6c1..77afe2a2e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,24 +18,27 @@ This document provides guidelines and instructions for contributing. ### Setting Up Your Environment 1. Clone the repository: + ```bash git clone https://github.com/BenchBox-dev/BenchBox.git cd BenchBox ``` 2. Install the package in development mode: + ```bash make develop # equivalent to: uv sync --group dev ``` 3. Install the pre-commit + pre-push hooks: + ```bash pre-commit install ``` This installs two hooks (configured in `.pre-commit-config.yaml`): - **pre-commit**: ruff format/check, codespell, YAML / markdown lint, timing-policy check. - - **pre-push**: `pr-preflight-fast-tests` — when `BENCHBOX_PREPUSH=1`, runs the path-aware fast-test lane so code PR pushes don't discover failures via the CI roundtrip. + - **pre-push**: `pr-preflight` — runs `make pr-preflight` (ruff on the changed Python files and the tests mapped from them) before each push. Existing clones that installed hooks before the pre-push stage was added should re-run `pre-commit install` once. If `pre-commit install` errors with `Cowardly refusing to install hooks with core.hooksPath set` and points at the default `.git/hooks` location, run `git config --unset-all core.hooksPath` and try again — that's a redundant override left over from earlier tooling. @@ -44,11 +47,11 @@ This document provides guidelines and instructions for contributing. `develop` is the long-lived development branch and the repository's default branch; **all changes land via PR**. `release` is release-only (handled by the version-branch flow — see `docs/operations/release-guide.md`). PRs target `develop` and squash-merge with linear history. -Required CI on `develop` reports through `ci-required-result`. The umbrella uses `.github/path-filters.yml` to classify each PR: content-only PRs run content validation and skip Python fast tests, while code, infra, workflow, tooling, and unknown paths run the post-Step-3 lint/type + Ubuntu 3.12 fast-test gate. Reviews are not required for solo-dev work; once a finished branch is armed, auto-merge lands it when required checks are green. +Required CI on `develop` reports six unit results (`core`, `explorer`, `results-data`, `docs`, `landing`, `tooling`) from `.github/workflows/ci.yml`, plus `oracle-review`, which passes on a result-affecting PR only when the Codex connector app has reviewed or thumbed up its current head (or, when the connector cannot review, a listed attester has posted the stand-in approval for that head) and its threads are resolved. `.github/ci-units.yml` and `.github/path-filters.yml` classify each PR: content-only PRs run content validation and skip Python fast tests, while code, infra, workflow, tooling, and unknown paths run the lint/type + Ubuntu 3.12 fast-test checks. Soundness-path review applies even when the author uses the owner's account; once a finished branch is armed, auto-merge lands it when required checks are green and all review threads are resolved. ## Development Workflow -The canonical loop is **branch → edit → preflight → `make pr-open` → (when final) arm**. `make pr-open` **withholds** auto-merge by default so follow-up commits cannot race a half-pushed stack. When the branch is finished, arm with `make pr-ready`; `make pr-open READY=1` may arm only when it reuses an already-open, reviewed PR. A newly created PR remains held until review; then walk away — don't poll. +The canonical loop is **branch → edit → preflight → `make pr-open` → arm → monitor to merge**. Once the branch is finished, arm it with `make pr-arm`, which checks the live PR for a hold and arms auto-merge on the exact head; GitHub merges it when its required checks are green. After the merge, `trunk.yml` tests `develop`; if it fails because of your change, revert first with `make trunk-revert PR=`. You are done when the PR is merged, not when it is open or green: re-arm (`make pr-arm` again) after a spurious failure and fix and push after a real failure. Stop and hand back only for the exceptions listed in `AGENTS.md` `[WRITE-CLOSEOUT-001]`. 1. **Create a feature worktree off `develop`.** Agents must keep the main clone read-only: @@ -78,30 +81,30 @@ The canonical loop is **branch → edit → preflight → `make pr-open` → (wh git commit -m "fix: resolve race in foo loader" ``` -4. **Run the local preflight, then open the PR (auto-merge withheld):** +4. **Run the local preflight, then open the PR:** ```bash - make pr-preflight # local lint + path-aware content guard / fast tests - make pr-open # push + gh pr create --base develop (does NOT arm auto-merge) + make pr-preflight + make pr-open ``` - `make pr-open` refuses to run from `develop` or `release`. The PR stays open without auto-merge so you can push follow-ups safely. + `make pr-open` refuses to run from `develop` or `release`. -5. **When the branch is final, run the readiness transaction** (hands-free finish path): +5. **Arm it and monitor until it merges:** ```bash - make pr-ready PR=123 HEAD=$(git rev-parse HEAD) EVIDENCE=/tmp/readiness.json - # Or reuse an already-open, reviewed PR and arm it in one step: - # make pr-open READY=1 EVIDENCE=/tmp/readiness.json + make pr-arm # PR= optional; refuses on a hold label, requested changes, a draft, or a head that is not pushed ``` - The evidence file must declare `delivery_mode` (`serial` or `batch`); batch - mode must include the complete prepared-batch binding. `make pr-ready` (or - `READY=1` while reusing an already-open, reviewed PR) is the only arm path: - `auto-merge-on-open.yml` is revoke-only and never arms. Once the exact readiness transaction passes, the PR - squash-merges when required checks turn green — don't poll. - Soundness-critical paths and the `no-auto-merge` hold label stay withheld - pending review (see `docs/operations/repo-admin-settings.md`). + `make pr-arm` reads the live PR first, so a `no-auto-merge` label or a requested change stops it instead of + being mistaken for a failing check; remove a hold deliberately to release it. Before editing an armed PR, + withdraw it with the revision transaction (`make pr-landing-withdraw`, see `docs/agent/review-protocol.md`), + push the correction, rerun CI and obtain the connector's review or thumbs-up (or the stand-in approval) on the new head before arming: + a later `--match-head-commit` cannot undo a merge of the old head. + `make pr-ready PR= HEAD=` arms an open PR through `make pr-arm`. With `EVIDENCE` or `BATCH` it runs the + readiness evidence transaction used to deliver a prepared batch, where the evidence file must declare + `delivery_mode` and the complete prepared-batch binding; a single PR does not need it. + `auto-merge-on-open.yml` only revokes and never arms (see `docs/operations/repo-admin-settings.md`). 6. **After merge**, remove the clean linked worktree. The remote branch normally auto-deletes through the repository setting; sweep stale local branches separately: @@ -123,7 +126,9 @@ There are two layers, and you only need the first: make pr-preflight ``` -This runs the local lint/type gate, then uses `.github/path-filters.yml` to decide the fast-test lane. Content-only branches run the cheap content guard and skip Python fast tests; code, infra, workflow, tooling, and unknown paths run the fast tests. This mirrors the `ci-required-result` umbrella, so a green preflight almost always means a green CI. The opt-in pre-push git hook runs the same path-aware test portion when `BENCHBOX_PREPUSH=1`. +This runs `ruff check` and `ruff format --check` on the Python files you changed, then runs the tests for them without xdist, so it does not wait on the shared test lock. A changed `tests/**/test_*.py` runs itself; a changed `benchbox/**/.py` or `scripts/.py` runs every `tests/**/test_.py`. Tests that failed in the previous run go first (`--ff`), then the rest run. If no test maps to your change it runs no tests and says CI will run the fast lane. + +It does not run the full fast lane, type checking, or the other lint guards. CI runs all of them on every PR within about 20 minutes and is the authoritative check. To run the full fast lane before pushing, use `make pr-preflight-fast-tests`; to run the CI lint guards use `make ci-lint`. **Optional thoroughness check** — only useful when you've changed cross-cutting things (CI workflows, packaging, docs build, integration paths): @@ -134,16 +139,16 @@ make ci-local This runs the broader CI mirror: | Step | Target | -|---|---| +| --- | --- | | Lint + format + type checking | `make ci-lint` | | Fast tests with coverage | `make ci-test` | | Integration smoke tests | `make test-integration-smoke` | | Documentation build | `make ci-docs` | | Package build + install test | `make test-package` | -Or run any of those individually. Additional one-offs: `make security-audit`, `make spellcheck`, `make docstring-coverage`. +Or run any of those individually. Additional one-offs: `make security-audit` and `make spellcheck`. -Skip `make ci-local` for everyday changes — `make pr-preflight` is the right gate. Once the PR is armed, auto-merge still blocks on any non-required check failure that *is* surfaced (e.g. doc build), so the cost of being wrong is just a re-push (and re-arm with `make pr-ready` if needed). +Skip `make ci-local` for everyday changes — `make pr-preflight` is the right gate. The required checks gate the merge, so the cost of being wrong is a re-push and `make pr-arm` for the new head. ## Testing diff --git a/MANIFEST.in b/MANIFEST.in index b252dda9d5..c8657bd9c0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -27,6 +27,7 @@ recursive-include _sources/tpc-h/dbgen/variants *.sql # Binaries are included inside the benchbox package for proper installation recursive-include benchbox/_binaries/tpc-h * recursive-include benchbox/_binaries/tpc-ds * +include benchbox/_binaries/SHA256MANIFEST.json # Exclude macOS metadata files global-exclude .DS_Store @@ -47,6 +48,7 @@ prune .mypy_cache prune .ruff_cache prune htmlcov prune landing +prune website prune scripts exclude .pre-commit-config.yaml exclude .coveragerc_core @@ -65,3 +67,4 @@ exclude pytest-ci.ini exclude Makefile global-exclude *.py[cod] __pycache__ *.so *.dylib *.dll *.log +prune deploy diff --git a/Makefile b/Makefile index bbd15b2b7d..ecd43293c1 100644 --- a/Makefile +++ b/Makefile @@ -36,17 +36,18 @@ DEVELOPMENT_TREE_ONLY_TARGETS := \ correctness-gate-digests-regen cross-surface-baseline-autodetect \ oracle-coverage-map oracle-coverage-map-check cross-surface-applicability-report \ joinorder-verify-reference-results complexity-check complexity-report \ - quality-governance-typecheck uv-lock-revision-check audit-deps audit-raw audit-raw-check \ + quality-governance-typecheck uv-lock-revision-check sqlglot-repro-retirement-check audit-deps audit-raw audit-raw-check \ audit-sha-check lint-explorer-tokens lint-site-theme-tokens lint-explorer-stale-theme \ explorer-snapshot-check artifact-hygiene agent-instructions-check agent-identity-check security-audit \ - agent-commit-range-check skill-integrity-check ci-lint pr-arm-auto-merge shrink-rollup \ + agent-commit-range-check skill-integrity-check ci-lint pr-preflight pr-arm-auto-merge shrink-rollup \ pr-review-followups-list pr-review-followups dev-loop-metrics platform-manifest \ platform-manifest-check test-docker-parity blind-spots-list blind-spots-report \ soundness-drain-report soundness-drain-self-test worktree-audit worktree-finish -.PHONY: test test-unit test-integration test-tpch test-all test-fast test-unlock test-medium test-slow test-stress test-pytest clean lint lint-markers lint-imports lint-explorer-tokens lint-site-theme-tokens artifact-hygiene agent-instructions-check agent-identity-check agent-commit-range-check audit-sha-check agent-write-preflight install develop coverage coverage-fast coverage-all coverage-opt-in-all coverage-html coverage-report coverage-check test-duckdb test-sqlite test-read-primitives test-benchmarks test-ci typecheck quality-governance-typecheck validate-imports catalog-schema-check format dependency-check docs-build docs-serve docs-clean docs-linkcheck docs-validate docs-check docs-images test-pyspark ci-lint ci-test ci-docs ci-local security-audit spellcheck docstring-coverage test-package test-integration-smoke test-correctness-gate plan-capture-gate correctness-gate-digests-regen test-local-matrix joinorder-verify-reference-results complexity-check complexity-report duplicate-check duplicate-check-verbose duplicate-check-json duplicate-check-delta makefile-inventory-check skill-sync skill-sync-check mutation-test tpchavoc-equivalence-report tpchavoc-equivalence-report-postgres tpchavoc-equivalence-report-datafusion tpchavoc-equivalence-report-clickhouse tpchavoc-dataframe-equivalence-report ssb-cross-surface-equivalence-report amplab-cross-surface-equivalence-report coffeeshop-cross-surface-equivalence-report clickbench-cross-surface-equivalence-report joinorder-synthetic-cross-surface-equivalence-report h2odb-cross-surface-equivalence-report read-primitives-cross-surface-equivalence-report cross-surface-update-baseline cross-surface-baseline-autodetect oracle-coverage-map oracle-coverage-map-check cross-surface-applicability-report compile-tpcds-binaries parity-fixtures parity-check compat-docs compat-docs-check query-docs platform-manifest platform-manifest-check pricing-data pricing-data-check pr-preflight pr-preflight-fast-tests pr-preflight-medium-tests pr-content-guard pr-open pr-ready pr-arm-auto-merge pr-status pr-review-followups pr-review-followups-list dev-loop-metrics shrink-rollup worktree-create worktree-remove worktree-list worktree-audit local-validation local-validation-show local-validation-path +.PHONY: coverage-check # Primary test commands using pytest marker system +.PHONY: test test: test-fast @echo "Default test run completed. Use 'make help' to see all test options." @@ -68,37 +69,44 @@ publication-help: @echo " 4. Approve the github-pages environment once; the workflow validates and records the result." @echo " Retry with a new transaction run against the same artifact after a pre-write failure." +.PHONY: test-all test-all: @echo "Running non-resource-heavy tests in parallel..." - uv run -- python -m pytest -m "not (slow or stress or resource_heavy or live_integration)" --timeout=300 + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest -m "not (slow or stress or resource_heavy or live_integration)" --timeout=300 @echo "Running slow and resource-heavy tests serially..." - uv run -- python -m pytest -m "(slow or resource_heavy) and not (stress or live_integration)" -n 0 --timeout=1200 + BENCHBOX_TEST_TIER=t3 uv run -- python -m pytest -m "(slow or resource_heavy) and not (stress or live_integration)" -n 0 --timeout=1200 +.PHONY: test-unit test-unit: - uv run -- python -m pytest -m "unit" --tb=short + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest -m "unit" --tb=short +.PHONY: test-integration test-integration: - uv run -- python -m pytest -m "integration and not live_integration and not stress" --tb=short + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest -m "integration and not live_integration and not stress" --tb=short +.PHONY: test-tpch test-tpch: - uv run -- python -m pytest -m "tpch" --tb=short + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest -m "tpch" --tb=short # Curated lightweight smoke lane test-quick: - uv run -- python -m pytest -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --maxfail=5 --timeout=120 + BENCHBOX_TEST_TIER=t1 uv run -- python -m pytest -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --maxfail=5 --timeout=120 # Verbose test output for all tests test-verbose: - uv run -- python -m pytest -v + BENCHBOX_TEST_TIER=t3 uv run -- python -m pytest -v # Enhanced pytest commands using comprehensive marker system +.PHONY: test-pytest test-pytest: - uv run -- python -m pytest -m "not stress" + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest -m "not stress" # Speed-based testing +.PHONY: test-fast test-fast: - uv run -- python -m pytest -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=120 + BENCHBOX_TEST_TIER=t1 uv run -- python -m pytest -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=120 +.PHONY: test-unlock test-unlock: @LOCK_DIR="$${BENCHBOX_TEST_LOCK_DIR:-$$HOME/.benchbox}"; \ case "$$LOCK_DIR" in \ @@ -108,18 +116,31 @@ test-unlock: LOCK_PATH="$$LOCK_DIR/test.lock"; \ python3 scripts/local_validation.py clear-test-lock "$$LOCK_PATH" +.PHONY: test-medium test-medium: - uv run -- python -m pytest -m "medium and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=60 -n 5 + BENCHBOX_TEST_TIER=t2 uv run -- python -m pytest -m "medium and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=60 -n 5 +.PHONY: test-medium-selected +test-medium-selected: + @set -eu; \ + OUTPUT=$$(mktemp); \ + trap 'rm -f "$$OUTPUT"' EXIT; \ + BENCHBOX_TEST_TIER=t2 uv run -- python scripts/canary_impact.py \ + --changed-json-env BENCHBOX_MEDIUM_CHANGED_PATHS_JSON \ + --marker-expression "medium and not (slow or stress or resource_heavy or live_integration)" --cant-affect-list empty \ + --product-code-only --run-selected --output "$$OUTPUT" + +.PHONY: test-slow test-slow: - uv run -- python -m pytest -m "slow and not (stress or live_integration)" -n 0 --tb=short -v --timeout=1200 + BENCHBOX_TEST_TIER=t3 uv run -- python -m pytest -m "slow and not (stress or live_integration)" -n 0 --tb=short -v --timeout=1200 +.PHONY: test-stress test-stress: - uv run -- python -m pytest -m "stress" -n 0 --tb=short -v --timeout=1800 + BENCHBOX_TEST_TIER=t3 uv run -- python -m pytest -m "stress" -n 0 --tb=short -v --timeout=1800 # Development cycle testing using the curated fast unit subset test-dev: - uv run -- python -m pytest -m "fast and unit and not (slow or stress or resource_heavy or live_integration)" --tb=short --maxfail=3 --timeout=120 + BENCHBOX_TEST_TIER=t1 uv run -- python -m pytest -m "fast and unit and not (slow or stress or resource_heavy or live_integration)" --tb=short --maxfail=3 --timeout=120 # Smoke tests (alias for test-quick) test-smoke: test-quick @@ -154,6 +175,7 @@ test-smoke: test-quick # from the same query set it is asserted against -- never re-hardcode the list). CORRECTNESS_GATE_QUERY_IDS := 1,2,3,4,5,6,7,8,9,10,12,13,14,15,17,19,21,22 +.PHONY: test-correctness-gate test-correctness-gate: @REPORT="$$(mktemp)"; \ BENCHBOX_STRICT_EXPECTED_RESULTS=1 BENCHBOX_EMIT_RESULT_DIGEST=1 BENCHBOX_CORRECTNESS_GATE_QUERY_IDS=$(CORRECTNESS_GATE_QUERY_IDS) uv run -- python -m pytest -m stress "tests/integration/test_local_platform_benchmark_matrix.py::test_local_platform_benchmark_matrix[tpch-duckdb]" -n 0 --tb=short --timeout=1200 -v --junitxml="$$REPORT"; \ @@ -163,6 +185,7 @@ test-correctness-gate: rm -f "$$REPORT"; \ test $$PYTEST_STATUS -eq 0 && test $$GUARD_STATUS -eq 0 +.PHONY: plan-capture-gate plan-capture-gate: uv run -- python -m pytest tests/integration/test_plan_capture_gate.py -q -n 0 --tb=short @@ -177,12 +200,14 @@ plan-capture-gate: # DuckDB build in uv.lock (stamped into the file's provenance). Run this whenever the # digest normalization changes or DuckDB is bumped, in the SAME change, or # `make test-correctness-gate` goes RED on a correct tree. +.PHONY: correctness-gate-digests-regen correctness-gate-digests-regen: BENCHBOX_CORRECTNESS_GATE_QUERY_IDS=$(CORRECTNESS_GATE_QUERY_IDS) uv run -- python _project/scripts/regenerate_correctness_gate_digests.py # Gate: compare every TPC-Havoc SQL variant to canonical TPC-H on real SF=0.1 # DuckDB data; exits non-zero on any divergence beyond KNOWN_DIVERGENCES # (see benchbox/core/tpchavoc/equivalence.py). +.PHONY: tpchavoc-equivalence-report tpchavoc-equivalence-report: uv run -- python -m benchbox.core.tpchavoc.equivalence @@ -193,6 +218,7 @@ tpchavoc-equivalence-report: # container; override via PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE). Skips # cleanly (exit 0) if no server is reachable. The DuckDB gate above stays the # hard blocker; this is a non-blocking sample (see equivalence.py). +.PHONY: tpchavoc-equivalence-report-postgres tpchavoc-equivalence-report-postgres: uv run -- python -m benchbox.core.tpchavoc.equivalence --engine postgres @@ -203,6 +229,7 @@ tpchavoc-equivalence-report-postgres: # in-process (no service container); skips cleanly (exit 0) only if DataFusion is # not installed. The DuckDB gate above stays the hard blocker; this is a # non-blocking sample with a DIFFERENT gap profile from Postgres (see equivalence.py). +.PHONY: tpchavoc-equivalence-report-datafusion tpchavoc-equivalence-report-datafusion: uv run -- python -m benchbox.core.tpchavoc.equivalence --engine datafusion @@ -218,12 +245,14 @@ tpchavoc-equivalence-report-datafusion: # is in-process (no service container); skips cleanly (exit 0) only if chDB is not # installed. The DuckDB gate above stays the hard blocker; this is a non-blocking # sample (see equivalence.py). +.PHONY: tpchavoc-equivalence-report-clickhouse tpchavoc-equivalence-report-clickhouse: uv run -- python -m benchbox.core.tpchavoc.equivalence --engine clickhouse # Gate: compare every TPC-Havoc DataFrame variant (both backends) to canonical # TPC-H on real SF=0.1 DuckDB-backed data; exits non-zero on any divergence # beyond KNOWN_DIVERGENCES (see benchbox/core/tpchavoc/dataframe_equivalence.py). +.PHONY: tpchavoc-dataframe-equivalence-report tpchavoc-dataframe-equivalence-report: uv run -- python -m benchbox.core.tpchavoc.dataframe_equivalence @@ -232,26 +261,31 @@ tpchavoc-dataframe-equivalence-report: # divergence beyond the benchmark's baseline. SQL is the reference for its own # DataFrame surface, so no hand-curated answer key is needed (see # benchbox/core/equivalence/cross_surface.py). Currently gates ssb, amplab, and coffeeshop. +.PHONY: ssb-cross-surface-equivalence-report ssb-cross-surface-equivalence-report: uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark ssb +.PHONY: amplab-cross-surface-equivalence-report amplab-cross-surface-equivalence-report: uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark amplab +.PHONY: coffeeshop-cross-surface-equivalence-report coffeeshop-cross-surface-equivalence-report: uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark coffeeshop # Enforced gate: clickbench SQL<->DataFrame equivalence on a bounded DuckDB cell. -# In GATES (datavault remains staged) and run in the blocking correctness-gate (pr.yml); +# In GATES (datavault remains staged) and run in the blocking correctness-gate (ci.yml); # exits non-zero on any unclassified divergence. Q18's order-less LIMIT is the one # classified exception (see _project/analysis/clickbench-cross-surface-divergences.md). +.PHONY: clickbench-cross-surface-equivalence-report clickbench-cross-surface-equivalence-report: uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark clickbench # Enforced gate: joinorder_synthetic SQL<->DataFrame equivalence on a bounded DuckDB # cell. In GATES (datavault remains staged) and run in the blocking correctness-gate -# (pr.yml); exits non-zero on any unclassified divergence (see +# (ci.yml); exits non-zero on any unclassified divergence (see # _project/analysis/joinorder-synthetic-cross-surface-divergences.md). +.PHONY: joinorder-synthetic-cross-surface-equivalence-report joinorder-synthetic-cross-surface-equivalence-report: uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark joinorder_synthetic @@ -260,16 +294,21 @@ joinorder-synthetic-cross-surface-equivalence-report: # the shared SF=0.1 would emit ~1M rows). Q9's PERCENTILE_CONT carries one # classified DECIMAL(8,2)-scale exception (see cross_surface.py); every other cell # must match. Exits non-zero on any unclassified divergence. +.PHONY: h2odb-cross-surface-equivalence-report h2odb-cross-surface-equivalence-report: uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark h2odb # Enforced gate: Read Primitives DataFrame surface vs its DuckDB-dialect SQL -# surface on a bounded ~300k-row TPC-H cell (SF=0.05). 148 gateable queries (the 4 -# fulltext/json ids DuckDB cannot transpile are excluded). Every compared cell +# surface on a bounded ~300k-row TPC-H cell (SF=0.05). Exact accounting: 157 +# runtime SQL queries, 153 DuckDB-transpilable, 152 DataFrame queries, 148 +# gateable. Excluded: 4 DF-only fulltext/json ids DuckDB cannot transpile +# (classified exclusions); 5 SQL-only rows with no DF query (approx_quantiles_array, +# approx_top_k_lineitem, 3 optimizer_* rewrites). Every compared cell # matches; the classified cells are irreducible engine differences (HLL/T-Digest # approximation, DECIMAL-scale percentile/ROUND, ARG_MIN ties, JSON text, Polars # Map-dtype gap) plus legitimately-empty selective/no-JSON filters. Exits non-zero # on any unclassified divergence. +.PHONY: read-primitives-cross-surface-equivalence-report read-primitives-cross-surface-equivalence-report: uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark read_primitives @@ -289,11 +328,64 @@ flightdata-cross-surface-equivalence-report: datavault-cross-surface-equivalence-report: uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark datavault +# Enforced gate: NYC Taxi DataFrame surface vs its own SQL surface on a bounded +# offline synthetic cell (SF=0.01, 12k trips; the builder forces synthesis and +# fails on any network download). 25 SQL slugs map to DataFrame Q1..Q25; +# airport-trips is classified legitimately empty (synthetic rate_code_id=1 vs +# IN (2, 3) filter). Exits non-zero on any unclassified divergence. +nyctaxi-cross-surface-equivalence-report: + uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark nyctaxi + +# Enforced gate: TSBS DevOps DataFrame surface vs its own SQL surface on a +# bounded offline cell (SF=0.01). 18 SQL slugs map to DataFrame Q1..Q18; +# three threshold queries are classified legitimately empty (generator caps +# usage_user at ~55, floors available_percent at ~17). Exits non-zero on any +# unclassified divergence. +tsbs-devops-cross-surface-equivalence-report: + uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark tsbs_devops + +# Enforced gate: TPC-H Skew DataFrame surface vs its own SQL surface on a +# bounded deterministic cell (SF=0.01, seed 42). 22 SQL ids map to DataFrame +# Q1..Q22; Q8 is classified legitimately empty (no ECONOMY ANODIZED STEEL +# rows at any probed scale). Exits non-zero on any unclassified divergence. +tpch-skew-cross-surface-equivalence-report: + uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark tpch_skew + +# Enforced gate: TPC-H DataFrame surface vs its own SQL surface on a bounded +# cell (SF=0.01, unseeded-vs-unseeded). 22 SQL ids map to DataFrame Q1..Q22 +# with an empty baseline. Exits non-zero on any unclassified divergence. +tpch-cross-surface-equivalence-report: + uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark tpch + +# Enforced gate: all 103 TPC-DS statements on the default Power draw at +# SF=0.01. This target runs the expression family on Polars and native +# DataFusion; pandas runs as its own target so each CI step stays within +# budget. Exits non-zero on any unclassified divergence or vacuity change. +tpcds-cross-surface-equivalence-report: + uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark tpcds --backend expression --backend datafusion + +.PHONY: tpcds-pandas-cross-surface-equivalence-report +tpcds-pandas-cross-surface-equivalence-report: + uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark tpcds --backend pandas + +# Post-merge check (trunk.yml): every backend twice on three other Power draws, failing on a +# divergence or a cell whose outcome changes between runs. Vacuity is listed +# but only the default draw's classifications are enforced. +.PHONY: tpcds-cross-surface-draws-report +tpcds-cross-surface-draws-report: + @status=0; \ + for draw in "--power-stream 1" "--seed 42" "--seed 42 --power-stream 1"; do \ + uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark tpcds \ + --backend expression --backend pandas --backend datafusion --repeats 2 $$draw || status=1; \ + done; \ + exit $$status + # Maintenance writer (#903 follow-up): drop known-divergence baseline entries that # no longer reproduce for ONE gate, in a reviewed change. Explicit/operator-driven - # the blocking gate run never prunes; only writes when the run is otherwise fully # clean, and is idempotent on a second run. Usage: # make cross-surface-update-baseline BENCHMARK=h2odb +.PHONY: cross-surface-update-baseline cross-surface-update-baseline: @test -n "$(BENCHMARK)" || { echo "Usage: make cross-surface-update-baseline BENCHMARK="; exit 1; } uv run -- python -m benchbox.core.equivalence.cross_surface --benchmark $(BENCHMARK) --update-baseline @@ -304,6 +396,7 @@ cross-surface-update-baseline: # EXISTING --update-baseline writer above for any gate with a resolved entry. # Local dry-run for what .github/workflows/cross-surface-baseline-autodetect.yml # runs on a schedule; safe to run any time (a no-op when nothing is resolved). +.PHONY: cross-surface-baseline-autodetect cross-surface-baseline-autodetect: uv run -- python _project/scripts/cross_surface_baseline_autodetect.py \ --json-out _project/cross-surface-baseline-autodetect/summary.json @@ -311,25 +404,46 @@ cross-surface-baseline-autodetect: # Regenerate the benchmark correctness-oracle coverage map (which oracle, if any, # guards each shipped benchmark). Derived from the registry + provider/gate # registries; commit the refreshed _project/analysis/ artifacts. +.PHONY: oracle-coverage-map oracle-coverage-map: uv run -- python _project/scripts/generate_oracle_coverage_map.py # Advisory CI guard: fail if the checked-in coverage map is stale (e.g. a new # benchmark was added without regenerating, hiding an UNGUARDED surface). +.PHONY: oracle-coverage-map-check oracle-coverage-map-check: uv run -- python _project/scripts/generate_oracle_coverage_map.py --check # Cross-surface applicability drill-down (report mode): which dual-surface # unguarded benchmarks actually ship comparable DataFrame queries (cross-surface # gateable) vs which only support DataFrame loading (need a w2 fallback oracle). +.PHONY: cross-surface-applicability-report cross-surface-applicability-report: uv run -- python _project/scripts/cross_surface_applicability_sweep.py # Real benchmark matrix across local SQL platforms x all benchmarks (heavy, opt-in) +.PHONY: test-local-matrix test-local-matrix: uv run -- python -m pytest tests/integration/test_local_platform_benchmark_matrix.py -m stress -n 0 --tb=short -v @echo "Tip: set BENCHBOX_SERVICE_LOCAL_MATRIX=1 to include Trino/Presto/Firebolt/PostgreSQL/TimescaleDB service-backed locals." +.PHONY: test-required-local-cases +test-required-local-cases: + @set -eu; \ + DIR=$$(mktemp -d); \ + trap 'rm -rf "$$DIR"' EXIT; \ + SHA=$$(git rev-parse HEAD); \ + uv run -- python -c "from tests.required_local_cases import REQUIRED_LOCAL_CASES; print('\n'.join(REQUIRED_LOCAL_CASES))" > "$$DIR/required.txt"; \ + set -- $$(cat "$$DIR/required.txt"); \ + STATUS=0; \ + uv run -- python -m pytest -m "" -n 0 --tb=short --timeout=1200 -v -p scripts.pytest_shard_evidence \ + --basetemp "$$DIR/basetemp" --assigned-nodeids "$$DIR/required.txt" --shard-evidence "$$DIR/evidence.json" \ + --checked-sha "$$SHA" "$$@" || STATUS=$$?; \ + uv run -- python scripts/release_canary_sharding.py verify-required --evidence "$$DIR/evidence.json" \ + --nodeids "$$DIR/required.txt" --checked-sha "$$SHA" || STATUS=$$?; \ + exit $$STATUS + +.PHONY: joinorder-verify-reference-results joinorder-verify-reference-results: @[ -n "$(JOINORDER_POSTGRES_CONTAINER)" ] || { echo "JOINORDER_POSTGRES_CONTAINER is required"; exit 2; } uv run -- python _project/scripts/build_joinorder_data.py verify-reference-results \ @@ -341,19 +455,24 @@ joinorder-verify-reference-results: --reference "$(JOINORDER_REFERENCE)" # Database-specific testing +.PHONY: test-duckdb test-duckdb: uv run -- python -m pytest -m "duckdb" --tb=short +.PHONY: test-sqlite test-sqlite: uv run -- python -m pytest -m "sqlite" --tb=short +.PHONY: test-pyspark test-pyspark: ./scripts/run_pyspark_tests.sh # Benchmark-specific testing +.PHONY: test-read-primitives test-read-primitives: uv run -- python -m pytest -m "primitives" --tb=short +.PHONY: test-benchmarks test-benchmarks: uv run -- python -m pytest -m "tpch or tpcds or ssb or amplab or clickbench or h2odb or merge" --tb=short @@ -369,6 +488,7 @@ test-window: # CI/CD testing # Maintained broad local CI profile (literal root-text compatibility contract). +.PHONY: test-ci test-ci: uv run -- python -m pytest -c pytest-ci.ini -m "not (slow or stress or resource_heavy or live_integration)" --cov=benchbox --cov-report=term-missing:skip-covered --cov-report=xml:coverage.xml --cov-fail-under=0 --timeout=300 @@ -390,45 +510,57 @@ test-parallel-fast: include $(BENCHBOX_MAKEFILE_ROOT)make/platform-tests.mk # Coverage commands using pytest +.PHONY: coverage-fast coverage-fast: uv run -- python -m pytest -c pytest-ci.ini -m "fast and not (slow or stress or resource_heavy or live_integration or cloud_import)" --cov=benchbox --cov-report=term-missing:skip-covered --cov-fail-under=0 --timeout=120 +.PHONY: coverage-all coverage-all: uv run -- python -m pytest -c pytest-ci.ini -m "not (stress or resource_heavy or live_integration)" --cov=benchbox --cov-branch --cov-report=term-missing:skip-covered --cov-report=html:htmlcov --cov-report=xml:coverage.xml --cov-fail-under=0 # Full opt-in coverage requires the services and credentials used by live tests. +.PHONY: coverage-opt-in-all coverage-opt-in-all: uv run -- python -m pytest -c pytest-ci.ini --cov=benchbox --cov-branch --cov-report=term-missing:skip-covered --cov-report=html:htmlcov --cov-report=xml:coverage.xml --cov-fail-under=0 +.PHONY: coverage coverage: coverage-all +.PHONY: coverage-html coverage-html: uv run -- python -m pytest -c pytest-ci.ini -m "not (stress or resource_heavy or live_integration)" --cov=benchbox --cov-report=html:htmlcov --cov-fail-under=0 +.PHONY: coverage-report coverage-report: uv run -- python -m pytest -c pytest-ci.ini -m "not (stress or resource_heavy or live_integration)" --cov=benchbox --cov-report=xml:coverage.xml --cov-report=term-missing --cov-fail-under=0 # Cyclomatic complexity checks +.PHONY: complexity-check complexity-check: uv run -- python _project/scripts/check_complexity.py +.PHONY: complexity-report complexity-report: uv run -- python _project/scripts/check_complexity.py --no-fail --top 30 # Strict type island for governance code owned by this policy. Production and # architecture islands remain governed by the repository-wide permissive pass. +.PHONY: quality-governance-typecheck quality-governance-typecheck: uv run ty check --error all _project/scripts/check_complexity.py # Install and development +.PHONY: install install: uv sync +.PHONY: develop develop: uv sync --group dev # Clean build artifacts +.PHONY: clean clean: rm -rf build/ rm -rf dist/ @@ -443,8 +575,10 @@ clean: find . -name '.DS_Store' -delete # Linting (ruff + explorer token scan) +.PHONY: lint lint: uv run ruff check . + $(MAKE) comment-policy-check $(MAKE) windows-antipatterns-check $(MAKE) lint-explorer-tokens $(MAKE) lint-site-theme-tokens @@ -457,6 +591,12 @@ lint: uv-lock-revision-check: uv run -- python _project/scripts/check_uv_lock_revision.py $(if $(BASE_REF),--baseline-ref "$(BASE_REF)",) +# SQLGlot repro retirement: re-run upstream repros when the locked sqlglot +# version changed vs base; fails with retirement guidance on newly-passing repros. +.PHONY: sqlglot-repro-retirement-check +sqlglot-repro-retirement-check: + uv run -- python scripts/check_sqlglot_repro_retirement.py $(if $(BASE_REF),--base-ref "$(BASE_REF)",) --check + # Dependency audit - checks that every declared dep has an import site or is allowlisted. # Fails if an unused dep is introduced. See _project/scripts/dependency_audit/. audit-deps: @@ -471,6 +611,7 @@ audit-raw-check: uv run -- python _project/scripts/dependency_audit/parse_deps.py --check # Validate that an audit report records the develop SHA it describes. +.PHONY: audit-sha-check audit-sha-check: @test -n "$(FILE)" || { echo "Usage: make audit-sha-check FILE="; exit 1; } uv run --no-project -- python _project/scripts/audit_sha_check.py \ @@ -485,7 +626,20 @@ audit-sha-check: windows-antipatterns-check: uv run -- python scripts/check_windows_antipatterns.py +.PHONY: comment-policy-check +comment-policy-check: + uv run -- python scripts/run_comment_policy.py --native-tests + +.PHONY: comment-policy-strict +comment-policy-strict: + uv run -- python scripts/check_comment_policy.py --mode strict + +.PHONY: comment-policy-report +comment-policy-report: + uv run -- python scripts/check_comment_policy.py --mode report + # Validate marker registration and the explicit marker-strategy policy. +.PHONY: lint-markers lint-markers: uv run -- python -m pytest --collect-only -q -p no:warnings uv run -- python -m pytest tests/unit/test_marker_strategy.py -q @@ -494,6 +648,7 @@ lint-markers: # the experimental/mcp isolation contracts (see .importlinter). Fails on any # newly introduced layering violation that isn't already an explicitly # named, justified exception in .importlinter's ignore_imports lists. +.PHONY: lint-imports lint-imports: uv run -- lint-imports @@ -501,9 +656,11 @@ lint-imports: # palette literals (text-/bg-/border-/...-{slate|gray|...}-{50..950}) appear # under results-explorer/src outside an explicit allowlist marker. Stdlib-only # so no dependency sync is required before the gate runs. +.PHONY: lint-explorer-tokens lint-explorer-tokens: python3 _project/scripts/scan_explorer_tokens.py +.PHONY: lint-site-theme-tokens lint-site-theme-tokens: python3 _project/scripts/scan_explorer_tokens.py landing/shared landing/index.html landing/style.css landing/prompts/index.html landing/prompts/prompts.css docs/_templates/page.html docs/_static/custom.css results-explorer/index.html results-explorer/src/components/Layout.tsx @@ -524,12 +681,15 @@ SNAPSHOT ?= results-explorer/public/data/results.duckdb explorer-snapshot-check: uv run -- python _project/scripts/results_explorer_snapshot_invariants.py "$(SNAPSHOT)" +.PHONY: artifact-hygiene artifact-hygiene: uv run -- python _project/scripts/artifact_hygiene_check.py --all-tracked +.PHONY: agent-instructions-check agent-instructions-check: uv run -- python _project/scripts/agent_instruction_audit.py +.PHONY: agent-identity-check agent-identity-check: uv run -- python _project/scripts/agent_instruction_audit.py --check-git-identity @@ -537,6 +697,7 @@ agent-identity-check: # CI runner is the runner's own identity and reveals nothing about the branch; # this inspects the commits themselves so agent authorship cannot reach develop. AGENT_IDENTITY_BASE_REF ?= origin/develop +.PHONY: agent-commit-range-check agent-commit-range-check: @git fetch origin $(patsubst origin/%,%,$(AGENT_IDENTITY_BASE_REF)) --quiet 2>/dev/null || true uv run -- python _project/scripts/agent_instruction_audit.py --check-commit-range $(AGENT_IDENTITY_BASE_REF) @@ -553,6 +714,7 @@ agent-commit-range-check: # developer. Override SKILL_SYNC to point at a different wrapper copy. SKILL_SYNC ?= tools/skill-sync +.PHONY: skill-sync skill-sync: @# This recipe contains $$(MAKE), so make executes this whole logical line @# even under `-n` (recursive dry-run passthrough). The guard below must @@ -594,6 +756,7 @@ skill-sync: # unrelated repository-wide ignore pattern. Ignored-mirror self-consistency # stays covered by `skill-sync verify` inside `skill-integrity-check`. # A missing wrapper is a hard failure, never a skip-and-succeed. +.PHONY: skill-sync-check skill-sync-check: @if [ ! -x "$(SKILL_SYNC)" ]; then \ echo "skill-sync wrapper not found or not executable at $(SKILL_SYNC); cannot verify the mirror (override with SKILL_SYNC=path/to/skill-sync)" >&2; \ @@ -617,10 +780,11 @@ skill-sync-check: fi; \ echo "skill-sync-check: tracked mirror up to date." -# Fail-closed local counterpart of pr.yml's required skill-integrity job. The +# Fail-closed local counterpart of ci.yml's required skill-integrity job. The # tool pin comes from the same policy module as CI; the vendored wrapper # needs no network, no Node, and no build, so verification runs directly # against the committed payload. A missing wrapper is a hard failure. +.PHONY: skill-integrity-check skill-integrity-check: @set -eu; \ uv run -- python scripts/skill_sync_ci_policy.py validate --manifest skill-sync.conf; \ @@ -632,24 +796,30 @@ skill-integrity-check: $(MAKE) artifact-hygiene # Duplicate code detection (AST structural clone detection) +.PHONY: duplicate-check duplicate-check: uv run -- python scripts/check_duplicate_code.py +.PHONY: duplicate-check-verbose duplicate-check-verbose: uv run -- python scripts/check_duplicate_code.py --verbose --top-n 30 +.PHONY: duplicate-check-json duplicate-check-json: uv run -- python scripts/check_duplicate_code.py --json # Fail only when duplicated lines rose vs the merge-base of BASE_REF # (default origin/develop). CI sets BASE_REF to the PR base SHA. # Absolute threshold mode is `make duplicate-check`. +.PHONY: duplicate-check-delta duplicate-check-delta: uv run -- python scripts/check_duplicate_code.py --delta-vs "$(if $(BASE_REF),$(BASE_REF),origin/develop)" +.PHONY: makefile-inventory-check makefile-inventory-check: uv run -- python make/check_makefile_inventory.py +.PHONY: mutation-test mutation-test: @echo "Running mutation tests on critical modules..." uv run -- mutmut run @@ -700,8 +870,6 @@ guards-fix: @$(MAKE) -s compat-docs @echo "-- vendor-derived pricing tables (offline, from checked-in evidence) --" @$(MAKE) -s pricing-data - @echo "-- Makefile public contract inventory --" - @uv run -- python make/check_makefile_inventory.py --write @echo "-- skill-sync (fail-closed: a missing wrapper aborts instead of no-op-ing) --" @# Last regen step, contained: a failing skill-sync apply (e.g. an @# unresolvable source rev in a fresh worktree) used to abort guards-fix @@ -728,14 +896,14 @@ guards-fix: # These targets mirror GitHub Actions workflows for local validation # CI lint check - superset covering both lint.yml (release-branch gate) and -# the pr.yml `lint` job (job id `code-lint`, the routine dev-PR gate), plus a +# the ci.yml `lint` job (job id `code-lint`, the routine dev-PR gate), plus a # few extra local-only conveniences (lint-explorer-tokens, # lint-site-theme-tokens, skill-sync-check, spellcheck). Every guard the -# pr.yml `lint` job runs (after dependency install) must also run here at the +# ci.yml `lint` job runs (after dependency install) must also run here at the # COMMAND level, or tests/system/test_ci_lint_parity.py fails — see # docs/operations/ci-local-parity.md. # -# Report-all: mirrors pr.yml's `lint-guard-summary` design. Every guard runs +# Report-all: mirrors ci.yml's `lint-guard-summary` design. Every guard runs # to completion in one pass (no stopping at the first failure) with its # output streamed live as it runs; failures are collected into `failed` and # a single consolidated FAILED-guards list prints at the end, with a @@ -769,6 +937,7 @@ guards-fix: # a vacuous pass inside the change whose whole purpose is removing vacuous # passes. That test now pins the gating instead. # The Make contract check is read-only here; guards-fix owns regeneration. +.PHONY: ci-lint ci-lint: @echo "Running CI lint checks..." @case " $(MAKEFLAGS) " in *" n "*|*" -n "*|*" --just-print "*) echo "Dry-run: ci-lint guards suppressed"; exit 0;; esac; \ @@ -783,12 +952,16 @@ ci-lint: [ $$? -eq 0 ] || failed="$$failed quality-governance-typecheck"; \ $(MAKE) uv-lock-revision-check; \ [ $$? -eq 0 ] || failed="$$failed uv-lock-revision"; \ + $(MAKE) sqlglot-repro-retirement-check; \ + [ $$? -eq 0 ] || failed="$$failed sqlglot-repro-retirement"; \ $(MAKE) lint-markers; \ [ $$? -eq 0 ] || failed="$$failed lint-markers"; \ $(MAKE) lint-imports; \ [ $$? -eq 0 ] || failed="$$failed lint-imports"; \ $(MAKE) windows-antipatterns-check; \ [ $$? -eq 0 ] || failed="$$failed windows-antipatterns"; \ + $(MAKE) comment-policy-check; \ + [ $$? -eq 0 ] || failed="$$failed comment-policy"; \ $(MAKE) lint-explorer-tokens; \ [ $$? -eq 0 ] || failed="$$failed lint-explorer-tokens"; \ $(MAKE) lint-site-theme-tokens; \ @@ -811,6 +984,8 @@ ci-lint: fi; \ uv run -- python _project/scripts/timing_policy_check.py --strict; \ [ $$? -eq 0 ] || failed="$$failed timing-policy"; \ + uv run -- python _project/scripts/fast_lane_ceiling_check.py --strict; \ + [ $$? -eq 0 ] || failed="$$failed fast-lane-guards"; \ uv run --project _project/scripts --no-sync -- python _project/scripts/uat_loc_table.py --check; \ [ $$? -eq 0 ] || failed="$$failed uat-loc-table"; \ $(MAKE) compat-docs-check; \ @@ -839,6 +1014,10 @@ ci-lint: [ $$? -eq 0 ] || failed="$$failed complexity-check"; \ $(MAKE) spellcheck; \ [ $$? -eq 0 ] || failed="$$failed spellcheck"; \ + uv run -- python scripts/check_rerun_shard_retention.py; \ + [ $$? -eq 0 ] || failed="$$failed rerun-shard-retention"; \ + uv run -- python _project/scripts/check_project_references.py; \ + [ $$? -eq 0 ] || failed="$$failed project-references"; \ if [ -n "$$failed" ]; then \ echo ""; \ echo "❌ FAILED guards:$$failed"; \ @@ -850,19 +1029,22 @@ ci-lint: # Note: -p pytest_cov re-enables pytest-cov which is disabled by default in pytest.ini # Suite-wide coverage threshold set to 70%. tests/conftest.py emits a separate # non-failing advisory warning below 80%; 70 is the blocking CI floor. +.PHONY: ci-test ci-test: @echo "Running CI test suite..." uv run -- python -m pytest tests -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=120 -p pytest_cov --cov=benchbox --cov-report=xml:coverage.xml --cov-report=term-missing --cov-fail-under=70 @echo "✅ CI test suite passed" -# CI docs build - exact match for docs.yml workflow +# CI docs build - exact match for the ci.yml docs-build job +.PHONY: ci-docs ci-docs: @echo "Running CI docs checks..." @$(MAKE) docs-validate - @cd docs && uv run sphinx-build -b html --keep-going . _build/html + @cd docs && uv run sphinx-build -b html -W --keep-going . _build/html @echo "✅ CI docs build passed" # Security audit - exact match for test.yml security job +.PHONY: security-audit security-audit: @echo "Running security audit..." @if [ -n "$(PIP_AUDIT_IGNORE_VULNS)" ]; then \ @@ -900,6 +1082,7 @@ SPELLCHECK_EXCLUDE_PATHS := \ ':(exclude,glob)**/_project/**' ':(exclude,glob)_project/**' \ ':(exclude,glob)**/_blog/**' ':(exclude,glob)_blog/**' +.PHONY: spellcheck spellcheck: @echo "Running spellcheck..." git ls-files -z -- $(SPELLCHECK_EXCLUDE_PATHS) \ @@ -914,13 +1097,8 @@ ci-linkcheck: @cat docs/_build/linkcheck/output.txt 2>/dev/null || echo "No output file generated" @echo "✅ Linkcheck passed" -# Docstring coverage - exact match for docs.yml docstring-coverage job -docstring-coverage: - @echo "Running docstring coverage check..." - uvx interrogate -c pyproject.toml --fail-under 90 benchbox/ - @echo "✅ Docstring coverage passed" - # Package build and install test - exact match for test.yml test-package job +.PHONY: test-package test-package: @echo "Building and testing package installation..." rm -rf dist/ @@ -942,12 +1120,14 @@ test-package: @echo "✅ Package test passed" # Integration smoke tests - exact match for test.yml integration-smoke job +.PHONY: test-integration-smoke test-integration-smoke: @echo "Running integration smoke tests..." uv run -- python -m pytest tests/integration -m "platform_smoke or (integration and fast)" --tb=short @echo "✅ Integration smoke tests passed" # Run all CI checks locally - ensures CI will pass before push +.PHONY: ci-local ci-local: @echo "========================================" @echo "Running all CI checks locally..." @@ -973,7 +1153,7 @@ ci-local: @echo "========================================" # --- Apple container Linux CI-parity sandbox (opt-in; Apple silicon + macOS 26) --- -# Reproduce the Linux pr.yml gate locally inside a `container machine`. Motivated by a +# Reproduce the Linux ci.yml gate locally inside a `container machine`. Motivated by a # MEASURED macOS<->Linux divergence: on identical DuckDB 1.3.2/arm64, TPC-H Q2/Q10/Q15 # value digests differ, so `make test-correctness-gate` FAILS on a correct tree on Apple # silicon (the pinned digest references are Linux-generated). This wrapper is the only way @@ -1008,6 +1188,7 @@ ci-linux: container machine run -n $(CI_LINUX_MACHINE) -- bash -lc 'cd "$(CURDIR)" && $(CI_LINUX_CMD)' # Type checking +.PHONY: typecheck typecheck: uv run ty check @@ -1017,16 +1198,20 @@ typecheck-uv: typecheck # Import validation. Alias for the import-linter gate (lint-imports); the # previously-referenced scripts/validate_imports.py never existed, so the bare # target failed with file-not-found. CI uses lint-imports directly. +.PHONY: validate-imports validate-imports: lint-imports # Field-level schema validation for migrated YAML catalogs (see benchbox/core/catalog_schema.py). +.PHONY: catalog-schema-check catalog-schema-check: uv run -- python -m benchbox.core.catalog_schema # Dependency matrix / validation +.PHONY: dependency-check dependency-check: uv run -- python -m benchbox.utils.dependency_validation $(ARGS) # Format code (ruff formatter) +.PHONY: format format: uv run ruff format . @@ -1061,8 +1246,8 @@ run-test: RELEASE_REQUIRED_CONTEXTS := validate-base release-required-result -.PHONY: release-cut release-cut-abort release-finalize .release-cut-tree-required +.PHONY: .release-cut-tree-required .release-cut-tree-required: @if [ -z "$(VERSION)" ]; then \ echo "Usage: make release-cut VERSION=X.Y.Z" >&2; \ @@ -1096,6 +1281,7 @@ RELEASE_REQUIRED_CONTEXTS := validate-base release-required-result # v0.3.1 cut died at step 3 twice and left exactly that half-applied state. # `make release-cut-abort VERSION=X.Y.Z` discards it instead. # Usage: make release-cut VERSION=X.Y.Z +.PHONY: release-cut release-cut: .release-cut-tree-required @test -n "$(VERSION)" || (echo "Usage: make release-cut VERSION=X.Y.Z" && exit 1) sh scripts/release_cut_start.sh "$(VERSION)" @@ -1140,10 +1326,13 @@ release-cut: .release-cut-tree-required git rm -rf --ignore-unmatch tests/unit/scripts/explorer_pipeline tests/unit/explorer git rm -f --ignore-unmatch tests/uat/test_explorer_smoke.py tests/unit/release/test_ruleset_drift_review_coverage.py tests/unit/release/test_ruleset_review_enforcement.py tests/unit/scripts/test_blind_spot_tools.py tests/unit/scripts/test_build_joinorder_data.py tests/unit/scripts/test_check_complexity.py tests/unit/scripts/test_explorer_build_contract.py tests/unit/scripts/test_pr_review_followups.py tests/unit/scripts/test_reference_usage_audit.py tests/unit/scripts/test_scan_explorer_stale_theme.py tests/unit/scripts/test_scan_explorer_tokens.py tests/unit/scripts/test_shrink_rollup.py tests/unit/scripts/test_submission_workflow_waiver.py tests/unit/test_agent_write_preflight.py tests/unit/test_auto_merge_soundness_paths.py tests/unit/test_cross_surface_applicability.py tests/unit/test_oracle_coverage_map.py tests/unit/test_ruleset_drift.py tests/unit/test_self_binding_detector.py tests/unit/test_site_header_parity.py tests/unit/test_sync_results_workflow.py tests/unit/core/joinorder/test_canonical_queries.py tests/unit/core/test_platform_labels.py tests/unit/workflows/test_validate_submission_comment_security.py tests/unit/workflows/test_detect_orphaned_commits.py tests/unit/workflows/test_validate_submission_vendor_gate.py git rm -f --ignore-unmatch tests/integration/test_todo_db_standalone_compat_real.py tests/unit/core/equivalence/test_cross_surface_baseline_autodetect.py tests/unit/docs/test_architecture_decision_surfaces.py - git rm -f --ignore-unmatch tests/unit/scripts/test_agent_instruction_audit.py tests/unit/scripts/test_audit_sha_check.py tests/unit/scripts/test_browser_gate_aggregate.py tests/unit/scripts/test_check_release_curation.py tests/unit/scripts/test_check_uv_lock_revision.py tests/unit/scripts/test_ci_lint_environment_boundary.py tests/unit/scripts/test_corpus_privacy_invariant.py tests/unit/scripts/test_dev_loop_pr_metrics.py tests/unit/scripts/test_fast_lane_ratchet_check.py tests/unit/scripts/test_green_unmerged_sweep.py tests/unit/scripts/test_guard_messages.py tests/unit/scripts/test_mirror_partial_validation_policy.py tests/unit/scripts/test_path_filter_decision.py tests/unit/scripts/test_results_explorer_corpus_migrate.py tests/unit/scripts/test_results_explorer_snapshot_invariants.py tests/unit/scripts/test_skill_sync_ci_policy.py tests/unit/scripts/test_soundness_drain_report.py tests/unit/scripts/test_timing_policy_modes.py tests/unit/scripts/test_todo_db_shadow.py tests/unit/scripts/test_todo_db_standalone_compat.py tests/unit/scripts/test_todo_schema_migration_check.py tests/unit/scripts/test_todo_verification_lint.py tests/unit/scripts/test_todo_wrapper.py + git rm -f --ignore-unmatch tests/unit/scripts/test_agent_instruction_audit.py tests/unit/scripts/test_audit_sha_check.py tests/unit/scripts/test_browser_gate_aggregate.py tests/unit/scripts/test_check_release_curation.py tests/unit/scripts/test_check_uv_lock_revision.py tests/unit/scripts/test_ci_lint_environment_boundary.py tests/unit/scripts/test_corpus_privacy_invariant.py tests/unit/scripts/test_dev_loop_pr_metrics.py tests/unit/scripts/test_green_unmerged_sweep.py tests/unit/scripts/test_guard_messages.py tests/unit/scripts/test_mirror_partial_validation_policy.py tests/unit/scripts/test_path_filter_decision.py tests/unit/scripts/test_results_explorer_corpus_migrate.py tests/unit/scripts/test_results_explorer_snapshot_invariants.py tests/unit/scripts/test_skill_sync_ci_policy.py tests/unit/scripts/test_soundness_drain_report.py tests/unit/scripts/test_soundness_merge_digest.py tests/unit/scripts/test_fast_lane_ceiling_check.py tests/unit/scripts/test_timing_policy_check.py tests/unit/scripts/test_todo_db_shadow.py tests/unit/scripts/test_todo_db_standalone_compat.py tests/unit/scripts/test_todo_schema_migration_check.py tests/unit/scripts/test_todo_verification_lint.py tests/unit/scripts/test_todo_wrapper.py git rm -f --ignore-unmatch tests/unit/test_auto_merge_hold_is_durable.py tests/unit/test_release_infrastructure.py tests/unit/workflows/test_auto_merge_partial_stack_race.py tests/unit/workflows/test_develop_post_merge_gaps.py tests/unit/workflows/test_merge_group_triggers.py tests/unit/workflows/test_published_results_base_ci.py tests/unit/workflows/test_results_explorer_browser_gate.py tests/unit/workflows/test_results_explorer_dependency_audit.py tests/unit/workflows/test_seed_corpus_pr_base.py tests/unit/workflows/test_validate_submission_changed_bundles.py tests/unit/workflows/test_validate_submission_fail_open.py + @# The v0.4.1 release PR exposed these tests for PR landing, worktree, queue, tracker, + @# publication, and submission-workflow tooling; they import or read curated-away paths. + git rm -f --ignore-unmatch tests/integration/worktree/test_pr_process.py tests/integration/worktree/test_worktree_audit.py tests/integration/worktree/test_worktree_finish_preview.py tests/unit/scripts/test_migrate_clickhouse_labels.py tests/unit/scripts/test_pr_arm.py tests/unit/scripts/test_pr_ready_make.py tests/unit/scripts/test_pr_landing.py tests/unit/scripts/test_results_explorer_cpu_attestation_backfill.py tests/unit/scripts/test_todo_state_contract_check.py tests/unit/scripts/test_worktree_audit.py tests/unit/workflows/test_publication_preview.py tests/unit/workflows/test_queue_certification.py tests/integration/worktree/test_batch_skill_delivery.py tests/unit/scripts/publication/test_plan_reconciliation.py tests/unit/workflows/test_validate_submission_override_approval.py tests/unit/scripts/test_sqlite_extract_repro.py tests/unit/workflows/test_validate_submission_workflow_guard_mirror.py tests/unit/workflows/test_validate_submission_trusted_checkout.py tests/unit/workflows/test_validate_submission_corpus_allowlist.py tests/unit/workflows/test_corpus_trust_boundary.py tests/unit/scripts/test_branch_prune_merged.py tests/unit/docs/test_publication_architecture.py @# Post-curation guard: every curated path must be gone from the index. - @LEFTOVER=$$(git ls-files _project ':(exclude)_project/scripts/explorer_pipeline/**' ':(exclude)_project/scripts/explorer_publish.py' ':(exclude)_project/scripts/results_explorer_snapshot_invariants.py' _blog .claude .codex .gemini .pre-commit-config.yaml .importlinter todo.config.yaml skill-sync.conf tools .gitattributes .coveragerc_core .dockerignore .env.example .mcp.json AGENTS.md CLAUDE.md GEMINI.md ANTIGRAVITY.md .github/workflows/results-explorer-browser.yml .github/workflows/seed-corpus.yml .github/workflows/sync-results-data-to-published.yml .github/workflows/validate-submission.yml tests/unit/scripts/explorer_pipeline tests/unit/explorer tests/uat/test_explorer_smoke.py tests/unit/release/test_ruleset_drift_review_coverage.py tests/unit/release/test_ruleset_review_enforcement.py tests/unit/scripts/test_blind_spot_tools.py tests/unit/scripts/test_build_joinorder_data.py tests/unit/scripts/test_check_complexity.py tests/unit/scripts/test_explorer_build_contract.py tests/unit/scripts/test_pr_review_followups.py tests/unit/scripts/test_reference_usage_audit.py tests/unit/scripts/test_scan_explorer_stale_theme.py tests/unit/scripts/test_scan_explorer_tokens.py tests/unit/scripts/test_shrink_rollup.py tests/unit/scripts/test_submission_workflow_waiver.py tests/unit/test_agent_write_preflight.py tests/unit/test_auto_merge_soundness_paths.py tests/unit/test_cross_surface_applicability.py tests/unit/test_oracle_coverage_map.py tests/unit/test_ruleset_drift.py tests/unit/test_self_binding_detector.py tests/unit/test_site_header_parity.py tests/unit/test_sync_results_workflow.py tests/unit/core/joinorder/test_canonical_queries.py tests/unit/core/test_platform_labels.py tests/unit/workflows/test_validate_submission_comment_security.py tests/unit/workflows/test_detect_orphaned_commits.py tests/unit/workflows/test_validate_submission_vendor_gate.py tests/integration/test_todo_db_standalone_compat_real.py tests/unit/core/equivalence/test_cross_surface_baseline_autodetect.py tests/unit/docs/test_architecture_decision_surfaces.py tests/unit/scripts/test_agent_instruction_audit.py tests/unit/scripts/test_audit_sha_check.py tests/unit/scripts/test_browser_gate_aggregate.py tests/unit/scripts/test_check_release_curation.py tests/unit/scripts/test_check_uv_lock_revision.py tests/unit/scripts/test_ci_lint_environment_boundary.py tests/unit/scripts/test_corpus_privacy_invariant.py tests/unit/scripts/test_dev_loop_pr_metrics.py tests/unit/scripts/test_fast_lane_ratchet_check.py tests/unit/scripts/test_green_unmerged_sweep.py tests/unit/scripts/test_guard_messages.py tests/unit/scripts/test_mirror_partial_validation_policy.py tests/unit/scripts/test_path_filter_decision.py tests/unit/scripts/test_results_explorer_corpus_migrate.py tests/unit/scripts/test_results_explorer_snapshot_invariants.py tests/unit/scripts/test_skill_sync_ci_policy.py tests/unit/scripts/test_soundness_drain_report.py tests/unit/scripts/test_timing_policy_modes.py tests/unit/scripts/test_todo_db_shadow.py tests/unit/scripts/test_todo_db_standalone_compat.py tests/unit/scripts/test_todo_schema_migration_check.py tests/unit/scripts/test_todo_verification_lint.py tests/unit/scripts/test_todo_wrapper.py tests/unit/test_auto_merge_hold_is_durable.py tests/unit/test_release_infrastructure.py tests/unit/workflows/test_auto_merge_partial_stack_race.py tests/unit/workflows/test_develop_post_merge_gaps.py tests/unit/workflows/test_merge_group_triggers.py tests/unit/workflows/test_published_results_base_ci.py tests/unit/workflows/test_results_explorer_browser_gate.py tests/unit/workflows/test_results_explorer_dependency_audit.py tests/unit/workflows/test_seed_corpus_pr_base.py tests/unit/workflows/test_validate_submission_changed_bundles.py tests/unit/workflows/test_validate_submission_fail_open.py); \ + @LEFTOVER=$$(git ls-files _project ':(exclude)_project/scripts/explorer_pipeline/**' ':(exclude)_project/scripts/explorer_publish.py' ':(exclude)_project/scripts/results_explorer_snapshot_invariants.py' _blog .claude .codex .gemini .pre-commit-config.yaml .importlinter todo.config.yaml skill-sync.conf tools .gitattributes .coveragerc_core .dockerignore .env.example .mcp.json AGENTS.md CLAUDE.md GEMINI.md ANTIGRAVITY.md .github/workflows/results-explorer-browser.yml .github/workflows/seed-corpus.yml .github/workflows/sync-results-data-to-published.yml .github/workflows/validate-submission.yml tests/unit/scripts/explorer_pipeline tests/unit/explorer tests/uat/test_explorer_smoke.py tests/unit/release/test_ruleset_drift_review_coverage.py tests/unit/release/test_ruleset_review_enforcement.py tests/unit/scripts/test_blind_spot_tools.py tests/unit/scripts/test_build_joinorder_data.py tests/unit/scripts/test_check_complexity.py tests/unit/scripts/test_explorer_build_contract.py tests/unit/scripts/test_pr_review_followups.py tests/unit/scripts/test_reference_usage_audit.py tests/unit/scripts/test_scan_explorer_stale_theme.py tests/unit/scripts/test_scan_explorer_tokens.py tests/unit/scripts/test_shrink_rollup.py tests/unit/scripts/test_submission_workflow_waiver.py tests/unit/test_agent_write_preflight.py tests/unit/test_auto_merge_soundness_paths.py tests/unit/test_cross_surface_applicability.py tests/unit/test_oracle_coverage_map.py tests/unit/test_ruleset_drift.py tests/unit/test_self_binding_detector.py tests/unit/test_site_header_parity.py tests/unit/test_sync_results_workflow.py tests/unit/core/joinorder/test_canonical_queries.py tests/unit/core/test_platform_labels.py tests/unit/workflows/test_validate_submission_comment_security.py tests/unit/workflows/test_detect_orphaned_commits.py tests/unit/workflows/test_validate_submission_vendor_gate.py tests/integration/test_todo_db_standalone_compat_real.py tests/unit/core/equivalence/test_cross_surface_baseline_autodetect.py tests/unit/docs/test_architecture_decision_surfaces.py tests/unit/scripts/test_agent_instruction_audit.py tests/unit/scripts/test_audit_sha_check.py tests/unit/scripts/test_browser_gate_aggregate.py tests/unit/scripts/test_check_release_curation.py tests/unit/scripts/test_check_uv_lock_revision.py tests/unit/scripts/test_ci_lint_environment_boundary.py tests/unit/scripts/test_corpus_privacy_invariant.py tests/unit/scripts/test_dev_loop_pr_metrics.py tests/unit/scripts/test_green_unmerged_sweep.py tests/unit/scripts/test_guard_messages.py tests/unit/scripts/test_mirror_partial_validation_policy.py tests/unit/scripts/test_path_filter_decision.py tests/unit/scripts/test_results_explorer_corpus_migrate.py tests/unit/scripts/test_results_explorer_snapshot_invariants.py tests/unit/scripts/test_skill_sync_ci_policy.py tests/unit/scripts/test_soundness_drain_report.py tests/unit/scripts/test_soundness_merge_digest.py tests/unit/scripts/test_fast_lane_ceiling_check.py tests/unit/scripts/test_timing_policy_check.py tests/unit/scripts/test_todo_db_shadow.py tests/unit/scripts/test_todo_db_standalone_compat.py tests/unit/scripts/test_todo_schema_migration_check.py tests/unit/scripts/test_todo_verification_lint.py tests/unit/scripts/test_todo_wrapper.py tests/unit/test_auto_merge_hold_is_durable.py tests/unit/test_release_infrastructure.py tests/unit/workflows/test_auto_merge_partial_stack_race.py tests/unit/workflows/test_develop_post_merge_gaps.py tests/unit/workflows/test_merge_group_triggers.py tests/unit/workflows/test_published_results_base_ci.py tests/unit/workflows/test_results_explorer_browser_gate.py tests/unit/workflows/test_results_explorer_dependency_audit.py tests/unit/workflows/test_seed_corpus_pr_base.py tests/unit/workflows/test_validate_submission_changed_bundles.py tests/unit/workflows/test_validate_submission_fail_open.py tests/integration/worktree/test_pr_process.py tests/integration/worktree/test_worktree_audit.py tests/integration/worktree/test_worktree_finish_preview.py tests/unit/scripts/test_migrate_clickhouse_labels.py tests/unit/scripts/test_pr_arm.py tests/unit/scripts/test_pr_ready_make.py tests/unit/scripts/test_pr_landing.py tests/unit/scripts/test_results_explorer_cpu_attestation_backfill.py tests/unit/scripts/test_todo_state_contract_check.py tests/unit/scripts/test_worktree_audit.py tests/unit/workflows/test_publication_preview.py tests/unit/workflows/test_queue_certification.py tests/integration/worktree/test_batch_skill_delivery.py tests/unit/scripts/publication/test_plan_reconciliation.py tests/unit/workflows/test_validate_submission_override_approval.py tests/unit/scripts/test_sqlite_extract_repro.py tests/unit/workflows/test_validate_submission_workflow_guard_mirror.py tests/unit/workflows/test_validate_submission_trusted_checkout.py tests/unit/workflows/test_validate_submission_corpus_allowlist.py tests/unit/workflows/test_corpus_trust_boundary.py tests/unit/scripts/test_branch_prune_merged.py tests/unit/docs/test_publication_architecture.py); \ if [ -n "$$LEFTOVER" ]; then \ echo "ERROR: release curation incomplete; development-only paths still tracked:" >&2; \ echo "$$LEFTOVER" | sed 's/^/ /' >&2; \ @@ -1222,6 +1411,7 @@ release-cut: .release-cut-tree-required # Refuses after a commit, a pushed branch or tag, or untracked work. Published # release refs need explicit disposition; this target never moves them. # Usage: make release-cut-abort VERSION=X.Y.Z +.PHONY: release-cut-abort release-cut-abort: @test -n "$(VERSION)" || (echo "Usage: make release-cut-abort VERSION=X.Y.Z" && exit 1) sh scripts/release_cut_abort.sh "$(VERSION)" @@ -1230,6 +1420,7 @@ release-cut-abort: # checked head, tag the confirmed merge commit, and push the tag. A rerun after # merge or tag creation resumes from hosted PR and tag state. # Usage: make release-finalize VERSION=X.Y.Z +.PHONY: release-finalize release-finalize: @test -n "$(VERSION)" || (echo "Usage: make release-finalize VERSION=X.Y.Z" && exit 1) uv run -- python scripts/release_finalize.py --version "$(VERSION)" --required-contexts "$(RELEASE_REQUIRED_CONTEXTS)" @@ -1238,6 +1429,22 @@ release-finalize: @echo "Push-to-release jobs are post-merge signals; release publication relied on $(RELEASE_REQUIRED_CONTEXTS)." @echo "develop is intentionally unchanged — dev-only paths persist on develop." +# Tag-on-develop release flow. release-prep produces one ordinary PR diff (version +# markers, uv.lock, CHANGELOG draft); release-check verifies the pre-tag state and +# runs in the required CI classifier for release-content changes. Neither target +# touches the release-cut path above or enables develop-tag publication. +# Usage: make release-prep VERSION=X.Y.Z [SINCE_REF=] +# make release-check VERSION=X.Y.Z +.PHONY: release-prep +release-prep: + @test -n "$(VERSION)" || (echo "Usage: make release-prep VERSION=X.Y.Z" && exit 1) + uv run --frozen -- python scripts/release_flow.py prep --version "$(VERSION)" $(if $(SINCE_REF),--since-ref "$(SINCE_REF)",) + +.PHONY: release-check +release-check: + @test -n "$(VERSION)" || (echo "Usage: make release-check VERSION=X.Y.Z" && exit 1) + uv run --locked -- python scripts/release_flow.py check --version "$(VERSION)" $(if $(BASE_REF),--baseline-ref "$(BASE_REF)",) + # ============================================================================= # PR + worktree workflow # Solo-dev develop is PR-gated (CI must be green; linear history; squash). @@ -1245,77 +1452,38 @@ release-finalize: # branches stay live in parallel via worktrees. # ============================================================================= -.PHONY: pr-preflight pr-preflight-uncached .pr-preflight-route pr-preflight-focused-tests pr-preflight-fast-tests lane-isolation-check pr-content-guard skill-integrity-check pr-open pr-ready pr-arm-auto-merge pr-fanout pr-refresh pr-conflict-scan pr-status pr-review-followups pr-review-followups-list dev-loop-metrics shrink-rollup audit-sha-check agent-write-preflight worktree-create worktree-remove worktree-list branch-prune-merged blind-spots-list blind-spots-report blind-spots-sweep soundness-drain-report soundness-drain-self-test +.PHONY: agent-write-preflight agent-write-preflight: @sh scripts/agent_write_preflight.sh -# Local gate before pushing. One classifier artifact selects the same product -# and skill-integrity lanes as CI. Content-only, product, unknown, empty, and -# structurally unsafe skill diffs retain the full historical ci-lint + content -# guard + fast-test contract; only an approved pure skill-integrity diff uses -# the focused pinned-verifier lane. CI coverage thresholds remain CI-only. -# The uncached implementation is called by the receipt-bound wrapper below. -pr-preflight-uncached: - @set -eu; \ - DECISION=$$(mktemp); \ - LISTS=$$(mktemp -d); \ - trap 'rm -f "$$DECISION"; rm -rf "$$LISTS"' EXIT; \ - git fetch origin develop --quiet; \ - uv run -- python scripts/path_filter_decision.py --base-ref origin/develop --json-out "$$DECISION" --lists-dir "$$LISTS" >/dev/null; \ - $(MAKE) -s .pr-preflight-route PATH_DECISION="$$DECISION" PATH_LISTS="$$LISTS" SKIP_FAST_TESTS="$(SKIP_FAST_TESTS)"; \ - $(MAKE) -s pr-preflight-medium-tests PATH_DECISION="$$DECISION"; \ - $(MAKE) -s uat-artifact-hygiene - -# Canonical local preflight: the focused lane runs once, then the remaining -# required lanes each classify the revalidated transaction input. The ordered -# wrapper rejects drift between stages, and each stage gets its own -# content-bound receipt; hosted required checks remain separate. +.PHONY: pr-preflight pr-preflight: - @git fetch origin develop --quiet - uv run -- python scripts/local_validation.py ordered \ - --focused-gate "local-focused-check" --focused-cmd 'make pr-preflight-focused-tests' \ - --preflight-gate "required-pr-preflight" --preflight-cmd '$(MAKE) -s pr-preflight-uncached SKIP_FAST_TESTS=1' \ - $(BATCH_ARGS) - -# Consume the classifier decision only; never reimplement path globs here. -# Mixed skill/product diffs run both lanes. Skill plus safe content stays on -# the two narrow lanes. Content-only remains on the full product preflight -# until a separately authorized optimization exists. -.pr-preflight-route: @set -eu; \ - [ -n "$(PATH_DECISION)" ] && [ -f "$(PATH_DECISION)" ] || { echo "PATH_DECISION is required" >&2; exit 2; }; \ - [ -n "$(PATH_LISTS)" ] && [ -d "$(PATH_LISTS)" ] || { echo "PATH_LISTS is required" >&2; exit 2; }; \ - ROUTE=$$(uv run -- python -c 'import json, sys; d=json.load(open(sys.argv[1], encoding="utf-8")); keys=("skill_integrity_needed", "content_guard_needed", "skill_integrity_only", "needs_code_ci"); assert all(type(d.get(k)) is bool for k in keys), "invalid preflight decision"; assert not d["skill_integrity_only"] or (d["skill_integrity_needed"] and not d["needs_code_ci"]), "contradictory preflight decision"; print(*(str(d[k]).lower() for k in keys))' "$(PATH_DECISION)"); \ - set -- $$ROUTE; SKILL=$$1; CONTENT=$$2; SKILL_ONLY=$$3; \ - if [ "$$SKILL_ONLY" = true ] && [ "$$CONTENT" != true ]; then \ - echo "Selected preflight lanes: skill-integrity"; \ - $(MAKE) -s skill-integrity-check; \ - elif [ "$$SKILL_ONLY" = true ]; then \ - echo "Selected preflight lanes: skill-integrity content"; \ - $(MAKE) -s skill-integrity-check; \ - $(MAKE) -s pr-content-guard PATH_LISTS="$(PATH_LISTS)"; \ + git fetch origin develop --quiet; \ + PY_LIST=$$(mktemp); \ + TEST_LIST=$$(mktemp); \ + trap 'rm -f "$$PY_LIST" "$$TEST_LIST"' EXIT; \ + uv run -- python _project/scripts/preflight_targets.py python --base-ref origin/develop > "$$PY_LIST"; \ + uv run -- python _project/scripts/preflight_targets.py tests --base-ref origin/develop > "$$TEST_LIST"; \ + if [ -s "$$PY_LIST" ]; then \ + echo "==> ruff (changed Python files)"; \ + uv run -- ruff check --force-exclude $$(cat "$$PY_LIST"); \ + uv run -- ruff format --check --force-exclude $$(cat "$$PY_LIST"); \ else \ - LANES=product; [ "$$CONTENT" = true ] && LANES="$$LANES content"; [ "$$SKILL" = true ] && LANES="$$LANES skill-integrity"; \ - echo "Selected preflight lanes: $$LANES"; \ - $(MAKE) ci-lint; \ - if [ "$$SKILL" = true ]; then $(MAKE) -s skill-integrity-check; fi; \ - if [ "$(SKIP_FAST_TESTS)" = "1" ]; then \ - echo "Focused local gate already completed; skipping duplicate fast tests."; \ - $(MAKE) -s pr-content-guard PATH_LISTS="$(PATH_LISTS)"; \ - else \ - $(MAKE) -s pr-preflight-fast-tests PATH_DECISION="$(PATH_DECISION)" PATH_LISTS="$(PATH_LISTS)"; \ - fi; \ - fi - -# The historical full-preflight target runs pr-content-guard unless the -# receipt-bound focused wrapper explicitly defers it to the required stage. -# The needs-code-ci decision gates only the fast-test run below. Direct -# invocation creates classifier artifacts when the parent preflight did not -# already supply them. -pr-preflight-focused-tests: - @$(MAKE) -s pr-preflight-fast-tests SKIP_CONTENT_GUARD=1 + echo "No Python files changed; skipping ruff."; \ + fi; \ + if [ ! -s "$$TEST_LIST" ]; then \ + echo "No test files map to the changed paths; CI runs the fast lane."; \ + exit 0; \ + fi; \ + echo "==> tests for changed files"; \ + STATUS=0; \ + env $$(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/-u \1/p') uv run -- python -m pytest -q -n 0 --tb=short --ff $$(cat "$$TEST_LIST") || STATUS=$$?; \ + if [ "$$STATUS" -eq 5 ]; then echo "Mapped tests are all deselected by the default markers."; STATUS=0; fi; \ + exit "$$STATUS" +.PHONY: pr-preflight-fast-tests pr-preflight-fast-tests: @set -eu; \ if [ -n "$(PATH_DECISION)" ] || [ -n "$(PATH_LISTS)" ]; then \ @@ -1330,11 +1498,7 @@ pr-preflight-fast-tests: git fetch origin develop --quiet; \ uv run -- python scripts/path_filter_decision.py --base-ref origin/develop --json-out "$$DECISION" --lists-dir "$$LISTS" >/dev/null; \ fi; \ - if [ "$(SKIP_CONTENT_GUARD)" = "1" ]; then \ - echo "Focused checks defer content guard to required preflight."; \ - else \ - $(MAKE) -s pr-content-guard PATH_LISTS="$$LISTS"; \ - fi; \ + $(MAKE) -s pr-content-guard PATH_LISTS="$$LISTS"; \ if uv run -- python scripts/path_filter_decision.py --json-in "$$DECISION" --check needs-code-ci >/dev/null; then \ echo "==> fast tests (CI marker selection; coverage remains CI-only)"; \ uv run -- python -m pytest -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short --timeout=120 -q; \ @@ -1342,79 +1506,6 @@ pr-preflight-fast-tests: echo "No code changes detected; skipping fast tests."; \ fi -# Medium tier as its own receipt-bound preflight stage. Runs the exact CI -# selection (`make test-medium`, no local-only subset) through -# local_validation's medium-tier gate only when the classifier says the diff -# needs code CI, so content-only and skill-integrity-only diffs skip it. -# Identical trees reuse the receipt across worktrees without colliding on -# the shared test lock. The stage fails pr-preflight on failure; there is -# no skip flag. The gate invocation scrubs the preflight control variables -# (PATH_DECISION, PATH_LISTS, SKIP_FAST_TESTS) from the environment and -# blanks MAKEFLAGS: make exports command-line variables and smuggles their -# assignments inside MAKEFLAGS, so a leak would make test-spawned makes take -# the caller-supplied branch with no lists dir ("PATH_LISTS is required") or -# flip the route into its skip branch (stale "already completed" line inside -# the suite). BATCH_ARGS is deliberately not scrubbed so batch mode still -# binds receipts. -pr-preflight-medium-tests: - @set -eu; \ - if [ -n "$(PATH_DECISION)" ]; then \ - [ -f "$(PATH_DECISION)" ] || { echo "PATH_DECISION is required" >&2; exit 2; }; \ - DECISION="$(PATH_DECISION)"; \ - else \ - DECISION=$$(mktemp); \ - trap 'rm -f "$$DECISION"' EXIT; \ - git fetch origin develop --quiet; \ - uv run -- python scripts/path_filter_decision.py --base-ref origin/develop --json-out "$$DECISION" >/dev/null; \ - fi; \ - if uv run -- python scripts/path_filter_decision.py --json-in "$$DECISION" --check needs-code-ci >/dev/null; then \ - echo "==> medium tier (same marker selection as CI)"; \ - env -u PATH_DECISION -u PATH_LISTS -u SKIP_FAST_TESTS MAKEFLAGS= $(MAKE) -s local-validation GATE=medium-tier CMD="make test-medium"; \ - else \ - echo "No code changes detected; skipping medium tier."; \ - fi - -# Publication lane isolation is a diff-vs-base guard. The path classifier has -# already produced the changed-path list for the PR, so this target consumes -# that exact artifact instead of reimplementing path classification or asking -# the working tree to infer a base. Keep it in the content guard, where -# PATH_LISTS is mandatory and the caller has already selected the PR lanes. -lane-isolation-check: - @set -eu; \ - [ -n "$(PATH_LISTS)" ] || { echo "PATH_LISTS is required" >&2; exit 2; }; \ - [ -d "$(PATH_LISTS)" ] || { echo "PATH_LISTS directory not found: $(PATH_LISTS)" >&2; exit 2; }; \ - CHANGED_PATHS="$(PATH_LISTS)/changed.txt"; \ - [ -s "$$CHANGED_PATHS" ] || { echo "non-empty changed paths artifact is required: $$CHANGED_PATHS" >&2; exit 2; }; \ - status=0; \ - for lane in site explorer corpus; do \ - uv run -- python scripts/publication/verify_lane_isolation.py --lane "$$lane" --changed-paths-file "$$CHANGED_PATHS" || status=$$?; \ - done; \ - exit "$$status" - -# Local validation singleflight. Runs CMD once per identical validated input -# across worktrees; identical repeats reuse the recorded receipt instead of -# re-executing and colliding on the shared test lock. Receipts never certify -# hosted checks and never cross changed trees. Usage: -# make local-validation GATE=fast-tests CMD="pytest tests/unit -q" -local-validation: - @[ -n "$(GATE)" ] || { echo "GATE is required" >&2; exit 2; }; \ - [ -n "$(CMD)" ] || { echo "CMD is required" >&2; exit 2; }; \ - uv run -- python scripts/local_validation.py run --gate "$(GATE)" $(BATCH_ARGS) -- $(CMD) - -local-validation-show: - @[ -n "$(GATE)" ] || { echo "GATE is required" >&2; exit 2; }; \ - uv run -- python scripts/local_validation.py show --gate "$(GATE)" $(BATCH_ARGS) $(if $(CMD),-- $(CMD),) - -# Ordered local delivery path. The focused check and required preflight have -# distinct receipt namespaces and the focused gate must finish first. A hook -# that is not active prints SKIPPED at its boundary instead of looking like a -# successful test invocation. -FOCUSED_CMD ?= uv run -- python -m pytest -m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short -q -PREFLIGHT_CMD ?= make pr-preflight -local-validation-path: - @echo "Running receipt-bound canonical preflight path (focused then required lanes)." - $(MAKE) -s pr-preflight $(BATCH_ARGS) - # Revision/readiness transactions behind one helper (scripts/pr_landing.py). # The live PR targets below use the same exact-checkout arming path: start # records identity, withdraw disarms auto-merge before a revision, and ready @@ -1453,10 +1544,15 @@ pr-followup-resume: @[ -n "$(KEY)" ] || { echo "KEY is required" >&2; exit 2; }; \ uv run -- python scripts/pr_landing.py --worktree . followup-resume --key "$(KEY)" +# Publication lane isolation is a diff-vs-base guard. The path classifier has +# already produced the changed-path list for the PR, so this target consumes +# that exact artifact instead of reimplementing path classification or asking +# the working tree to infer a base. Keep it in the content guard, where +# PATH_LISTS is mandatory and the caller has already selected the PR lanes. +.PHONY: pr-content-guard pr-content-guard: @set -eu; \ [ -n "$(PATH_LISTS)" ] || { echo "PATH_LISTS is required"; exit 2; }; \ - $(MAKE) -s lane-isolation-check PATH_LISTS="$(PATH_LISTS)"; \ EXISTING=$$(mktemp); \ trap 'rm -f "$$EXISTING"' EXIT; \ $(MAKE) artifact-hygiene; \ @@ -1484,30 +1580,33 @@ pr-content-guard: echo "No docs paths changed."; \ fi -# Push current branch and open a PR against develop. Auto-merge is WITHHELD by -# default so a follow-up commit cannot race a merge. Arm only when the branch -# is final: `make pr-ready`, or `make pr-open READY=1` when reusing an already -# open, reviewed PR. A newly created PR always remains held so it can receive -# review before readiness is evaluated. When a merge queue is enabled on -# develop, arming auto-merge enqueues the PR for speculative integration once -# checks pass. Soundness-path diffs are never armed or auto-enqueued (see -# pr-arm-auto-merge). +# Push current branch and open a PR against develop. This does not arm +# auto-merge by itself, so a follow-up commit pushed for review feedback cannot +# race a merge. Arm with `make pr-arm` once the branch carries the review +# fixes, or run `make pr-open READY=1` to open (or reuse) the PR and arm it in +# one step. Arming turns on auto-merge, which merges the PR when the +# required checks pass. # Refuses to run from develop/release. # # Idempotent: safe to rerun. If a PR is already open for the branch, reuses it. -# Without READY=1 this does not re-enable auto-merge — run `make pr-ready` -# when the branch is final. READY=1 arms only after reusing an already-open -# PR; a newly created PR prints the exact pr-ready action and stays held. +# READY=1 arms through `make pr-arm`, which checks the live PR (hold label, +# requested changes, unresolved review threads, draft state, head) before it +# enqueues the exact local HEAD. READY=1 with EVIDENCE or BATCH keeps its +# earlier meaning: the evidence transaction (`pr-ready`) for a reused PR. # # Pre-push warning: runs `git merge-tree` against every other open PR head # (pure git, ~1s, no CI) and prints any textual conflicts so you can coordinate # before landing. Warn-only — does not block the push. # -# Currency: after fetching origin/develop, an ancestor-only branch is accepted -# by the native queue only after the live ruleset checker proves the queue and -# its required protections. The merge-tree probe still blocks genuine base -# conflicts. If the queue is absent, unknown, or misconfigured, the existing -# current-base gate remains in force; `pr-refresh` is the only refresh path. +# Currency: after fetching origin/develop, a branch behind it is published as +# is; the merge-tree probe refuses only a genuine base conflict. Required +# checks and auto-merge gate the merge, and the post-merge trunk run covers +# the combined result. `pr-refresh` remains the explicit way to refresh a branch. +# +# Trunk gate: a non-revert branch is refused while the newest completed +# `trunk.yml` run on develop has been red for more than 30 minutes. Revert +# branches (`fix/revert-*`, see `make trunk-revert`) are exempt. +.PHONY: pr-open pr-open: @set -eu; \ $(MAKE) -s agent-write-preflight; \ @@ -1519,10 +1618,6 @@ pr-open: echo "PR_BODY_FILE does not exist: $(PR_BODY_FILE)" >&2; \ exit 1; \ fi; \ - if [ "$(READY)" = "1" ] && [ -z "$(EVIDENCE)" ]; then \ - echo "EVIDENCE is required when READY=1; use make pr-open without READY=1 to publish a held PR." >&2; \ - exit 2; \ - fi; \ REPOSITORY="$(or $(REPO),BenchBox-dev/BenchBox)"; \ case "$$REPOSITORY" in \ */*/*|/*|*/|"") echo "REPO must be a GitHub owner/name identity" >&2; exit 2 ;; \ @@ -1559,21 +1654,8 @@ pr-open: echo "Refusing to open PR: HEAD conflicts with origin/develop. Resolve the conflict first; no refresh merge is attempted." >&2; \ exit 1; \ fi; \ - QUEUE_REPORT=$$(mktemp); \ - trap 'rm -f "$$QUEUE_REPORT"' EXIT; \ - if ! gh auth token 2>/dev/null | uv run -- python scripts/ruleset_drift_check.py --queue-policy \ - --require-bypass-actor-visibility --repo "$$REPOSITORY" --token-stdin \ - --output "$$QUEUE_REPORT"; then \ - echo "Refusing to open PR: native merge queue and its protections could not be verified. Run 'make pr-refresh' to satisfy the current-base gate." >&2; \ - exit 1; \ - fi; \ - if ! DECISION=$$(uv run -- python scripts/pr_landing.py --worktree . queue-policy --queue-report "$$QUEUE_REPORT"); then \ - echo "Refusing to open PR: queue policy did not authorize stale-base publication. Run 'make pr-refresh'." >&2; \ - exit 1; \ - fi; \ - [ "$$DECISION" = "publish-without-refresh" ] || { echo "Refusing to open PR: unexpected stale-base decision $$DECISION." >&2; exit 1; }; \ - echo "Verified native merge queue: publishing without an author-side base refresh."; \ fi; \ + uv run -- python scripts/trunk_revert.py gate --branch "$$CURRENT" --repo "$$REPOSITORY"; \ $(MAKE) -s pr-conflict-scan BRANCH="$$CURRENT" || true; \ git push -u origin "$$CURRENT" || { echo "Push failed for $$CURRENT — aborting before opening a PR (remote branch may be stale)." >&2; exit 1; }; \ REUSED_PR=0; \ @@ -1596,12 +1678,17 @@ pr-open: fi && \ echo "$$URL" && \ if [ "$(READY)" != "1" ]; then \ - echo "Auto-merge withheld. Run 'make pr-ready' when the branch is final, or rerun 'make pr-open READY=1' to arm a reused reviewed PR."; \ - elif [ "$$REUSED_PR" != "1" ]; then \ - echo "PR created and held; READY=1 does not arm a newly created PR."; \ - echo "Next action after review: make pr-ready REPO=\"$$REPOSITORY\" URL=\"$$URL\" HEAD=\"$$(git rev-parse HEAD)\" EVIDENCE=\"\""; \ + echo "Next: make pr-arm once the review fixes are pushed; auto-merge merges it when the required checks pass."; \ + elif [ -n "$(EVIDENCE)$(BATCH)" ]; then \ + if [ "$$REUSED_PR" != "1" ]; then \ + echo "READY=1 with EVIDENCE or BATCH applies to a reused PR, so this new PR is not armed."; \ + echo "Arm it after review with make pr-arm, or with the evidence transaction: make pr-ready REPO=\"$$REPOSITORY\" URL=\"$$URL\" HEAD=\"$$(git rev-parse HEAD)\" EVIDENCE=\"\""; \ + else \ + $(MAKE) -s pr-ready REPO="$$REPOSITORY" URL="$$URL" HEAD="$$(git rev-parse HEAD)" EVIDENCE="$(EVIDENCE)" BATCH="$(BATCH)"; \ + fi; \ else \ - $(MAKE) -s pr-ready REPO="$$REPOSITORY" URL="$$URL" HEAD="$$(git rev-parse HEAD)" EVIDENCE="$(EVIDENCE)" BATCH="$(BATCH)"; \ + PR_NUMBER=$$(gh pr view --repo "$$REPOSITORY" "$$URL" --json number --jq '.number') || { echo "Could not resolve $$URL to a PR in $$REPOSITORY" >&2; exit 1; }; \ + $(MAKE) -s pr-arm PR="$$PR_NUMBER" REPO="$$REPOSITORY" HEAD="$$(git rev-parse HEAD)"; \ fi # Runs the readiness transaction for an already-open PR. This compatibility @@ -1611,6 +1698,7 @@ pr-open: # was only durable against paths that never arm (workflow + sweep) while the # one live arm path ignored it — #1626 was armed 52s after being labeled. See # _project/decisions/auto-merge-policy-consolidation-2026-08-06.md (D3). +.PHONY: pr-arm-auto-merge pr-arm-auto-merge: @set -eu; \ [ -n "$(EVIDENCE)" ] || { echo "EVIDENCE is required; use pr-landing-ready with exact readiness evidence" >&2; exit 2; }; \ @@ -1629,9 +1717,50 @@ pr-arm-auto-merge: $(MAKE) -s pr-landing-ready REPO="$$REPOSITORY" PR="$$PR_NUMBER" HEAD="$$EXPECTED_HEAD" \ EVIDENCE="$(EVIDENCE)" BATCH="$(BATCH)" ARM=1 -# Declares the branch final and arms auto-merge / queue enrollment. +# Arms a PR for its exact head after a live hold check and enqueues it into the merge queue. +# `make pr-arm` (PR= optional, HEAD= optional) refuses without merging when a hold label, +# a requested change, a draft or closed state, or a head other than local HEAD applies. +# The values reach the helper through the environment, never as words in a shell command, so a value +# such as `PR='7 --pr 8'` cannot add arguments. `_SET` tells an omitted variable from an empty one and +# is computed before `override`, which makes a variable look defined. Make expands a recursive +# command-line value when it exports it, so a `$(shell ...)` typed on the command line runs for every +# target in this Makefile; that is the invoker's own input and is not something a wrapper can prevent. +# The target-specific overrides keep PR, HEAD and REPO literal in this target's environment. A global +# override would also change how every other target's recipe sees them (an unexpanded `$(cmd)` would +# reach their shell), which test_pr_arm.py pins. +PR_ARM_PR_SET := $(if $(filter undefined,$(origin PR)),,1) +PR_ARM_HEAD_SET := $(if $(filter undefined,$(origin HEAD)),,1) +PR_ARM_REPO_SET := $(if $(filter undefined,$(origin REPO)),,1) +.PHONY: pr-arm +pr-arm: override PR := $(value PR) +pr-arm: override HEAD := $(value HEAD) +pr-arm: override REPO := $(value REPO) +pr-arm: export PR_ARM_PR := $(PR) +pr-arm: export PR_ARM_PR_SET := $(PR_ARM_PR_SET) +pr-arm: export PR_ARM_HEAD := $(HEAD) +pr-arm: export PR_ARM_HEAD_SET := $(PR_ARM_HEAD_SET) +pr-arm: export PR_ARM_REPO := $(REPO) +pr-arm: export PR_ARM_REPO_SET := $(PR_ARM_REPO_SET) +pr-arm: + @uv run -- python scripts/pr_arm.py + +# Opens a revert PR for a merged PR: `make trunk-revert PR=`. Creates a +# sibling worktree on fix/revert- with `worktree-create`, reverts the +# squash merge commit there, pushes, and opens the PR against develop. This +# worktree is left untouched. Revert branches are exempt from the red-trunk +# gate in `pr-open`. Refuses a PR that is not merged; the primary clone is +# refused by `agent-write-preflight`. +.PHONY: trunk-revert +trunk-revert: + @$(MAKE) -s agent-write-preflight + @case "$(PR)" in ""|*[!0-9]*) echo "PR= is required" >&2; exit 2 ;; esac + @uv run -- python scripts/trunk_revert.py revert --pr "$(PR)" --repo "$(or $(REPO),BenchBox-dev/BenchBox)" + +# Arms an open PR for an exact HEAD. Without EVIDENCE or BATCH this is +# `make pr-arm` for the PR; with either one it runs the readiness evidence +# transaction (`pr-arm-auto-merge`), which also validates a registered batch. # -# Auto-merge is NOT armed by `pr-open`, because arming at creation is only +# Auto-merge is not armed when `pr-open` creates a PR, because arming at creation is only # correct if nothing more will be pushed - and the usual reason something more # is pushed is review feedback, which arrives after the PR exists. A PR armed # at creation can satisfy its checks and merge while the follow-up commit is @@ -1639,18 +1768,32 @@ pr-arm-auto-merge: # orphaned on a closed branch. That happened three times in one session # (#1503, #1521, #1531); the last two stranded the very commits that addressed # their own review findings. +.PHONY: pr-ready pr-ready: @[ -n "$(PR)" ] || [ -n "$(URL)" ] || { echo "PR or URL is required" >&2; exit 2; } @[ -n "$(HEAD)" ] || { echo "HEAD is required" >&2; exit 2; } - @[ -n "$(EVIDENCE)" ] || { echo "EVIDENCE is required" >&2; exit 2; } - @$(MAKE) -s pr-arm-auto-merge REPO="$(or $(REPO),BenchBox-dev/BenchBox)" PR="$(PR)" URL="$(URL)" HEAD="$(HEAD)" EVIDENCE="$(EVIDENCE)" BATCH="$(BATCH)" + @if [ -n "$(EVIDENCE)$(BATCH)" ]; then \ + $(MAKE) -s pr-arm-auto-merge REPO="$(or $(REPO),BenchBox-dev/BenchBox)" PR="$(PR)" URL="$(URL)" HEAD="$(HEAD)" EVIDENCE="$(EVIDENCE)" BATCH="$(BATCH)"; \ + elif [ -n "$(URL)" ]; then \ + [ -z "$(PR)" ] || { echo "PR and URL are mutually exclusive" >&2; exit 2; }; \ + REPOSITORY="$(or $(REPO),BenchBox-dev/BenchBox)"; \ + PR_INFO=$$(gh pr view --repo "$$REPOSITORY" "$(URL)" --json number,url --jq '"\(.number) \(.url)"') || { echo "Could not resolve URL to a PR in $$REPOSITORY" >&2; exit 1; }; \ + PR_NUMBER="$${PR_INFO%% *}"; PR_URL=$$(printf '%s' "$${PR_INFO#* }" | tr 'A-Z' 'a-z'); \ + WANT=$$(printf '%s' "$$REPOSITORY" | tr 'A-Z' 'a-z'); \ + case "$$PR_URL" in "https://github.com/$$WANT/pull/"*) ;; *) echo "URL names a PR outside $$REPOSITORY; refusing to arm it" >&2; exit 2 ;; esac; \ + $(MAKE) -s pr-arm PR="$$PR_NUMBER" REPO="$$REPOSITORY" HEAD="$(HEAD)"; \ + else \ + $(MAKE) -s pr-arm PR="$(PR)" REPO="$(or $(REPO),BenchBox-dev/BenchBox)" HEAD="$(HEAD)"; \ + fi +.PHONY: shrink-rollup shrink-rollup: @git fetch origin develop --quiet @uv run --project _project/scripts -- python _project/scripts/shrink_rollup.py # Walk every worktree (except the main clone) and run `make pr-open` in each. # Use PR_FANOUT_JOBS to bound parallelism. +.PHONY: pr-fanout pr-fanout: @MAIN_CLONE=$$(dirname "$$(realpath "$$(git rev-parse --git-common-dir)")"); \ TMP=$$(mktemp); \ @@ -1672,11 +1815,11 @@ pr-fanout: # Refresh the current PR branch onto origin/develop, then run pr-open. # This is the stale-PR escape hatch when required checks must be current with # develop: GitHub can show a PR as CLEAN even though merge is waiting for a -# branch update. pr-refresh does NOT re-enable auto-merge on its own (pr-open -# withholds unless READY=1 reuses an already-open reviewed PR); run -# `make pr-ready` when the branch is final. +# branch update. pr-refresh does not arm on its own (pr-open arms only with +# READY=1); run `make pr-arm` once the refreshed branch is pushed. # Run this one stale PR at a time; updating several branches at once can let # the first merge stale the others again under strict checks. +.PHONY: pr-refresh pr-refresh: @CURRENT=$$(git branch --show-current); \ case "$$CURRENT" in \ @@ -1697,6 +1840,7 @@ pr-refresh: # `changed in both` / `added in both` / `removed in {local,remote}` lines are # informational trivial-merge headers, not conflicts, and its real conflict # markers are diff-prefixed (`+<<<<<<< .our`), so `^<<<<<<<` never matches. +.PHONY: pr-conflict-scan pr-conflict-scan: @CURRENT="$(BRANCH)"; \ [ -n "$$CURRENT" ] || CURRENT=$$(git branch --show-current); \ @@ -1713,6 +1857,7 @@ pr-conflict-scan: done; true # Show open PRs against develop and their CI + auto-merge state. +.PHONY: pr-status pr-status: @if [ "$(ALL_OPEN)" = "1" ] || [ "$(ALL_OPEN)" = "true" ] || [ "$(ALL_OPEN)" = "yes" ]; then \ echo "All open develop PRs (bounded to $(PR_STATUS_ALL_OPEN_LIMIT)):"; \ @@ -1728,6 +1873,7 @@ pr-status: # fresh @codex review trigger. Default --author filter is the # chatgpt-codex-connector bot; override with --author (or by editing # DEFAULT_REVIEW_AUTHORS in the script) to add other reviewers. +.PHONY: pr-review-followups-list pr-review-followups-list: @uv run --project _project/scripts -- python _project/scripts/pr_review_followups.py list \ --base "$(PR_REVIEW_BASE)" \ @@ -1769,6 +1915,7 @@ pr-review-followups-list: # --allow-dirty and skips comments already # committed locally. Only the literals # 1|true|yes enable it. +.PHONY: pr-review-followups pr-review-followups: @uv run --project _project/scripts -- python _project/scripts/pr_review_followups.py run \ --base "$(PR_REVIEW_BASE)" \ @@ -1785,6 +1932,7 @@ pr-review-followups: $(if $(filter 0 false no,$(PR_REVIEW_SUBMIT)),--no-submit) \ $(if $(filter 1 true yes,$(PR_REVIEW_RESUME)),--resume) +.PHONY: dev-loop-metrics dev-loop-metrics: @set -e; \ TMP=$$(mktemp -d); \ @@ -1853,6 +2001,7 @@ include $(BENCHBOX_MAKEFILE_ROOT)make/worktree-maintenance.mk ## A local branch left behind that historical head is likewise kept. ## ## Run with DRY_RUN=1 to preview without deleting anything. +.PHONY: branch-prune-merged branch-prune-merged: @command -v gh >/dev/null 2>&1 || { echo "gh CLI required for branch-prune-merged" >&2; exit 1; } @DRY_RUN='$(if $(filter 1,$(DRY_RUN)),1,$(if $(strip $(DRY_RUN)),invalid,))' \ @@ -1863,7 +2012,6 @@ branch-prune-merged: # Operator-only; not exposed as `benchbox` CLI subcommands. UAT is a # project-developer concern, benchbox is a project-user concern. # ---------------------------------------------------------------------- -.PHONY: uat-cell uat-execute uat-validate uat-package uat-explorer-smoke uat-report uat-sweep uat-smoke uat-stress uat-bring-up uat-prepull uat-docker-cleanup uat-artifact-hygiene uat-gate-check # Local-artifact hygiene gate. No-op unless an external output root is # configured (BENCHBOX_OUTPUT_DIR or OUTPUT=); when it is, fails if the @@ -1871,12 +2019,14 @@ branch-prune-merged: # landed under the external root (the 2026-06-01 datagen-leak incident). # Report-only: never deletes or moves artifacts. # make uat-artifact-hygiene [OUTPUT=] [THRESHOLD_BYTES=N] +.PHONY: uat-artifact-hygiene uat-artifact-hygiene: @uv run --no-sync -- python -m tests.uat.artifact_hygiene \ $(if $(OUTPUT),--output "$(OUTPUT)",) \ $(if $(THRESHOLD_BYTES),--threshold-bytes "$(THRESHOLD_BYTES)",) # make uat-cell PLATFORM=duckdb BENCHMARK=tpch SCALE=0.01 +.PHONY: uat-cell uat-cell: @if [ -z "$(PLATFORM)" ] || [ -z "$(BENCHMARK)" ] || [ -z "$(SCALE)" ]; then \ echo "Usage: make uat-cell PLATFORM= BENCHMARK= SCALE=" >&2; \ @@ -1892,6 +2042,7 @@ uat-cell: $(if $(LOG_DIR),--log-dir "$(LOG_DIR)",) # make uat-validate RESULTS_DIR= OUTPUT_TSV= [FLOOR=0.80] +.PHONY: uat-validate uat-validate: @if [ -z "$(RESULTS_DIR)" ] || [ -z "$(OUTPUT_TSV)" ]; then \ echo "Usage: make uat-validate RESULTS_DIR= OUTPUT_TSV= [FLOOR=0.80]" >&2; \ @@ -1903,6 +2054,7 @@ uat-validate: $(if $(FLOOR),--floor "$(FLOOR)",) # make uat-report CELLS_JSONL= OUTPUT_TSV= [RUNGS=0.01,0.1,1.0] [CROSS_SCALE_FLOOR=N] +.PHONY: uat-report uat-report: @if [ -z "$(CELLS_JSONL)" ] || [ -z "$(OUTPUT_TSV)" ]; then \ echo "Usage: make uat-report CELLS_JSONL= OUTPUT_TSV= [RUNGS=...] [CROSS_SCALE_FLOOR=N]" >&2; \ @@ -1915,6 +2067,7 @@ uat-report: $(if $(CROSS_SCALE_FLOOR),--cross-scale-floor "$(CROSS_SCALE_FLOOR)",) # make uat-explorer-smoke BUNDLES_DIR= OUTPUT_DIR= LOG_DIR= [BROWSERS=chromium] +.PHONY: uat-explorer-smoke uat-explorer-smoke: @if [ -z "$(BUNDLES_DIR)" ] || [ -z "$(OUTPUT_DIR)" ] || [ -z "$(LOG_DIR)" ]; then \ echo "Usage: make uat-explorer-smoke BUNDLES_DIR= OUTPUT_DIR= LOG_DIR= [BROWSERS=chromium]" >&2; \ @@ -1927,6 +2080,7 @@ uat-explorer-smoke: $(if $(BROWSERS),--browsers "$(BROWSERS)",) # make uat-package CONFIG= SUBMISSIONS_DIR= RESULTS="r1.json r2.json ..." +.PHONY: uat-package uat-package: @if [ -z "$(CONFIG)" ] || [ -z "$(SUBMISSIONS_DIR)" ] || [ -z "$(RESULTS)" ]; then \ echo "Usage: make uat-package CONFIG= SUBMISSIONS_DIR= RESULTS=\"r1.json r2.json ...\"" >&2; \ @@ -1940,6 +2094,7 @@ uat-package: UAT_BRING_UP_KNOWN_PLATFORMS := cedardb clickhouse-server databend doris influxdb lakesail pg-duckdb pg-mooncake postgresql presto questdb singlestore starrocks timescaledb trino velox # make uat-bring-up PLATFORM= [TIMEOUT_S=300] [DRY_RUN=1] [BENCHMARK_RUNS_DIR=~/Developer/benchmark_runs] +.PHONY: uat-bring-up uat-bring-up: $(if $(strip $(PLATFORM)),$(if $(filter $(PLATFORM),$(UAT_BRING_UP_KNOWN_PLATFORMS)),,$(error unknown platform '$(PLATFORM)'; supported: $(UAT_BRING_UP_KNOWN_PLATFORMS))),) @if [ -z "$(PLATFORM)" ]; then \ @@ -1960,6 +2115,7 @@ uat-bring-up: # skipped-unreachable platform. Reuses uat_bring_up.py --prepull-only rather # than a new script (same PLATFORM validation, project-name derivation, # resolved-engine identity print). +.PHONY: uat-prepull uat-prepull: $(if $(strip $(PLATFORM)),$(if $(filter $(PLATFORM),$(UAT_BRING_UP_KNOWN_PLATFORMS)),,$(error unknown platform '$(PLATFORM)'; supported: $(UAT_BRING_UP_KNOWN_PLATFORMS))),) @if [ -z "$(PLATFORM)" ]; then \ @@ -1976,6 +2132,7 @@ uat-prepull: # ENGINE=container reclaims the Apple `container` store (~/Library/Application # Support/com.apple.container); MODE widens breadth owned.yaml [DRY_RUN=1] +.PHONY: uat-sweep uat-sweep: @if [ -z "$(CONFIG)" ]; then \ echo "Usage: make uat-sweep CONFIG= [DRY_RUN=1]" >&2; \ @@ -1994,11 +2152,13 @@ uat-sweep: # make uat-smoke # Native TPCH SF 0.01 smoke loop; no Docker, package, or explorer phase. +.PHONY: uat-smoke uat-smoke: @uv run --no-sync -- python -m tests.uat._cli sweep --config tests/uat/configs/uat-smoke.yaml # make uat-stress [PLATFORM=] [BENCHMARK=] [SCALE=] [CONFIG=] # Canned stress preset using the UAT framework matrix runner. +.PHONY: uat-stress uat-stress: @uv run --no-sync -- python -m tests.uat._cli stress \ $(if $(CONFIG),--config "$(CONFIG)",) \ @@ -2011,6 +2171,7 @@ uat-stress: # each stage's run dir) into _project/release-evidence/uat-gate-summary.json, # enforcing cross-stage Docker ordering and the mechanized APPROVE checklist. # Review and commit the evidence file yourself; nothing here touches git. +.PHONY: uat-gate-check uat-gate-check: @if [ -z "$(STAGE1)" ] || [ -z "$(STAGE2)" ] || [ -z "$(STAGE3)" ]; then \ echo "Usage: make uat-gate-check STAGE1= STAGE2= STAGE3= [OUTPUT=]" >&2; \ @@ -2023,6 +2184,7 @@ uat-gate-check: $(if $(OUTPUT),--output "$(OUTPUT)",) # make uat-execute CONFIG=tests/uat/configs/uat.yaml +.PHONY: uat-execute uat-execute: @if [ -z "$(CONFIG)" ]; then \ echo "Usage: make uat-execute CONFIG=" >&2; \ diff --git a/README.md b/README.md index c8febdd933..ba76b60632 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ a comparison. -- Platform registry: **50** metadata entries; **46** SQL-capable; **18** DataFrame-capable; **14** dual-mode; support status counts: stable=5, beta=28, experimental=16, deprecated=1. +- Platform registry: **52** metadata entries; **48** SQL-capable; **18** DataFrame-capable; **14** dual-mode; support status counts: stable=5, beta=28, experimental=18, deprecated=1. - Benchmark registry: **23** metadata entries; **22** public discovery entries. @@ -185,7 +185,7 @@ submission process. | Choose a benchmark | [Benchmark catalog](docs/benchmarks/index.md) | | Choose a platform | [Platform selection guide](docs/platforms/platform-selection-guide.md) | | Use a DataFrame runtime | [DataFrame platforms](docs/platforms/dataframe.md) | -| Use the Python API | [Python API reference](docs/reference/python-api/index.rst) | +| Use the Python API | [Python API reference](docs/reference/python-api/index.md) | | Find examples | [Examples guide](docs/usage/examples.md) | | Troubleshoot a run | [Troubleshooting guide](docs/usage/troubleshooting.md) | | Understand the design | [Architecture overview](docs/concepts/architecture.md) | @@ -217,7 +217,7 @@ options. Do not put credentials in configuration files that you commit. > **BenchBox is BETA software.** The CLI and core workflows are usable, but > public APIs may change before 1.0. -Current release: v0.4.1. +Current release: v0.4.2. Support labels describe the stability of each public surface. The `benchbox.experimental` namespace has no compatibility guarantee and can change diff --git a/_benchbox_pytest_xdist_safety.py b/_benchbox_pytest_xdist_safety.py index 74e8ea9695..be3092a711 100644 --- a/_benchbox_pytest_xdist_safety.py +++ b/_benchbox_pytest_xdist_safety.py @@ -1,4 +1,4 @@ -"""Early pytest plugin that caps unsafe xdist worker counts on developer Macs.""" +"""Early pytest plugin that caps unsafe xdist worker counts and refuses unsafe pytest settings.""" from __future__ import annotations @@ -6,6 +6,10 @@ import sys from typing import Optional +import pytest + +from tests.utilities import session_isolation + def _safe_worker_count() -> int: """Return a safe pytest-xdist worker count for local development.""" @@ -81,15 +85,62 @@ def _rewrite_numprocesses_args(args: list[str], safe: int) -> str | None: return value -def pytest_load_initial_conftests(early_config, parser, args: list[str]) -> None: - """Cap xdist workers before pytest/xdist creates any worker plan.""" +def _last_option_value(args: list[str], long_name: str, short_name: str) -> str | None: + """Return the value of the last ``-x value``, ``-xvalue``, ``--long value`` or ``--long=value`` option.""" + value: str | None = None + for idx, arg in enumerate(args): + if arg in {short_name, long_name} and idx + 1 < len(args): + value = args[idx + 1] + elif arg.startswith(f"{long_name}="): + value = arg.split("=", 1)[1] + elif arg.startswith(short_name) and not arg.startswith("--") and len(arg) > len(short_name): + value = arg[len(short_name) :] + return value + + +def _wants_parallel_lock(args: list[str]) -> bool: + """Whether this process competes for the shared lock: a parallel controller and no explicit bypass. + + An xdist worker never does: its controller holds the lock for it. xdist sets this variable before it + prepares the worker's configuration, which loads this plugin. A worker cannot verify the controller's + hold on Windows, where the locked lock file is unreadable, so trying to would time out. + """ + if os.environ.get("BENCHBOX_SKIP_TEST_LOCK") or os.environ.get("PYTEST_XDIST_WORKER"): + return False + value = _last_option_value(args, "--numprocesses", "-n") + if value is None: + return False + return value in {"auto", "logical"} or bool(_requested_numprocesses(value)) + + +def _selects_live_tests(args: list[str]) -> bool: + """Whether the effective ``-m`` expression selects a test marked only ``live_integration``. + + Live tests read the caller's real credential files, so a run that selects them keeps the real HOME. + The default expression deselects them. An expression that does not parse is treated as not selecting. + """ + expression = _last_option_value(args, "--markers-expression", "-m") + if not expression: + return False + try: + from _pytest.mark.expression import Expression + + return bool(Expression.compile(expression).evaluate(lambda name, **_: name == "live_integration")) + except Exception: + return False + + +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_load_initial_conftests(early_config, parser, args: list[str]): + """Own lock and HOME before collection, then cap xdist's worker plan.""" + if not _selects_live_tests(args) and session_isolation.start(acquire_lock=_wants_parallel_lock(args)): + early_config.add_cleanup(session_isolation.finish) safe = _safe_worker_count() requested = _rewrite_numprocesses_args(args, safe) - if requested is None: - return - - os.environ["BENCHBOX_XDIST_CAP_REQUESTED"] = requested - os.environ["BENCHBOX_XDIST_CAP_EFFECTIVE"] = str(safe) + if requested is not None: + os.environ["BENCHBOX_XDIST_CAP_REQUESTED"] = requested + os.environ["BENCHBOX_XDIST_CAP_EFFECTIVE"] = str(safe) + return (yield) def pytest_xdist_auto_num_workers(config) -> int: @@ -97,10 +148,31 @@ def pytest_xdist_auto_num_workers(config) -> int: return _safe_worker_count() +FAULTHANDLER_TIMEOUT_REFUSAL = ( + "[benchbox] faulthandler_timeout is not supported here. Its watchdog thread dumps the stack of a test " + "that is still running Python code without holding the GIL, so it can read a frame while that frame is " + "being replaced. On CPython 3.12 the watchdog can then spin forever inside the dump: the stack is cut " + "off mid-dump, the test thread blocks in cancel_dump_traceback_later when the test ends, and the " + "pytest-xdist worker hangs or dies. Use pytest-timeout (--timeout or @pytest.mark.timeout) instead: " + "its signal method prints the stacks from the test thread itself." +) + + +def _faulthandler_timeout(config) -> float: + """Return the effective ``faulthandler_timeout`` ini value, or 0 when the plugin is disabled.""" + try: + return float(config.getini("faulthandler_timeout") or 0.0) + except (ValueError, TypeError): + return 0.0 + + def pytest_configure(config) -> None: if hasattr(config, "workerinput"): return + if _faulthandler_timeout(config) > 0: + pytest.exit(FAULTHANDLER_TIMEOUT_REFUSAL, returncode=pytest.ExitCode.USAGE_ERROR) + requested = os.environ.pop("BENCHBOX_XDIST_CAP_REQUESTED", None) effective = os.environ.pop("BENCHBOX_XDIST_CAP_EFFECTIVE", None) if requested and effective: diff --git a/_binaries/tpc-h/darwin-arm64/checksums.md5 b/_binaries/tpc-h/darwin-arm64/checksums.md5 index 3b58d2390d..25d14debcd 100644 --- a/_binaries/tpc-h/darwin-arm64/checksums.md5 +++ b/_binaries/tpc-h/darwin-arm64/checksums.md5 @@ -1,3 +1,3 @@ -60fd091f0b5dfdfdafcd352a73c81eb8 dbgen -f56daf0bc329d1da241291ce3758ff8d qgen +1e0079c2d6116b4789fecacdfdef6e04 dbgen +8df81d3243928ec231c5491f0bc9728f qgen 878d277d8f6989bf5a3ec0d45f1b4696 dists.dss diff --git a/_binaries/tpc-h/darwin-arm64/dbgen b/_binaries/tpc-h/darwin-arm64/dbgen index ba9c67cda3..7a4f818e93 100755 Binary files a/_binaries/tpc-h/darwin-arm64/dbgen and b/_binaries/tpc-h/darwin-arm64/dbgen differ diff --git a/_binaries/tpc-h/darwin-arm64/qgen b/_binaries/tpc-h/darwin-arm64/qgen index 0dde2a47dd..2de9aacb7e 100755 Binary files a/_binaries/tpc-h/darwin-arm64/qgen and b/_binaries/tpc-h/darwin-arm64/qgen differ diff --git a/_binaries/tpc-h/darwin-x86_64/checksums.md5 b/_binaries/tpc-h/darwin-x86_64/checksums.md5 index 414756ae4f..a234eb198b 100644 --- a/_binaries/tpc-h/darwin-x86_64/checksums.md5 +++ b/_binaries/tpc-h/darwin-x86_64/checksums.md5 @@ -1,3 +1,3 @@ -a80e07b0558aa13c38a3533107f2b1b0 dbgen -4828cdd32878cf8705322ab8c4a3a2b3 qgen +5240ca85f36053646f89c8fe1e1d8ad4 dbgen +a3ecd3a3724c1f3148b164742de94fc4 qgen 878d277d8f6989bf5a3ec0d45f1b4696 dists.dss diff --git a/_binaries/tpc-h/darwin-x86_64/dbgen b/_binaries/tpc-h/darwin-x86_64/dbgen index 1110c826bd..c5278bcd5a 100755 Binary files a/_binaries/tpc-h/darwin-x86_64/dbgen and b/_binaries/tpc-h/darwin-x86_64/dbgen differ diff --git a/_binaries/tpc-h/darwin-x86_64/qgen b/_binaries/tpc-h/darwin-x86_64/qgen index 5f96ea408d..3b0c20767b 100755 Binary files a/_binaries/tpc-h/darwin-x86_64/qgen and b/_binaries/tpc-h/darwin-x86_64/qgen differ diff --git a/_project/scripts/explorer_pipeline/duckdb_builder.py b/_project/scripts/explorer_pipeline/duckdb_builder.py index 6a103f2641..55af47aa16 100644 --- a/_project/scripts/explorer_pipeline/duckdb_builder.py +++ b/_project/scripts/explorer_pipeline/duckdb_builder.py @@ -66,30 +66,6 @@ ("storage_tier", "VARCHAR"), ] -_NORMALIZED_COST_KEYS = frozenset( - { - "normalized_cost_usd", - "cost_model_version", - "cost_model_source", - "cost_scope", - "cost_status", - "billing_unit", - "pricing_region", - "deployment", - } -) -_DEPLOYMENT_KEYS = frozenset( - { - "cloud_provider", - "cloud_region", - "instance_type", - "warehouse_size", - "node_count", - "cluster_size", - "storage_format", - "storage_tier", - } -) _COST_SCOPES = frozenset({"compute_only", "compute_plus_storage"}) _COST_STATUSES = frozenset({"normalized", "not_applicable_local", "unavailable"}) @@ -108,44 +84,40 @@ def _finite_float_or_none(entry: ManifestEntry, key: str, value: Any) -> float | return parsed -def _required_cost_string(entry: ManifestEntry, cost: dict[str, Any], key: str) -> str: - value = cost[key] +def _required_cost_string(entry: ManifestEntry, value: Any, key: str) -> str: if not isinstance(value, str) or not value: raise ValueError(f"{entry.result_id}: normalized_cost.{key} must be a non-empty string") return value -def _required_cost_choice(entry: ManifestEntry, cost: dict[str, Any], key: str, choices: frozenset[str]) -> str: - value = _required_cost_string(entry, cost, key) - if value not in choices: +def _required_cost_choice(entry: ManifestEntry, value: Any, key: str, choices: frozenset[str]) -> str: + checked = _required_cost_string(entry, value, key) + if checked not in choices: raise ValueError(f"{entry.result_id}: normalized_cost.{key} must be one of {sorted(choices)}; got {value!r}") - return value + return checked def _normalized_cost_column_values(entry: ManifestEntry) -> tuple: - """Flatten entry.normalized_cost metadata into the DuckDB column contract.""" + """Flatten entry.normalized_cost metadata into the DuckDB column contract. + + Structural validation (required provenance strings, scope/status + vocabulary) runs here as well as in the transformer ingest because the + legacy ``build()`` entry point accepts hand-built entries whose + ``NormalizedCost`` dataclass instance bypassed that validation: the + dataclass accepts out-of-vocabulary statuses and empty provenance + strings, and ``ManifestEntry`` carries an already-created instance + without revalidation. Finiteness of the numeric payload is enforced here + for the same reason. + """ cost = entry.normalized_cost - missing = _NORMALIZED_COST_KEYS - cost.keys() - if missing: - raise ValueError(f"{entry.result_id}: normalized_cost missing required keys {sorted(missing)}") - - deployment = cost["deployment"] - if not isinstance(deployment, dict): - raise ValueError(f"{entry.result_id}: normalized_cost.deployment must be a dict") - deployment_missing = _DEPLOYMENT_KEYS - deployment.keys() - if deployment_missing: - raise ValueError( - f"{entry.result_id}: normalized_cost.deployment missing required keys {sorted(deployment_missing)}" - ) - return ( - _finite_float_or_none(entry, "normalized_cost_usd", cost["normalized_cost_usd"]), - _required_cost_string(entry, cost, "cost_model_version"), - _required_cost_string(entry, cost, "cost_model_source"), - _required_cost_choice(entry, cost, "cost_scope", _COST_SCOPES), - _required_cost_choice(entry, cost, "cost_status", _COST_STATUSES), - _required_cost_string(entry, cost, "billing_unit"), - _required_cost_string(entry, cost, "pricing_region"), + _finite_float_or_none(entry, "normalized_cost_usd", cost.normalized_cost_usd), + _required_cost_string(entry, cost.cost_model_version, "cost_model_version"), + _required_cost_string(entry, cost.cost_model_source, "cost_model_source"), + _required_cost_choice(entry, cost.cost_scope, "cost_scope", _COST_SCOPES), + _required_cost_choice(entry, cost.cost_status, "cost_status", _COST_STATUSES), + _required_cost_string(entry, cost.billing_unit, "billing_unit"), + _required_cost_string(entry, cost.pricing_region, "pricing_region"), ) @@ -162,14 +134,13 @@ def _environment_facet_column_values(entry: ManifestEntry) -> tuple: def _legacy_cost_deployment_column_values(entry: ManifestEntry) -> tuple: """Keep legacy cost-deployment shape columns for existing cost/chart surfaces.""" - cost = entry.normalized_cost - deployment = cost["deployment"] + deployment = entry.normalized_cost.deployment return ( - deployment["instance_type"], - deployment["warehouse_size"], - deployment["node_count"], - deployment["cluster_size"], - deployment["storage_tier"], + deployment.instance_type, + deployment.warehouse_size, + deployment.node_count, + deployment.cluster_size, + deployment.storage_tier, ) @@ -802,30 +773,30 @@ def _populate_environment( detail = details_map.get(entry.result_id) if detail is None: continue - env = detail.environment or {} - raw_cpu_model = env.get("cpu_model") + env = detail.environment + raw_cpu_model = env.cpu_model if isinstance(raw_cpu_model, str): raw_cpu_model = raw_cpu_model.strip() or None - cpu_family = env.get("cpu_family") or normalize_cpu_family(raw_cpu_model) + cpu_family = env.cpu_family or normalize_cpu_family(raw_cpu_model) if not raw_cpu_model: raw_cpu_model = None cpu_family = None env_rows.append( ( entry.result_id, - env.get("os"), - env.get("arch"), - env.get("cpu_count"), - env.get("memory_gb"), - env.get("python"), + env.os, + env.arch, + env.cpu_count, + env.memory_gb, + env.python, raw_cpu_model, cpu_family, - env.get("cpu_identity_provenance"), - env.get("client_region"), - env.get("client_cloud"), - env.get("statement_overhead_min_ms"), - env.get("statement_overhead_median_ms"), - env.get("link_status"), + env.cpu_identity_provenance, + env.client_region, + env.client_cloud, + env.statement_overhead_min_ms, + env.statement_overhead_median_ms, + env.link_status, ) ) diff --git a/_project/scripts/explorer_pipeline/models.py b/_project/scripts/explorer_pipeline/models.py index b8a8277833..267dbf571d 100644 --- a/_project/scripts/explorer_pipeline/models.py +++ b/_project/scripts/explorer_pipeline/models.py @@ -8,10 +8,11 @@ import math import re +from collections.abc import Mapping from dataclasses import dataclass from typing import Any -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator from benchbox.core.cost.models import NormalizedCost from benchbox.core.cost.pricing import PRICING_VERSION @@ -22,6 +23,11 @@ def unavailable_normalized_cost_payload() -> dict[str, Any]: """Return the explicit normalized-cost unavailable payload for old bundles.""" + return _unavailable_normalized_cost().to_dict() + + +def _unavailable_normalized_cost() -> NormalizedCost: + """Return explicit normalized-cost-unavailable metadata for old bundles.""" return NormalizedCost( normalized_cost_usd=None, cost_model_version=PRICING_VERSION, @@ -30,7 +36,7 @@ def unavailable_normalized_cost_payload() -> dict[str, Any]: cost_status="unavailable", billing_unit="unknown", pricing_region="unknown", - ).to_dict() + ) # --------------------------------------------------------------------------- @@ -219,7 +225,7 @@ class ManifestEntry(BaseModel): validation_status: str | None = None failed_query_count: int = 0 cost_usd: float | None = None - normalized_cost: dict[str, Any] = Field(default_factory=unavailable_normalized_cost_payload) + normalized_cost: NormalizedCost = Field(default_factory=_unavailable_normalized_cost) deployment_class: str | None = None cloud_provider: str | None = None cloud_region: str | None = None @@ -228,6 +234,13 @@ class ManifestEntry(BaseModel): compliance_class: str | None = None basis_availability: BasisAvailability | None = None + @field_serializer("normalized_cost") + def _serialize_normalized_cost(self, cost: NormalizedCost) -> dict[str, Any]: + # The serialized read model keeps the legacy cost payload shape + # (including the deprecated cost_usd alias); the typed dataclass is + # the in-memory working shape only. + return cost.to_dict() + @model_validator(mode="after") def _default_logical_query_count(self) -> ManifestEntry: if self.logical_query_count == 0 and self.query_count > 0: @@ -390,6 +403,34 @@ def _comparison_exclusion_reason( return None +class ExplorerEnvironment(BaseModel): + """Execution-environment block on a DetailResult. + + Verbatim passthrough fields (``os``, ``arch``, ``cpu_count``, + ``memory_gb``, ``python``, ``cpu_identity_provenance``) keep the bundle's + raw values untouched -- legacy bundles vary, so these stay + variant-typed by design. Derived fields (CPU identity, client-link + projection) carry real types because the transformer computes them. + Anything else the bundle recorded rides along as extras. + """ + + model_config = ConfigDict(extra="allow") + + os: Any = None + arch: Any = None + cpu_count: Any = None + memory_gb: Any = None + python: Any = None + cpu_model: str | None = None + cpu_family: str | None = None + cpu_identity_provenance: Any = None + client_region: str | None = None + client_cloud: str | None = None + link_status: str | None = None + statement_overhead_min_ms: float | None = None + statement_overhead_median_ms: float | None = None + + class DetailResult(BaseModel): """Full detail for a single result, used to populate DuckDB detail tables.""" @@ -412,7 +453,7 @@ class DetailResult(BaseModel): display_exclusion_reason: str | None = None comparison_exclusion_reason: str | None = None ranking_exclusion_reason: str | None = None - environment: dict[str, Any] + environment: ExplorerEnvironment queries: list[QueryTiming] display_timings: list[QueryDisplayTiming] = [] has_plans: bool @@ -471,7 +512,7 @@ class DetailResult(BaseModel): validation_status: str | None = None failed_query_count: int = 0 cost_usd: float | None = None - normalized_cost: dict[str, Any] = Field(default_factory=unavailable_normalized_cost_payload) + normalized_cost: NormalizedCost = Field(default_factory=_unavailable_normalized_cost) compliance_class: str | None = None # Phase durations in seconds (None for pre-pipeline rows). phase_durations: dict[str, float] | None = None @@ -494,6 +535,18 @@ class DetailResult(BaseModel): physical_rendering_id: str | None = None basis_availability: BasisAvailability | None = None + @field_serializer("normalized_cost") + def _serialize_normalized_cost(self, cost: NormalizedCost) -> dict[str, Any]: + # Same legacy cost payload shape as ManifestEntry (see above). + return cost.to_dict() + + @field_serializer("environment") + def _serialize_environment(self, env: ExplorerEnvironment) -> dict[str, Any]: + # Only keys the bundle actually provided: exclude_unset reproduces + # the historical verbatim-copy key set instead of adding None for + # every declared field the bundle never recorded. + return env.model_dump(mode="json", exclude_unset=True) + # --------------------------------------------------------------------------- # Ranking eligibility helpers @@ -740,10 +793,404 @@ class MetaRank(BaseModel): primary_order: str | None = None # "asc" | "desc" +# --------------------------------------------------------------------------- +# Bundle input document (schema-v2 result bundle JSON, ingest side) +# --------------------------------------------------------------------------- + + +class _BundleBlock(BaseModel): + """Lenient base for schema-v2 bundle input blocks. + + The explorer input gate (``_ensure_explorer_input_schema``) only pins the + bundle schema version, not block structure, so every nested block carries + legacy variants: missing blocks, explicit nulls, and occasionally a scalar + where a mapping belongs. Coercing all of those to an empty block preserves + the historical ingest behavior (every extractor treats them as "absent") + while letting the rest of the pipeline work with typed models. Unknown + keys are kept (``extra=\"allow\"``) so the typed view never drops + evidence the raw bundle carries. + """ + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + @classmethod + def _coerce_mapping(cls, data: Any) -> Any: + if isinstance(data, Mapping): + return data + return {} + + +class BundleRunBlock(_BundleBlock): + """Typed view of a bundle ``run`` block.""" + + # Timestamp stays untyped: ``_utc_run_date_from_timestamp`` enforces the + # strict UTC-calendar-date grammar and must keep raising its own errors. + timestamp: Any = None + total_duration_ms: float = 0.0 + + @field_validator("total_duration_ms", mode="before") + @classmethod + def _coerce_duration(cls, value: Any) -> Any: + if value is None: + return 0.0 + return value + + +class BundleBenchmarkBlock(_BundleBlock): + """Typed view of a bundle ``benchmark`` block.""" + + id: str = "unknown" + scale_factor: float = 0.0 + test_type: str | None = None + compliance_class: str | None = None + + @field_validator("test_type", mode="before") + @classmethod + def _coerce_test_type(cls, value: Any) -> Any: + # Falsy (including "") means "not recorded"; the extractor falls back + # to the phases block. Verbatim otherwise: no stripping. + return str(value) if value else None + + @field_validator("compliance_class", mode="before") + @classmethod + def _coerce_compliance_class(cls, value: Any) -> Any: + # Verbatim ingest: only an explicit null means "not recorded". + return None if value is None else str(value) + + +class BundleLogicalProfile(_BundleBlock): + """Typed view of ``platform.tuning.logical_profile`` (ADR-2 section 3).""" + + physical_mechanisms: Any = None + physical_rendering_id: Any = None + + +class BundleAppliedBlock(_BundleBlock): + """Typed view of ``platform.tuning.applied``.""" + + receipt: Any = None + + +class BundleTuningBlock(_BundleBlock): + """Typed view of a bundle ``platform.tuning`` summary block.""" + + requested_config_hash: str | None = None + applied_ledger_hash: str | None = None + validation_status: str | None = None + tuning_policy_generation: str | None = None + requested: Any = None + applied: BundleAppliedBlock = Field(default_factory=BundleAppliedBlock) + logical_profile: BundleLogicalProfile | None = None + + @field_validator("logical_profile", mode="before") + @classmethod + def _coerce_logical_profile(cls, value: Any) -> Any: + # A non-object profile is "no profile recorded" (unknown), not an + # empty recording: only a real mapping carries the tri-state split + # `_physical_mechanisms` depends on. + if value is None or isinstance(value, Mapping): + return value + return None + + @field_validator( + "requested_config_hash", + "applied_ledger_hash", + "validation_status", + "tuning_policy_generation", + mode="before", + ) + @classmethod + def _coerce_verbatim_hash(cls, value: Any) -> Any: + # ADR-1 identities are ingested verbatim and display-only; falsy + # values (including explicit nulls) mean "not recorded". + return str(value) if value else None + + +class BundleCloudBlock(_BundleBlock): + """Typed view of a bundle ``platform.cloud`` block.""" + + provider: Any = None + region: Any = None + location: Any = None + + +class BundleComputeBlock(_BundleBlock): + """Typed view of a bundle ``platform.compute`` block.""" + + node_type: Any = None + warehouse_size: Any = None + warehouse: Any = None + cluster_id: Any = None + cluster_name: Any = None + rpu: Any = None + serverless_slots: Any = None + worker_shape: Any = None + driver_shape: Any = None + + +class BundleStorageBlock(_BundleBlock): + """Typed view of a bundle ``platform.storage`` block.""" + + table_format: Any = None + + +class BundlePlatformConfig(_BundleBlock): + """Typed view of a bundle ``platform.config`` block.""" + + execution_mode: Any = None + + +class BundleDeploymentBlock(_BundleBlock): + """Typed view of a bundle ``platform.deployment`` block.""" + + deployment_type: Any = None + endpoint_class: Any = None + cloud_provider: Any = None + cloud_region: Any = None + instance_type: Any = None + warehouse_size: Any = None + node_count: Any = None + cluster_size: Any = None + storage_format: Any = None + storage_tier: Any = None + + +class BundlePlatformRuntime(_BundleBlock): + """Typed view of an ``environment.platform_runtime`` block.""" + + runtime_type: Any = None + + +class BundleContainerBlock(_BundleBlock): + """Typed view of an ``environment.container`` block (presence only).""" + + +class BundlePlatformBlock(_BundleBlock): + """Typed view of a bundle ``platform`` block.""" + + name: str = "unknown" + version: Any = None + client_version: Any = None + config: BundlePlatformConfig = Field(default_factory=BundlePlatformConfig) + tuning: BundleTuningBlock = Field(default_factory=BundleTuningBlock) + deployment: BundleDeploymentBlock = Field(default_factory=BundleDeploymentBlock) + cloud: BundleCloudBlock = Field(default_factory=BundleCloudBlock) + compute: BundleComputeBlock = Field(default_factory=BundleComputeBlock) + storage: BundleStorageBlock = Field(default_factory=BundleStorageBlock) + + @field_validator("name", mode="before") + @classmethod + def _coerce_name(cls, value: Any) -> Any: + if value is None: + return "unknown" + return value + + +class BundleConfigBlock(_BundleBlock): + """Typed view of a bundle top-level ``config`` block.""" + + tuning_mode: Any = None + tuning_config: Any = None + tuning: Any = None + execution_mode: Any = None + mode: Any = None + options: Any = None + + +class BundleExecutionBlock(_BundleBlock): + """Typed view of a bundle top-level ``execution`` block.""" + + tuning_mode: Any = None + execution_mode: Any = None + mode: Any = None + driver_version_actual: Any = None + driver_version_resolved: Any = None + driver_version_requested: Any = None + driver_actual_version: Any = None + driver_resolved_version: Any = None + driver_requested_version: Any = None + + +class BundleSummaryQueries(_BundleBlock): + """Typed view of a bundle ``summary.queries`` block.""" + + total: Any = None + + +class BundleTpcMetrics(_BundleBlock): + """Typed view of a bundle ``summary.tpc_metrics`` block.""" + + power_at_size: Any = None + qphh_at_size: Any = None + qphds_at_size: Any = None + + +class BundleSummaryBlock(_BundleBlock): + """Typed view of a bundle ``summary`` block.""" + + queries: BundleSummaryQueries = Field(default_factory=BundleSummaryQueries) + # ``validation`` is str-or-mapping by schema; ``normalize_validation_status`` + # owns that split, so the leaf stays variant-typed. + validation: Any = None + tpc_metrics: BundleTpcMetrics = Field(default_factory=BundleTpcMetrics) + + +class BundlePhaseBlock(_BundleBlock): + """Typed view of one entry in a bundle ``phases`` block.""" + + duration_ms: Any = None + + +class BundleQueryRow(_BundleBlock): + """One execution row from a bundle ``queries`` list. + + ``query_id`` is resolved at parse time from the legacy ``id`` / ``query_id`` + key split. Numeric leaves stay variant-typed: each consumer coerces them + with its own fallback (skip the row, default to zero, ...), so the model + preserves the raw value instead of pre-deciding. + """ + + query_id: str = "" + run_type: str | None = None + status: str = "pass" + ms: Any = None + execution_time_ms: Any = None + iter: Any = None + stream: Any = None + dataframe_skip_summary: Any = None + + @model_validator(mode="before") + @classmethod + def _resolve_identity(cls, data: Any) -> Any: + if not isinstance(data, Mapping): + return {} + resolved = dict(data) + resolved["query_id"] = str(data.get("id") or data.get("query_id", "")) + if "status" in data and data["status"] is None: + # Explicit null is not a pass; downstream normalizes any + # non-allow-listed value to "fail". + resolved["status"] = "fail" + return resolved + + @field_validator("run_type", mode="before") + @classmethod + def _coerce_run_type(cls, value: Any) -> Any: + if value is None: + return None + return value if isinstance(value, str) else str(value) + + +class BundleStatementOverhead(_BundleBlock): + """Typed view of ``environment.client_link.statement_overhead_ms``.""" + + min: Any = None + median: Any = None + + +class BundleClientLink(_BundleBlock): + """Typed view of an ``environment.client_link`` block.""" + + collection_status: Any = None + client_region: Any = None + client_cloud: Any = None + statement_overhead_ms: BundleStatementOverhead = Field(default_factory=BundleStatementOverhead) + + +class BundleEnvironmentBlock(_BundleBlock): + """Typed view of a bundle ``environment`` block.""" + + os: Any = None + arch: Any = None + cpu_count: Any = None + memory_gb: Any = None + python: Any = None + cpu_model: Any = None + cpu_identity_provenance: Any = None + platform_runtime: BundlePlatformRuntime = Field(default_factory=BundlePlatformRuntime) + container: BundleContainerBlock = Field(default_factory=BundleContainerBlock) + client_link: BundleClientLink = Field(default_factory=BundleClientLink) + + +class BundleProvenanceBlock(_BundleBlock): + """Typed view of a bundle ``provenance`` block.""" + + funding: Any = None + + +class BundleDocument(_BundleBlock): + """Typed view of one schema-v2 result bundle for explorer ingest. + + This is the ingest-side counterpart to the ``ManifestEntry`` / + ``DetailResult`` read models: every transformer extractor takes this + document (or one of its blocks) instead of a raw ``dict[str, Any]``. + Only the fields the explorer projects are declared; everything else + rides along as extras. + """ + + run: BundleRunBlock = Field(default_factory=BundleRunBlock) + benchmark: BundleBenchmarkBlock = Field(default_factory=BundleBenchmarkBlock) + platform: BundlePlatformBlock = Field(default_factory=BundlePlatformBlock) + config: BundleConfigBlock = Field(default_factory=BundleConfigBlock) + execution: BundleExecutionBlock = Field(default_factory=BundleExecutionBlock) + summary: BundleSummaryBlock = Field(default_factory=BundleSummaryBlock) + phases: dict[str, BundlePhaseBlock] = Field(default_factory=dict) + queries: list[BundleQueryRow] = Field(default_factory=list) + environment: BundleEnvironmentBlock = Field(default_factory=BundleEnvironmentBlock) + provenance: BundleProvenanceBlock = Field(default_factory=BundleProvenanceBlock) + cost: dict[str, Any] | None = Field(default=None) + normalized_cost: dict[str, Any] | None = Field(default=None) + + @field_validator("phases", mode="before") + @classmethod + def _coerce_phases(cls, value: Any) -> Any: + if not isinstance(value, Mapping): + return {} + return {str(name): block for name, block in value.items()} + + @field_validator("queries", mode="before") + @classmethod + def _coerce_queries(cls, value: Any) -> Any: + # Non-object rows are skipped downstream; drop them at parse time so + # the typed list only carries real execution rows. + if not isinstance(value, list): + return [] + return [row for row in value if isinstance(row, Mapping)] + + @field_validator("cost", "normalized_cost", mode="before") + @classmethod + def _coerce_cost_block(cls, value: Any) -> Any: + # Cost blocks stay raw mappings: ``_normalized_cost_from_block`` owns + # their strict validation and error messages. ``None`` (absent or + # explicit null) stays ``None`` so absence is distinguishable from an + # explicitly empty mapping, which must fail strict validation rather + # than degrade to synthetic unavailable metadata. + if value is None: + return None + if isinstance(value, Mapping): + return dict(value) + return None + + __all__ = [ "BenchmarkSummary", + "BundleAppliedBlock", + "BundleBenchmarkBlock", + "BundleConfigBlock", + "BundleDeploymentBlock", + "BundleDocument", + "BundleEnvironmentBlock", + "BundleExecutionBlock", + "BundleLogicalProfile", + "BundlePlatformBlock", + "BundleProvenanceBlock", + "BundleQueryRow", + "BundleRunBlock", + "BundleSummaryBlock", + "BundleTuningBlock", "CANONICAL_BENCHMARK_ALIASES", "DetailResult", + "ExplorerEnvironment", "ManifestEntry", "PercentileStats", "PlatformRow", diff --git a/_project/scripts/explorer_pipeline/pipeline.py b/_project/scripts/explorer_pipeline/pipeline.py index 97f0643c2e..f7d948fff2 100644 --- a/_project/scripts/explorer_pipeline/pipeline.py +++ b/_project/scripts/explorer_pipeline/pipeline.py @@ -42,6 +42,7 @@ CompanionPrivacyError, _applied_receipt, _override_display, + _parse_bundle, _platform_percentile_stats, _public_companion_bytes, _sanitize_applied_receipt, @@ -298,7 +299,7 @@ def _public_applied_receipt( published before the move. Sanitization is unchanged either way: the public path re-scrubs whatever it finds. """ - receipt_json = _applied_receipt(bundle_path, bundle_data) + receipt_json = _applied_receipt(bundle_path, _parse_bundle(bundle_data) if bundle_data is not None else None) if receipt_json is None: return None try: diff --git a/_project/scripts/explorer_pipeline/transformer.py b/_project/scripts/explorer_pipeline/transformer.py index fde8428693..40b9f2b536 100644 --- a/_project/scripts/explorer_pipeline/transformer.py +++ b/_project/scripts/explorer_pipeline/transformer.py @@ -20,7 +20,13 @@ from _project.scripts.explorer_pipeline.models import ( BasisAvailability, + BundleContainerBlock, + BundleDocument, + BundleLogicalProfile, + BundlePlatformRuntime, + BundleTuningBlock, DetailResult, + ExplorerEnvironment, ManifestEntry, PercentileStats, QueryDisplayTiming, @@ -308,6 +314,17 @@ def _load_bundle(bundle_path: Path) -> tuple[dict[str, Any], bytes]: return data, raw +def _parse_bundle(data: dict[str, Any]) -> BundleDocument: + """Parse validated bundle data into the typed ingest document. + + Callers pass the raw bundle mapping (source or anonymized public lane); + the schema gate must already have accepted it via + ``_ensure_explorer_input_schema``. All field extractors below take the + resulting document, never raw mappings. + """ + return BundleDocument.model_validate(data) + + def _ensure_explorer_input_schema(data: dict[str, Any]) -> None: """Reject unsupported bundles before explorer field projection starts.""" decision = EXPLORER_INPUT_SCHEMA_POLICY.evaluate(result_schema_version_value(data)) @@ -353,7 +370,7 @@ def _run_date_from_timestamp(timestamp: object) -> str: return timestamp[:10].replace("-", "") -def _driver_version(data: dict[str, Any]) -> str | None: +def _driver_version(bundle: BundleDocument) -> str | None: """Extract the package version used to identify a DuckDB run. DuckDB development wheels can report an internal engine build string from @@ -362,11 +379,8 @@ def _driver_version(data: dict[str, Any]) -> str | None: is the stable comparison identity; the raw bundle still retains the actual engine string for auditability. """ - execution = data.get("execution", {}) - if not isinstance(execution, dict): - execution = {} - platform = data.get("platform", {}) - is_duckdb = isinstance(platform, dict) and str(platform.get("name", "")).lower() == "duckdb" + execution = bundle.execution + is_duckdb = str(bundle.platform.name).lower() == "duckdb" if is_duckdb: for key in ( "driver_version_resolved", @@ -374,10 +388,10 @@ def _driver_version(data: dict[str, Any]) -> str | None: "driver_resolved_version", "driver_requested_version", ): - val = execution.get(key) + val = getattr(execution, key) if val and isinstance(val, str): return val - client_version = platform.get("client_version") if isinstance(platform, dict) else None + client_version = bundle.platform.client_version if client_version and isinstance(client_version, str) and client_version != "unknown": return client_version for key in ( @@ -388,43 +402,36 @@ def _driver_version(data: dict[str, Any]) -> str | None: "driver_resolved_version", "driver_requested_version", ): - val = execution.get(key) + val = getattr(execution, key) if val and isinstance(val, str): return val return None -def _power_score(data: dict[str, Any]) -> float | None: +def _power_score(bundle: BundleDocument) -> float | None: """Extract TPC power@size metric from a schema-v2 bundle.""" - summary = data.get("summary", {}) - tpc = summary.get("tpc_metrics", {}) if isinstance(summary, dict) else {} - if isinstance(tpc, dict): - for key in ("power_at_size", "qphh_at_size", "qphds_at_size"): - val = tpc.get(key) - if val is not None: - try: - return float(val) - except (TypeError, ValueError): - pass + tpc = bundle.summary.tpc_metrics + for key in ("power_at_size", "qphh_at_size", "qphds_at_size"): + val = getattr(tpc, key) + if val is not None: + try: + return float(val) + except (TypeError, ValueError): + pass return None -def _geomean_ms(data: dict[str, Any]) -> float | None: +def _geomean_ms(bundle: BundleDocument) -> float | None: """Compute geometric mean of measurement-query execution times in milliseconds. Uses only queries with run_type="measurement" (or all when run_type is absent). Returns None if no valid positive timings exist. """ - raw_queries: list[dict[str, Any]] = data.get("queries", []) values: list[float] = [] - for q in raw_queries: - if not isinstance(q, dict): - continue - run_type = q.get("run_type") - if run_type is not None and run_type != "measurement": + for q in bundle.queries: + if q.run_type is not None and q.run_type != "measurement": continue - ms_raw = q.get("ms") - duration_ms_raw = ms_raw if ms_raw is not None else q.get("execution_time_ms") + duration_ms_raw = q.ms if q.ms is not None else q.execution_time_ms if duration_ms_raw is None: continue try: @@ -441,30 +448,30 @@ def _geomean_ms(data: dict[str, Any]) -> float | None: _FUNDING_SOURCES = ("employer", "personal", "free-trial", "vendor-sponsored", "grant", "unspecified") -def _funding(data: dict[str, Any]) -> str: +def _funding(bundle: BundleDocument) -> str: """Extract the funding disclosure from a bundle's provenance block. Returns the normalized funding value, defaulting to "unspecified" when the bundle declares no provenance or an unrecognized value. Kept in lockstep with benchbox/core/results/provenance.py::FUNDING_SOURCES. """ - provenance = data.get("provenance") - if not isinstance(provenance, dict): - return "unspecified" - token = str(provenance.get("funding") or "").strip().lower() + token = str(bundle.provenance.funding or "").strip().lower() return token if token in _FUNDING_SOURCES else "unspecified" -def _platform_version(data: dict[str, Any]) -> str | None: +def _platform_version(bundle: BundleDocument) -> str | None: """Extract platform version from schema-v2 bundle.""" - platform = data.get("platform", {}) - if not isinstance(platform, dict): - return None - val = platform.get("version") + val = bundle.platform.version return str(val) if val and val != "unknown" else None -#: Ordered key paths consulted for a bundle's SQL-vs-DataFrame execution mode. +#: Values a bundle may legitimately carry for execution mode. +_EXECUTION_MODES = frozenset({"sql", "dataframe"}) + +#: Documented lookup order for a bundle's SQL-vs-DataFrame execution mode, kept +#: in lockstep with the candidate list in :func:`_execution_mode`: +#: ``config.execution_mode``, ``platform.config.execution_mode``, +#: ``config.mode``, ``execution.execution_mode``. #: #: Measured against all 207 published bundles on 2026-08-23: #: - ``config.execution_mode`` and ``execution.execution_mode`` are the @@ -478,39 +485,32 @@ def _platform_version(data: dict[str, Any]) -> str | None: #: #: ``execution.mode`` is deliberately NOT consulted: it reads "sql" for 105 #: DataFrame runs, so using it would label more than half the corpus wrongly. -_EXECUTION_MODE_KEY_PATHS: tuple[tuple[str, ...], ...] = ( - ("config", "execution_mode"), - ("platform", "config", "execution_mode"), - ("config", "mode"), - ("execution", "execution_mode"), -) - -#: Values a bundle may legitimately carry for execution mode. -_EXECUTION_MODES = frozenset({"sql", "dataframe"}) -def _execution_mode(data: dict[str, Any]) -> str | None: +def _execution_mode(bundle: BundleDocument) -> str | None: """Extract execution mode (sql/dataframe) from a schema-v2 bundle. - Walks :data:`_EXECUTION_MODE_KEY_PATHS` in order and returns the first + Consults the documented locations in order and returns the first recognized value. A bundle that records no mode, or records something outside the known vocabulary, stays ``None`` rather than being guessed -- an invented mode would label a DataFrame result as SQL, which is worse than an honestly empty facet. """ - for path in _EXECUTION_MODE_KEY_PATHS: - node: Any = data - for key in path: - if not isinstance(node, dict): - node = None - break - node = node.get(key) + # Lookup order (see the module comment above): config.execution_mode, + # platform.config.execution_mode, config.mode, execution.execution_mode. + candidates: list[Any] = [ + bundle.config.execution_mode, + bundle.platform.config.execution_mode, + bundle.config.mode, + bundle.execution.execution_mode, + ] + for node in candidates: if node and str(node).lower() in _EXECUTION_MODES: return str(node).lower() return None -def _tuning_mode(data: dict[str, Any]) -> str | None: +def _tuning_mode(bundle: BundleDocument) -> str | None: """Extract tuning mode from a schema-v2 bundle. Reads ``config.tuning_mode`` first (the current schema location), falling @@ -530,20 +530,16 @@ def _tuning_mode(data: dict[str, Any]) -> str | None: (``None`` here; the explorer surfaces that as the "Not Recorded" state) rather than silently reclassified. """ - config = data.get("config", {}) - if isinstance(config, dict): - val = config.get("tuning_mode") - if val and is_canonical_mode(str(val)): - return str(val) - execution = data.get("execution", {}) - if isinstance(execution, dict): - val = execution.get("tuning_mode") - if val and is_canonical_mode(str(val)): - return str(val) + config = bundle.config + if config.tuning_mode and is_canonical_mode(str(config.tuning_mode)): + return str(config.tuning_mode) + execution = bundle.execution + if execution.tuning_mode and is_canonical_mode(str(execution.tuning_mode)): + return str(execution.tuning_mode) return None -def _tuning_hash(data: dict[str, Any]) -> str | None: +def _tuning_hash(bundle: BundleDocument) -> str | None: """Compute a stable 8-char hash of the tuning configuration. Only machine-readable tuning detail is hashed: a dict is hashed @@ -555,13 +551,12 @@ def _tuning_hash(data: dict[str, Any]) -> str | None: its own when no machine-readable detail is present. Returns None when there is neither a mode nor machine-readable detail to hash. """ - mode = _tuning_mode(data) - config = data.get("config", {}) + mode = _tuning_mode(bundle) + config = bundle.config tuning_detail: dict[str, Any] | None = None - if isinstance(config, dict): - raw_detail = config.get("tuning_config") or config.get("tuning") - if isinstance(raw_detail, dict): - tuning_detail = raw_detail + raw_detail = config.tuning_config or config.tuning + if isinstance(raw_detail, dict): + tuning_detail = raw_detail # Non-dict detail (e.g. a repr() string) is intentionally not hashed: # it is not canonical, so cosmetic differences would change the hash # even when the underlying tuning configuration is identical. @@ -571,22 +566,19 @@ def _tuning_hash(data: dict[str, Any]) -> str | None: return hashlib.sha256(payload.encode()).hexdigest()[:8] -def _tuning_summary(data: dict[str, Any]) -> dict[str, Any] | None: - """Return the ``platform.tuning`` summary block, or None when absent. +def _tuning_summary(bundle: BundleDocument) -> BundleTuningBlock: + """Return the ``platform.tuning`` summary block. This is the per-platform tuning summary emitted by ``benchbox/core/results/schema.py::_build_tuning_summary`` -- the same - block that carries ``logical_profile`` (see ``_logical_profile``). None - for legacy bundles that never recorded a tuning summary. + block that carries ``logical_profile`` (see ``_logical_profile``). The + document always carries a (possibly empty) block; extractors read + ``None`` fields off it for legacy bundles that never recorded a summary. """ - platform = data.get("platform", {}) - if not isinstance(platform, dict): - return None - tuning = platform.get("tuning", {}) - return tuning if isinstance(tuning, dict) else None + return bundle.platform.tuning -def _requested_config_hash(data: dict[str, Any]) -> str | None: +def _requested_config_hash(bundle: BundleDocument) -> str | None: """Ingest the ADR-1 canonical requested-config hash from the bundle. Read verbatim from ``platform.tuning.requested_config_hash`` (emitted by @@ -594,14 +586,11 @@ def _requested_config_hash(data: dict[str, Any]) -> str | None: that predate the field. Display-only, like ``tuning_hash`` -- never a join/dedup/grouping key. """ - summary = _tuning_summary(data) - if summary is None: - return None - val = summary.get("requested_config_hash") - return str(val) if val else None + summary = _tuning_summary(bundle) + return summary.requested_config_hash -def _applied_ledger_hash(data: dict[str, Any]) -> str | None: +def _applied_ledger_hash(bundle: BundleDocument) -> str | None: """Ingest the ADR-1 physical applied-ledger hash from the bundle. Read verbatim from ``platform.tuning.applied_ledger_hash`` (emitted by @@ -609,14 +598,11 @@ def _applied_ledger_hash(data: dict[str, Any]) -> str | None: recomputed here. None for legacy bundles, or new-generation bundles whose applied ledger recorded no executed statements. Display-only. """ - summary = _tuning_summary(data) - if summary is None: - return None - val = summary.get("applied_ledger_hash") - return str(val) if val else None + summary = _tuning_summary(bundle) + return summary.applied_ledger_hash -def _tuning_validation_status(data: dict[str, Any]) -> str | None: +def _tuning_validation_status(bundle: BundleDocument) -> str | None: """Ingest the ADR-1 tuning verified-state from the bundle. Read verbatim from ``platform.tuning.validation_status`` (emitted by @@ -627,34 +613,22 @@ def _tuning_validation_status(data: dict[str, Any]) -> str | None: catalog. ``None`` for legacy bundles predating the field -- the explorer treats absence as "unknown". Display-only; never a join/match key. """ - summary = _tuning_summary(data) - if summary is None: - return None - val = summary.get("validation_status") - return str(val) if val else None + summary = _tuning_summary(bundle) + return summary.validation_status -def _has_requested_tuning(bundle_data: dict[str, Any] | None) -> bool: +def _has_requested_tuning(bundle: BundleDocument | None) -> bool: """Return True when the bundle carries a requested-tuning block of its own.""" - if not isinstance(bundle_data, dict): - return False - platform = bundle_data.get("platform") - tuning = platform.get("tuning") if isinstance(platform, dict) else None - if not isinstance(tuning, dict): + if bundle is None: return False - return bool(tuning.get("requested")) + return bool(bundle.platform.tuning.requested) -def _inline_applied_receipt(bundle_data: dict[str, Any] | None) -> Any: +def _inline_applied_receipt(bundle: BundleDocument | None) -> Any: """Return the receipt carried inside the bundle, or None when it has none.""" - if not isinstance(bundle_data, dict): + if bundle is None: return None - platform = bundle_data.get("platform") - tuning = platform.get("tuning") if isinstance(platform, dict) else None - applied = tuning.get("applied") if isinstance(tuning, dict) else None - if not isinstance(applied, dict): - return None - return applied.get("receipt") + return bundle.platform.tuning.applied.receipt def _companion_applied_receipt(bundle_path: Path) -> tuple[Any, bool]: @@ -726,7 +700,7 @@ def _override_display(bundle_path: Path) -> dict[str, Any]: } -def _applied_receipt(bundle_path: Path, bundle_data: dict[str, Any] | None = None) -> str | None: +def _applied_receipt(bundle_path: Path, bundle: BundleDocument | None = None) -> str | None: """Ingest the per-statement introspection receipt for a run. The receipt is the post-load introspection record that earns the @@ -746,7 +720,7 @@ def _applied_receipt(bundle_path: Path, bundle_data: dict[str, Any] | None = Non the exception: already-published legacy data is bounded defensively and stored with an explicit truncation marker rather than being silently dropped. """ - receipt = _inline_applied_receipt(bundle_data) + receipt = _inline_applied_receipt(bundle) if receipt is None: receipt, already_serialized = _companion_applied_receipt(bundle_path) if already_serialized: @@ -769,7 +743,7 @@ def _applied_receipt(bundle_path: Path, bundle_data: dict[str, Any] | None = Non return None -def _tuning_policy_generation(data: dict[str, Any]) -> str | None: +def _tuning_policy_generation(bundle: BundleDocument) -> str | None: """Ingest the ADR-3 explicit tuning-policy generation marker from the bundle. Read verbatim from ``platform.tuning.tuning_policy_generation`` (emitted by @@ -778,14 +752,11 @@ def _tuning_policy_generation(data: dict[str, Any]) -> str | None: explorer treats that absence as the "pre-seam" generation. Display-only, like the tuning hashes: never a join/dedup/grouping/match key. """ - summary = _tuning_summary(data) - if summary is None: - return None - val = summary.get("tuning_policy_generation") - return str(val) if val else None + summary = _tuning_summary(bundle) + return summary.tuning_policy_generation -def _logical_profile(data: dict[str, Any]) -> dict[str, Any] | None: +def _logical_profile(bundle: BundleDocument) -> BundleLogicalProfile | None: """Extract the `platform.tuning.logical_profile` block (ADR-2 §3). Returns `None` when no logical profile was recorded at all (unknown -- @@ -795,17 +766,10 @@ def _logical_profile(data: dict[str, Any]) -> dict[str, Any] | None: to distinguish "unknown" from "recorded, just empty" for fields like physical_mechanisms (see `_physical_mechanisms`). """ - platform = data.get("platform", {}) - if not isinstance(platform, dict): - return None - tuning = platform.get("tuning", {}) - if not isinstance(tuning, dict): - return None - profile = tuning.get("logical_profile") - return profile if isinstance(profile, dict) else None + return bundle.platform.tuning.logical_profile -def _physical_mechanisms(data: dict[str, Any]) -> list[str] | None: +def _physical_mechanisms(bundle: BundleDocument) -> list[str] | None: """Extract the platform-rendered physical tuning mechanisms. Tri-state (see `DetailResult.physical_mechanisms`): `None` when no @@ -816,56 +780,64 @@ def _physical_mechanisms(data: dict[str, Any]) -> list[str] | None: unknown/legacy bundle compared against a genuinely zero-mechanism bundle look like a real mismatch instead of "nothing to compare". """ - profile = _logical_profile(data) + profile = _logical_profile(bundle) if profile is None: return None - mechanisms = profile.get("physical_mechanisms", []) + mechanisms = profile.physical_mechanisms + if mechanisms is None: + # No mechanisms key recorded at all: the profile exists but says + # nothing about mechanisms. Treat like an empty recording -- a + # genuine, comparable value -- rather than unknown. + return [] if not isinstance(mechanisms, list): return [] return [str(item) for item in mechanisms] -def _physical_rendering_id(data: dict[str, Any]) -> str | None: +def _physical_rendering_id(bundle: BundleDocument) -> str | None: """Extract the physical rendering strategy id (ADR-2 §3 secondary facet).""" - profile = _logical_profile(data) + profile = _logical_profile(bundle) if profile is None: return None - val = profile.get("physical_rendering_id") + val = profile.physical_rendering_id return str(val) if val else None -def _test_type(data: dict[str, Any]) -> str | None: +def _test_type(bundle: BundleDocument) -> str | None: """Extract test type (power/throughput) from schema-v2 bundle.""" - benchmark = data.get("benchmark", {}) - if isinstance(benchmark, dict): - val = benchmark.get("test_type") - if val: - return str(val) - phases = data.get("phases", {}) - if isinstance(phases, dict): - if phases.get("power_test"): - return "power" - if phases.get("throughput_test"): - return "throughput" + if bundle.benchmark.test_type: + return bundle.benchmark.test_type + # A present-but-empty phase block carries no evidence the phase ran; + # truthiness on the unset-excluded dump matches the historical + # raw-mapping check. + power_phase = bundle.phases.get("power_test") + if power_phase is not None and bool(power_phase.model_dump(exclude_unset=True)): + return "power" + throughput_phase = bundle.phases.get("throughput_test") + if throughput_phase is not None and bool(throughput_phase.model_dump(exclude_unset=True)): + return "throughput" return None -def _validation_status(data: dict[str, Any]) -> str | None: +def _validation_status(bundle: BundleDocument, raw: dict[str, Any]) -> str | None: """Extract validation status from schema-v2 bundle summary. Handles both string (``"passed"``) and dict (``{"status": "passed", ...}``) - forms of ``summary.validation``. + forms of ``summary.validation``. ``raw`` is the original bundle mapping + for the shared ``benchbox.core.results.status`` helpers, which still take + raw mappings. """ - summary = data.get("summary", {}) - if not isinstance(summary, dict): + val = bundle.summary.validation + failed_queries = bundle_failed_query_count(raw) + raw_summary = raw.get("summary") + if raw_summary is not None and not isinstance(raw_summary, dict): + # A non-object summary carries no validation evidence at all. return None - val = summary.get("validation") - failed_queries = bundle_failed_query_count(data) if isinstance(val, str): normalized = normalize_validation_status(val) if failed_queries and normalized in {None, "passed"}: return "partial" - if _translation_uncertain(data, normalized): + if _translation_uncertain(raw, normalized): return "uncertain" return normalized if isinstance(val, dict): @@ -873,7 +845,7 @@ def _validation_status(data: dict[str, Any]) -> str | None: normalized = normalize_validation_status(s) if failed_queries and normalized in {None, "passed"}: return "partial" - if _translation_uncertain(data, normalized): + if _translation_uncertain(raw, normalized): return "uncertain" return normalized if failed_queries: @@ -901,14 +873,20 @@ def _unavailable_normalized_cost() -> NormalizedCost: ) -def _raw_normalized_cost_block(data: dict[str, Any]) -> dict[str, Any] | None: - """Find a normalized cost block in current or near-future bundle shapes.""" - raw = data.get("normalized_cost") - if isinstance(raw, dict): - return raw +def _raw_normalized_cost_block(bundle: BundleDocument) -> dict[str, Any] | None: + """Find a normalized cost block in current or near-future bundle shapes. - cost = data.get("cost", {}) - if not isinstance(cost, dict): + Presence is keyed on the field being set, not on the mapping being + non-empty: an explicitly empty ``"normalized_cost": {}`` block is + malformed submitted cost evidence and must reach strict validation + (which rejects it for missing provenance fields) rather than degrade + to synthetic unavailable metadata. + """ + if bundle.normalized_cost is not None: + return bundle.normalized_cost + + cost = bundle.cost + if cost is None: return None for key in ("normalized_cost", "normalized"): @@ -983,9 +961,9 @@ def _normalized_cost_from_block(raw: dict[str, Any]) -> NormalizedCost: ) -def _normalized_cost(data: dict[str, Any]) -> NormalizedCost: +def _normalized_cost(bundle: BundleDocument) -> NormalizedCost: """Extract normalized BenchBox cost or explicit unavailable metadata.""" - raw = _raw_normalized_cost_block(data) + raw = _raw_normalized_cost_block(bundle) if raw is None: return _unavailable_normalized_cost() return _normalized_cost_from_block(raw) @@ -998,10 +976,6 @@ def _cost_usd_alias(cost: NormalizedCost) -> float | None: return float(cost.cost_usd) -def _mapping_or_empty(raw: Any) -> dict[str, Any]: - return raw if isinstance(raw, dict) else {} - - def _string_or_none(raw: Any) -> str | None: if raw is None: return None @@ -1017,16 +991,15 @@ def _first_string(*values: Any) -> str | None: return None -def _deployment_class_from_contract(data: dict[str, Any]) -> str | None: +def _deployment_class_from_contract(bundle: BundleDocument) -> str | None: """Classify deployment from normalized runtime/deployment metadata only.""" - environment = _mapping_or_empty(data.get("environment")) - runtime = _mapping_or_empty(environment.get("platform_runtime")) - platform = _mapping_or_empty(data.get("platform")) - deployment = _mapping_or_empty(platform.get("deployment")) + environment = bundle.environment + runtime = environment.platform_runtime + deployment = bundle.platform.deployment - runtime_type = _string_or_none(runtime.get("runtime_type")) - deployment_type = _string_or_none(deployment.get("deployment_type")) - endpoint_class = _string_or_none(deployment.get("endpoint_class")) + runtime_type = _string_or_none(runtime.runtime_type) + deployment_type = _string_or_none(deployment.deployment_type) + endpoint_class = _string_or_none(deployment.endpoint_class) runtime_key = runtime_type.lower() if runtime_type else None deployment_key = deployment_type.lower() if deployment_type else None @@ -1052,13 +1025,23 @@ def _deployment_class_from_contract(data: dict[str, Any]) -> str | None: return None -def _has_normalized_environment_contract(data: dict[str, Any]) -> bool: +def _has_normalized_environment_contract(bundle: BundleDocument) -> bool: """Return true when the bundle carries normalized environment/platform facets.""" - environment = _mapping_or_empty(data.get("environment")) - platform = _mapping_or_empty(data.get("platform")) - return any(isinstance(environment.get(key), dict) for key in ("platform_runtime", "container")) or any( - isinstance(platform.get(key), dict) for key in ("deployment", "cloud", "compute", "storage") + environment = bundle.environment + platform = bundle.platform + # Content-based presence: a block counts when it carries at least one + # key. This deliberately narrows the historical isinstance check, under + # which an explicitly empty mapping (``"deployment": {}``) counted as a + # contract block and routed facet extraction away from the legacy cost + # fallback. An empty mapping carries no facets, so treating it as absent + # is the more correct routing; no corpus bundle hits the old branch. + runtime_present = environment.platform_runtime != BundlePlatformRuntime() + container_present = environment.container != BundleContainerBlock() + platform_present = any( + getattr(platform, key) != type(getattr(platform, key))() + for key in ("deployment", "cloud", "compute", "storage") ) + return runtime_present or container_present or platform_present def _legacy_environment_facets_from_cost(normalized_cost: NormalizedCost) -> dict[str, str | None]: @@ -1092,65 +1075,57 @@ def _legacy_environment_facets_from_cost(normalized_cost: NormalizedCost) -> dic def _environment_facets( - data: dict[str, Any], + bundle: BundleDocument, *, normalized_cost: NormalizedCost | None = None, ) -> dict[str, str | None]: """Flatten normalized execution-environment facets for the browser store.""" - platform = _mapping_or_empty(data.get("platform")) - cloud = _mapping_or_empty(platform.get("cloud")) - compute = _mapping_or_empty(platform.get("compute")) - storage = _mapping_or_empty(platform.get("storage")) + platform = bundle.platform + cloud = platform.cloud + compute = platform.compute + storage = platform.storage facets = { - "deployment_class": _deployment_class_from_contract(data), - "cloud_provider": _string_or_none(cloud.get("provider")), - "cloud_region": _first_string(cloud.get("region"), cloud.get("location")), + "deployment_class": _deployment_class_from_contract(bundle), + "cloud_provider": _string_or_none(cloud.provider), + "cloud_region": _first_string(cloud.region, cloud.location), "instance_or_warehouse": _first_string( - compute.get("node_type"), - compute.get("warehouse_size"), - compute.get("warehouse"), - compute.get("cluster_id"), - compute.get("cluster_name"), - compute.get("rpu"), - compute.get("serverless_slots"), - compute.get("worker_shape"), - compute.get("driver_shape"), + compute.node_type, + compute.warehouse_size, + compute.warehouse, + compute.cluster_id, + compute.cluster_name, + compute.rpu, + compute.serverless_slots, + compute.worker_shape, + compute.driver_shape, ), - "storage_format": _string_or_none(storage.get("table_format")), + "storage_format": _string_or_none(storage.table_format), } - if _has_normalized_environment_contract(data) or normalized_cost is None: + if _has_normalized_environment_contract(bundle) or normalized_cost is None: return facets return _legacy_environment_facets_from_cost(normalized_cost) -def _compliance_class(data: dict[str, Any]) -> str | None: +def _compliance_class(bundle: BundleDocument) -> str | None: """Extract compliance class from the benchmark block of a schema-v2 bundle.""" - benchmark = data.get("benchmark", {}) - if not isinstance(benchmark, dict): - return None - val = benchmark.get("compliance_class") - return str(val) if val is not None else None + return bundle.benchmark.compliance_class -def _phase_durations(data: dict[str, Any]) -> dict[str, float] | None: +def _phase_durations(bundle: BundleDocument) -> dict[str, float] | None: """Extract per-phase durations (seconds) from a schema-v2 bundle phases block. Returns a dict keyed by phase name (e.g. "data_loading", "power_test") with values in seconds. Returns None when no phase data is present. """ - phases = data.get("phases", {}) - if not isinstance(phases, dict): - return None result: dict[str, float] = {} - for phase_name, phase_data in phases.items(): - if isinstance(phase_data, dict): - duration_ms = phase_data.get("duration_ms") - if duration_ms is not None: - try: - result[str(phase_name)] = float(duration_ms) / 1000.0 - except (TypeError, ValueError): - pass + for phase_name, phase_data in bundle.phases.items(): + duration_ms = phase_data.duration_ms + if duration_ms is not None: + try: + result[str(phase_name)] = float(duration_ms) / 1000.0 + except (TypeError, ValueError): + pass return result if result else None @@ -1196,44 +1171,35 @@ def _platform_percentile_stats(display_timings: list[QueryDisplayTiming]) -> Per ) -def _query_timings(data: dict[str, Any]) -> list[QueryTiming]: +def _query_timings(bundle: BundleDocument) -> list[QueryTiming]: """Extract per-query timings from the queries list in a schema-v2 bundle. Includes measurement and warmup execution queries (or those without a run_type). Explicitly filters out non-execution pseudo-rows (metadata, summary). Preserves run_type, iter, and stream fields for provenance. """ - raw_queries: list[dict[str, Any]] = data.get("queries", []) timings: list[QueryTiming] = [] - for q in raw_queries: - if not isinstance(q, dict): - continue - run_type = q.get("run_type") + for q in bundle.queries: # Ingest both measurement and warmup executions. Narrow the filter to an # explicit allow-list; do not remove the check entirely, because the corpus # carries "metadata" and "summary" pseudo-rows (ms=0, status=SKIPPED) that # are not execution timings. - if run_type is not None and run_type not in _ALLOWED_EXECUTION_RUN_TYPES: + if q.run_type is not None and q.run_type not in _ALLOWED_EXECUTION_RUN_TYPES: continue - query_id = q.get("id") or q.get("query_id", "") - ms_raw = q.get("ms") - duration_ms_raw = ms_raw if ms_raw is not None else (q.get("execution_time_ms") or 0.0) + duration_ms_raw = q.ms if q.ms is not None else (q.execution_time_ms or 0.0) try: duration_ms = float(duration_ms_raw) except (TypeError, ValueError): duration_ms = 0.0 - raw_status = q.get("status", "pass") - status = "pass" if raw_status in _PASS_STATUSES else "fail" - iter_val = q.get("iter") - stream_val = q.get("stream") + status = "pass" if q.status in _PASS_STATUSES else "fail" timings.append( QueryTiming( - query_id=str(query_id), + query_id=q.query_id, duration_ms=duration_ms, status=status, - run_type=run_type, - iter=int(iter_val) if iter_val is not None else None, - stream=int(stream_val) if stream_val is not None else None, + run_type=q.run_type, + iter=int(q.iter) if q.iter is not None else None, + stream=int(q.stream) if q.stream is not None else None, ) ) return timings @@ -1351,38 +1317,33 @@ def _display_geomean_ms(display_timings: list[QueryDisplayTiming]) -> float | No return math.exp(sum(math.log(v) for v in values) / len(values)) -def _summary_query_count(data: dict[str, Any]) -> int: - summary = data.get("summary", {}) - if not isinstance(summary, dict): - return 0 - queries = summary.get("queries", {}) - if not isinstance(queries, dict): - return 0 +def _summary_query_count(bundle: BundleDocument) -> int: + total = bundle.summary.queries.total try: - return int(queries.get("total", 0) or 0) + return int(total or 0) except (TypeError, ValueError): return 0 -def _logical_query_count(data: dict[str, Any], display_timings: list[QueryDisplayTiming]) -> int: +def _logical_query_count(bundle: BundleDocument, display_timings: list[QueryDisplayTiming]) -> int: """Infer the logical query denominator without treating repeats as misses. ``summary.queries.total`` is retained as the raw sample count. For repeated benchmark runs it can be 3x/4x the logical benchmark query count, while ``display_timings`` has one row per logical query ID. """ - dataframe_skip_total = _dataframe_skip_logical_query_count(data) + dataframe_skip_total = _dataframe_skip_logical_query_count(bundle) if dataframe_skip_total is not None: return dataframe_skip_total - raw_query_count = _summary_query_count(data) + raw_query_count = _summary_query_count(bundle) observed_query_count = len({timing.query_id for timing in display_timings if timing.query_id}) if observed_query_count <= 0: return raw_query_count if raw_query_count <= observed_query_count: return raw_query_count or observed_query_count - benchmark = str(data.get("benchmark", {}).get("id", "unknown")) + benchmark = str(bundle.benchmark.id) known_count = _KNOWN_LOGICAL_QUERY_COUNTS.get(benchmark) if known_count and observed_query_count <= known_count and raw_query_count % known_count == 0: return known_count @@ -1393,17 +1354,11 @@ def _logical_query_count(data: dict[str, Any], display_timings: list[QueryDispla return raw_query_count -def _dataframe_skip_logical_query_count(data: dict[str, Any]) -> int | None: +def _dataframe_skip_logical_query_count(bundle: BundleDocument) -> int | None: """Return executed+skipped logical query count for DataFrame partial runs.""" - raw_queries = data.get("queries", []) - if not isinstance(raw_queries, list): - return None - best_total: int | None = None - for query in raw_queries: - if not isinstance(query, dict): - continue - summary = query.get("dataframe_skip_summary") + for query in bundle.queries: + summary = query.dataframe_skip_summary if not isinstance(summary, dict): continue executed = _int_or_none(summary.get("executed_total")) @@ -1431,6 +1386,60 @@ def _int_or_none(value: Any) -> int | None: return None +def _to_finite_float_or_none(value: Any) -> float | None: + """Coerce a value to a finite float, or None when absent/invalid.""" + if value is None: + return None + try: + parsed = float(value) + return parsed if math.isfinite(parsed) else None + except (ValueError, TypeError): + return None + + +def _detail_environment(bundle: BundleDocument) -> ExplorerEnvironment: + """Build the detail environment model from the typed environment block. + + Starts from the bundle's own environment keys verbatim, then normalizes + the CPU identity and projects the producer ``client_link`` shape + (``benchbox/platforms/base/adapter.py``) into flat derived fields. + """ + env = bundle.environment + provided = dict(env.model_extra or {}) + for key in ("os", "arch", "cpu_count", "memory_gb", "python", "cpu_model", "cpu_identity_provenance"): + if key in env.model_fields_set: + provided[key] = getattr(env, key) + # Nested contract blocks travel verbatim: exclude_unset reproduces the raw + # mapping exactly (declared fields were only defaults otherwise), so the + # environment copy keeps keys the typed projections never read. + for key in ("platform_runtime", "container", "client_link"): + if key in env.model_fields_set: + provided[key] = getattr(env, key).model_dump(exclude_unset=True) + if "environment" in bundle.model_fields_set: + raw_cpu = env.cpu_model + cleaned_cpu = raw_cpu.strip() if isinstance(raw_cpu, str) else None + if cleaned_cpu: + provided["cpu_model"] = cleaned_cpu + provided["cpu_family"] = normalize_cpu_family(cleaned_cpu) + else: + provided["cpu_model"] = None + provided["cpu_family"] = None + + # Producer shape (benchbox/platforms/base/adapter.py): the bundle + # carries client_link:{collection_status, client_region, + # client_cloud, statement_overhead_ms:{samples,min,median}}. Read + # exactly that shape: earlier flattened fallbacks matched no + # producer and silently projected NULLs. + link = env.client_link + overhead = link.statement_overhead_ms + provided["client_region"] = _string_or_none(link.client_region) + provided["client_cloud"] = _string_or_none(link.client_cloud) + provided["link_status"] = _string_or_none(link.collection_status) + provided["statement_overhead_min_ms"] = _to_finite_float_or_none(overhead.min) + provided["statement_overhead_median_ms"] = _to_finite_float_or_none(overhead.median) + return ExplorerEnvironment(**provided) + + class BundleTransformer: """Transforms a schema-v2 result bundle into explorer read model artifacts.""" @@ -1472,12 +1481,16 @@ def result_id_from_bundle( bundle_data, file_raw = data, bundle_path.read_bytes() else: bundle_data, file_raw = _load_bundle(bundle_path) - - benchmark = bundle_data.get("benchmark", {}).get("id", "unknown") - platform = str(bundle_data.get("platform", {}).get("name", "unknown")).lower().replace(" ", "-") - scale_factor = bundle_data.get("benchmark", {}).get("scale_factor", 0.0) - timestamp = bundle_data.get("run", {}).get("timestamp") - run_date = _run_date_from_timestamp(timestamp) + bundle = _parse_bundle(bundle_data) + + # The id embeds the raw scale_factor rendering (int ``1`` stays + # ``sf1``); the typed float is only for the read-model fields below. + raw_benchmark = bundle_data.get("benchmark", {}) + raw_benchmark = raw_benchmark if isinstance(raw_benchmark, dict) else {} + benchmark = bundle.benchmark.id + platform = str(bundle.platform.name).lower().replace(" ", "-") + scale_factor = raw_benchmark.get("scale_factor", 0.0) + run_date = _run_date_from_timestamp(bundle.run.timestamp) sha_prefix = _sha256_prefix(file_raw) return f"{benchmark}-{platform}-sf{scale_factor}-{run_date}-{sha_prefix}" @@ -1493,30 +1506,29 @@ def to_manifest_entry( """Extract manifest entry from a result bundle JSON file.""" bundle_data = data if data is not None else self.load_bundle(bundle_path) _ensure_explorer_input_schema(bundle_data) + bundle = _parse_bundle(bundle_data) rid = result_id or self.result_id_from_bundle(bundle_path, data=bundle_data) - benchmark = bundle_data.get("benchmark", {}).get("id", "unknown") - scale_factor = float(bundle_data.get("benchmark", {}).get("scale_factor", 0.0)) - platform = str(bundle_data.get("platform", {}).get("name", "unknown")) - timestamp = bundle_data.get("run", {}).get("timestamp") - run_date = _utc_run_date_from_timestamp(timestamp) - total_duration_ms = bundle_data.get("run", {}).get("total_duration_ms", 0.0) - total_duration_s = float(total_duration_ms) / 1000.0 + benchmark = bundle.benchmark.id + scale_factor = float(bundle.benchmark.scale_factor) + platform = str(bundle.platform.name) + run_date = _utc_run_date_from_timestamp(bundle.run.timestamp) + total_duration_s = float(bundle.run.total_duration_ms) / 1000.0 - query_count = _summary_query_count(bundle_data) + query_count = _summary_query_count(bundle) failed_query_count = bundle_failed_query_count(bundle_data) # Same _query_timings → _build_display_timings pass also runs in # to_detail_result; a shared intermediate could halve the work for # large bundles (≤99 queries makes this negligible today). - timings = _query_timings(bundle_data) + timings = _query_timings(bundle) display_timings = _build_display_timings(timings) - logical_query_count = _logical_query_count(bundle_data, display_timings) + logical_query_count = _logical_query_count(bundle, display_timings) timing_contract = timing_eligibility(display_timings, logical_query_count) - normalized_cost = _normalized_cost(bundle_data) + normalized_cost = _normalized_cost(bundle) environment_facets = _environment_facets( - bundle_data, - normalized_cost=normalized_cost if _raw_normalized_cost_block(bundle_data) is not None else None, + bundle, + normalized_cost=normalized_cost if _raw_normalized_cost_block(bundle) is not None else None, ) override = _override_display(bundle_path) entry = ManifestEntry( @@ -1525,11 +1537,11 @@ def to_manifest_entry( scale_factor=scale_factor, platform=platform, platform_id=_platform_id(platform), - driver_version=_driver_version(bundle_data), + driver_version=_driver_version(bundle), run_date=run_date, - power_score=_power_score(bundle_data), + power_score=_power_score(bundle), total_duration_s=total_duration_s, - geomean_ms=_geomean_ms(bundle_data), + geomean_ms=_geomean_ms(bundle), display_geomean_ms=_display_geomean_ms(display_timings), query_count=int(query_count), logical_query_count=logical_query_count, @@ -1541,31 +1553,31 @@ def to_manifest_entry( comparison_exclusion_reason=timing_contract.comparison_exclusion_reason, trust_label=trust_label, visibility=visibility, - funding=_funding(bundle_data), - platform_version=_platform_version(bundle_data), - execution_mode=_execution_mode(bundle_data), - tuning_mode=_tuning_mode(bundle_data), - tuning_hash=_tuning_hash(bundle_data), - requested_config_hash=_requested_config_hash(bundle_data), - applied_ledger_hash=_applied_ledger_hash(bundle_data), - tuning_validation_status=_tuning_validation_status(bundle_data), - applied_receipt=_applied_receipt(bundle_path, bundle_data), + funding=_funding(bundle), + platform_version=_platform_version(bundle), + execution_mode=_execution_mode(bundle), + tuning_mode=_tuning_mode(bundle), + tuning_hash=_tuning_hash(bundle), + requested_config_hash=_requested_config_hash(bundle), + applied_ledger_hash=_applied_ledger_hash(bundle), + tuning_validation_status=_tuning_validation_status(bundle), + applied_receipt=_applied_receipt(bundle_path, bundle), override_rules=override["override_rules"], override_evidence=override["override_evidence"], override_approver=override["override_approver"], override_expires=override["override_expires"], - tuning_policy_generation=_tuning_policy_generation(bundle_data), - test_type=_test_type(bundle_data), - validation_status=_validation_status(bundle_data), + tuning_policy_generation=_tuning_policy_generation(bundle), + test_type=_test_type(bundle), + validation_status=_validation_status(bundle, bundle_data), failed_query_count=failed_query_count, cost_usd=_cost_usd_alias(normalized_cost), - normalized_cost=normalized_cost.to_dict(), + normalized_cost=normalized_cost, deployment_class=environment_facets["deployment_class"], cloud_provider=environment_facets["cloud_provider"], cloud_region=environment_facets["cloud_region"], instance_or_warehouse=environment_facets["instance_or_warehouse"], storage_format=environment_facets["storage_format"], - compliance_class=_compliance_class(bundle_data), + compliance_class=_compliance_class(bundle), basis_availability=_compute_basis_availability(timings), ) return entry.model_copy(update={"ranking_exclusion_reason": ranking_exclusion_reason(entry)}) @@ -1583,88 +1595,42 @@ def to_detail_result( """Extract full detail from a result bundle JSON file.""" bundle_data = data if data is not None else self.load_bundle(bundle_path) _ensure_explorer_input_schema(bundle_data) + bundle = _parse_bundle(bundle_data) - benchmark = bundle_data.get("benchmark", {}).get("id", "unknown") - scale_factor = float(bundle_data.get("benchmark", {}).get("scale_factor", 0.0)) - platform = str(bundle_data.get("platform", {}).get("name", "unknown")) - timestamp = bundle_data.get("run", {}).get("timestamp") - run_date = _utc_run_date_from_timestamp(timestamp) - total_duration_ms = bundle_data.get("run", {}).get("total_duration_ms", 0.0) - total_duration_s = float(total_duration_ms) / 1000.0 - - environment: dict[str, Any] = {} - if isinstance(bundle_data.get("environment"), dict): - environment = dict(bundle_data["environment"]) - raw_cpu = environment.get("cpu_model") - cleaned_cpu = raw_cpu.strip() if isinstance(raw_cpu, str) else None - if cleaned_cpu: - environment["cpu_model"] = cleaned_cpu - environment["cpu_family"] = normalize_cpu_family(cleaned_cpu) - else: - environment["cpu_model"] = None - environment["cpu_family"] = None - - # Producer shape (benchbox/platforms/base/adapter.py): the bundle - # carries client_link:{collection_status, client_region, - # client_cloud, statement_overhead_ms:{samples,min,median}}. Read - # exactly that shape: earlier flattened fallbacks matched no - # producer and silently projected NULLs. - client_link = environment.get("client_link") if isinstance(environment.get("client_link"), dict) else {} - overhead = client_link.get("statement_overhead_ms") - overhead = overhead if isinstance(overhead, dict) else {} - client_region = client_link.get("client_region") - client_cloud = client_link.get("client_cloud") - link_status = client_link.get("collection_status") - stmt_min = overhead.get("min") - stmt_med = overhead.get("median") - - def _to_finite_float_or_none(v: Any) -> float | None: - if v is None: - return None - try: - val = float(v) - return val if math.isfinite(val) else None - except (ValueError, TypeError): - return None + benchmark = bundle.benchmark.id + scale_factor = float(bundle.benchmark.scale_factor) + platform = str(bundle.platform.name) + run_date = _utc_run_date_from_timestamp(bundle.run.timestamp) + total_duration_s = float(bundle.run.total_duration_ms) / 1000.0 - environment["client_region"] = ( - str(client_region).strip() if client_region is not None and str(client_region).strip() else None - ) - environment["client_cloud"] = ( - str(client_cloud).strip() if client_cloud is not None and str(client_cloud).strip() else None - ) - environment["link_status"] = ( - str(link_status).strip() if link_status is not None and str(link_status).strip() else None - ) - environment["statement_overhead_min_ms"] = _to_finite_float_or_none(stmt_min) - environment["statement_overhead_median_ms"] = _to_finite_float_or_none(stmt_med) + environment = _detail_environment(bundle) # Plans are still a companion file; the requested tuning is not. It rides # in `platform.tuning.requested`, with a retired `{stem}.tuning.json` # companion recognized for bundles published before the move. stem = bundle_path.stem has_plans = bundle_path.with_name(f"{stem}.plans.json").exists() - has_tuning = _has_requested_tuning(bundle_data) or bundle_path.with_name(f"{stem}.tuning.json").exists() + has_tuning = _has_requested_tuning(bundle) or bundle_path.with_name(f"{stem}.tuning.json").exists() override = _override_display(bundle_path) - timings = _query_timings(bundle_data) + timings = _query_timings(bundle) display_timings = _build_display_timings(timings) - query_count = _summary_query_count(bundle_data) - logical_query_count = _logical_query_count(bundle_data, display_timings) + query_count = _summary_query_count(bundle) + logical_query_count = _logical_query_count(bundle, display_timings) timing_contract = timing_eligibility(display_timings, logical_query_count) - normalized_cost = _normalized_cost(bundle_data) + normalized_cost = _normalized_cost(bundle) detail = DetailResult( result_id=result_id, benchmark=benchmark, scale_factor=scale_factor, platform=platform, platform_id=_platform_id(platform), - driver_version=_driver_version(bundle_data), + driver_version=_driver_version(bundle), run_date=run_date, total_duration_s=total_duration_s, - geomean_ms=_geomean_ms(bundle_data), + geomean_ms=_geomean_ms(bundle), display_geomean_ms=_display_geomean_ms(display_timings), - power_score=_power_score(bundle_data), + power_score=_power_score(bundle), has_display_timing=timing_contract.has_display_timing, logical_query_count=logical_query_count, valid_query_count=timing_contract.valid_query_count, @@ -1680,28 +1646,28 @@ def _to_finite_float_or_none(v: Any) -> float | None: bundle_download_url=bundle_download_url, trust_label=trust_label, visibility=visibility, - platform_version=_platform_version(bundle_data), - execution_mode=_execution_mode(bundle_data), - tuning_mode=_tuning_mode(bundle_data), - tuning_hash=_tuning_hash(bundle_data), - requested_config_hash=_requested_config_hash(bundle_data), - applied_ledger_hash=_applied_ledger_hash(bundle_data), - tuning_validation_status=_tuning_validation_status(bundle_data), - applied_receipt=_applied_receipt(bundle_path, bundle_data), + platform_version=_platform_version(bundle), + execution_mode=_execution_mode(bundle), + tuning_mode=_tuning_mode(bundle), + tuning_hash=_tuning_hash(bundle), + requested_config_hash=_requested_config_hash(bundle), + applied_ledger_hash=_applied_ledger_hash(bundle), + tuning_validation_status=_tuning_validation_status(bundle), + applied_receipt=_applied_receipt(bundle_path, bundle), override_rules=override["override_rules"], override_evidence=override["override_evidence"], override_approver=override["override_approver"], override_expires=override["override_expires"], - tuning_policy_generation=_tuning_policy_generation(bundle_data), - test_type=_test_type(bundle_data), - validation_status=_validation_status(bundle_data), + tuning_policy_generation=_tuning_policy_generation(bundle), + test_type=_test_type(bundle), + validation_status=_validation_status(bundle, bundle_data), failed_query_count=bundle_failed_query_count(bundle_data), cost_usd=_cost_usd_alias(normalized_cost), - normalized_cost=normalized_cost.to_dict(), - compliance_class=_compliance_class(bundle_data), - phase_durations=_phase_durations(bundle_data), - physical_mechanisms=_physical_mechanisms(bundle_data), - physical_rendering_id=_physical_rendering_id(bundle_data), + normalized_cost=normalized_cost, + compliance_class=_compliance_class(bundle), + phase_durations=_phase_durations(bundle), + physical_mechanisms=_physical_mechanisms(bundle), + physical_rendering_id=_physical_rendering_id(bundle), basis_availability=_compute_basis_availability(timings), ) manifest_peer = ManifestEntry( @@ -1710,7 +1676,7 @@ def _to_finite_float_or_none(v: Any) -> float | None: scale_factor=scale_factor, platform=platform, platform_id=_platform_id(platform), - driver_version=_driver_version(bundle_data), + driver_version=_driver_version(bundle), run_date=run_date, power_score=detail.power_score, total_duration_s=total_duration_s, diff --git a/_sources/tpc-h/PATCHES.md b/_sources/tpc-h/PATCHES.md index 6f3e1218b1..f702e91e6f 100644 --- a/_sources/tpc-h/PATCHES.md +++ b/_sources/tpc-h/PATCHES.md @@ -189,6 +189,53 @@ bundled per-platform binaries (skipping those the host can't execute). --- +## Patch: Defined arithmetic for the full-range `UnifInt` call (canonical addresses under optimization) + +### Problem + +`a_rnd()` in `bm_utils.c` draws its alphanumeric noise with +`RANDOM(char_int, 0, MAX_LONG, column)`. `UnifInt()` in `rnd.c` special-cases +that range and computes it in 32-bit `int`: + +```c +dRange = DOUBLE_CAST (nHigh32 - nLow32 + 1); +``` + +With `nHigh32 == 0x7FFFFFFF` this is signed overflow, which C leaves undefined. +Unoptimized builds wrap to `-2147483648`, and the reference TPC-H data depends on +that value. Clang at `-O2` instead folds the expression to `+2147483648.0`, +which changes every `V_STR` column: `S_ADDRESS` and `C_ADDRESS`. All other +columns are unaffected because no other call takes this branch. The darwin +binaries built with `Makefile.auto` (`-O2`) and any runtime `-O2` source build +on macOS produced non-canonical supplier and customer addresses. + +### Solution + +Compute the wrapped value with unsigned arithmetic, which is defined, and +convert back to a signed 32-bit value: + +```c +nRange = (int32_t)((unsigned int)nHigh32 - (unsigned int)nLow32 + 1u); +dRange = DOUBLE_CAST nRange; +``` + +Output now matches the reference data at every optimization level. A +`-fsanitize=undefined` build reports no other undefined behavior for a full +SF 0.01 `dbgen` run or for `qgen -d` over all 22 queries. `qgen` output was +never affected. + +### Verification + +```bash +./dbgen -f -s 0.01 -T s && head -n 1 supplier.tbl +# 1|Supplier#000000001| N kD4on9OM Ipw3,gf0JBoQDd7tgrzrddZ|17|27-918-335-1736|5755.94|each slyly above the careful +``` + +`tests/unit/core/tpch/test_tpch_dbgen_framing_binaries.py` checks the SF 0.01 +supplier and customer SHA-256 values for every bundled binary the host can run. + +--- + ## Applying the Patch To apply these changes to a fresh TPC-H source distribution: diff --git a/_sources/tpc-h/dbgen/rnd.c b/_sources/tpc-h/dbgen/rnd.c index 4655f4c65e..89617dee13 100644 --- a/_sources/tpc-h/dbgen/rnd.c +++ b/_sources/tpc-h/dbgen/rnd.c @@ -211,8 +211,8 @@ UnifInt(DSS_HUGE nLow, DSS_HUGE nHigh, long nStream) if ((nHigh == MAX_LONG) && (nLow == 0)) { - dRange = DOUBLE_CAST (nHigh32 - nLow32 + 1); - nRange = nHigh32 - nLow32 + 1; + nRange = (int32_t)((unsigned int)nHigh32 - (unsigned int)nLow32 + 1u); + dRange = DOUBLE_CAST nRange; } else { diff --git a/benchbox/__init__.py b/benchbox/__init__.py index 739695225f..da6440aa8f 100644 --- a/benchbox/__init__.py +++ b/benchbox/__init__.py @@ -11,7 +11,7 @@ from types import ModuleType from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union -__version__ = "0.4.1" +__version__ = "0.4.2" from benchbox.base import BaseBenchmark from benchbox.flightdata import FlightData diff --git a/benchbox/_binaries/SHA256MANIFEST.json b/benchbox/_binaries/SHA256MANIFEST.json new file mode 100644 index 0000000000..df006f9fbc --- /dev/null +++ b/benchbox/_binaries/SHA256MANIFEST.json @@ -0,0 +1,208 @@ +{ + "files": { + "tpc-ds/darwin-arm64/checksums.md5": "181a1a7564972f9109d61906366e7848348fad4fbb180b0e9c7f34a720d5e534", + "tpc-ds/darwin-arm64/dsdgen": "14c6f1169c0eeb7d1afd4d23851917976f53a8762a07b6a195da221c4e95491f", + "tpc-ds/darwin-arm64/dsqgen": "3225615e13a23767be23ed31360481b1a9f3fd6a813baf11f88b3de2a567b39b", + "tpc-ds/darwin-arm64/tpcds.dst": "7aeb147cec9582168a0fc9fe3979bf2d3371dd08be2528be6a531094c6af3c71", + "tpc-ds/darwin-arm64/tpcds.idx": "19c11f3bc9745b342346ae3f5982f9fac33f8dcf0dd88f71db86f737489a2e5a", + "tpc-ds/darwin-x86_64/checksums.md5": "ddcdf068fa6a41ba2a57768a6a56c46e6924e456cb35b26094f80acf1914dc34", + "tpc-ds/darwin-x86_64/dsdgen": "fa4f3dda391316197ab6b9021bc892c7fd1f11cdcf45e3bb713e9313f595cd5c", + "tpc-ds/darwin-x86_64/dsqgen": "1899b4b5dd09dd5ae864874f87278ba90cb065237ca6749a2ab29a7212cf5edc", + "tpc-ds/darwin-x86_64/tpcds.dst": "7aeb147cec9582168a0fc9fe3979bf2d3371dd08be2528be6a531094c6af3c71", + "tpc-ds/darwin-x86_64/tpcds.idx": "19c11f3bc9745b342346ae3f5982f9fac33f8dcf0dd88f71db86f737489a2e5a", + "tpc-ds/linux-arm64/checksums.md5": "b4622277690f0421cfdf4481166b8e70c2bdb7e29cbeac8201deebb443fa7baf", + "tpc-ds/linux-arm64/dsdgen": "3436a56a834ccbf0de6b67e0153feae14dd5430c9ae69545501e4bae3ee00b72", + "tpc-ds/linux-arm64/dsqgen": "de34218e1cd721dc188b09c5e212b8ce675ad3c7ddd7c00f4b30c72aeb58148b", + "tpc-ds/linux-arm64/tpcds.dst": "7aeb147cec9582168a0fc9fe3979bf2d3371dd08be2528be6a531094c6af3c71", + "tpc-ds/linux-arm64/tpcds.idx": "19c11f3bc9745b342346ae3f5982f9fac33f8dcf0dd88f71db86f737489a2e5a", + "tpc-ds/linux-x86_64/checksums.md5": "ad1099fbec1de02b9b6991bbb5f12fa881771d2b8af06843fafd7c2505be5bb8", + "tpc-ds/linux-x86_64/dsdgen": "d8d47309ae5e9fd84b256f1fac71398c18f9e3e90011d721c294e7557cde9f7a", + "tpc-ds/linux-x86_64/dsqgen": "37ba0ecf5e65f2294889a0fdda220a9da9c0f80535af2a9f6444b048771d49be", + "tpc-ds/linux-x86_64/tpcds.dst": "7aeb147cec9582168a0fc9fe3979bf2d3371dd08be2528be6a531094c6af3c71", + "tpc-ds/linux-x86_64/tpcds.idx": "19c11f3bc9745b342346ae3f5982f9fac33f8dcf0dd88f71db86f737489a2e5a", + "tpc-ds/templates/query_templates/README": "9831bc50ce16d1f7e2e07a5310c0252fca398cca87c721f844194a57cd3b1433", + "tpc-ds/templates/query_templates/ansi.tpl": "4414e1eba1dd729f2f11fae42a7bfdb156ef61a4f7572694ed7b1725199069eb", + "tpc-ds/templates/query_templates/db2.tpl": "1e04875e6213f3752ad04966f9d70342fd77d2b709eca897a97d89f1e3094e26", + "tpc-ds/templates/query_templates/netezza.tpl": "ca3fc6b841d32ae8b1abfbd679fc0089bcc60810d58c52f5fa5944186225573e", + "tpc-ds/templates/query_templates/oracle.tpl": "9f3f3351f591db37342e825a38ba86b082fc472415dd5f63b06da68e3a1362fe", + "tpc-ds/templates/query_templates/query1.tpl": "43bd12704315f45866976de974e809c5371734d8dfe2a04f3ba0c1384350ecf7", + "tpc-ds/templates/query_templates/query10.tpl": "937882b86b0a0c545d771e9f23622819fda4304339d3da79cbf7eb626b1b4591", + "tpc-ds/templates/query_templates/query11.tpl": "a0331ea068e9c4fdb7e361d98e8f0febc74df581ce0a84d1075f3c3357a4fc9f", + "tpc-ds/templates/query_templates/query12.tpl": "849ee94964441bde85ff32078f3cab3a38d497a5cf2924fca71382e1a16c5a69", + "tpc-ds/templates/query_templates/query13.tpl": "2647cff4eaa10d5bf26900b049861d306bd53a7ae58faa89e905d488f1da6f5a", + "tpc-ds/templates/query_templates/query14.tpl": "a550cd5c5a82a8cd6fed17380bed8cf2069c3e9e52d5e7a2349a811fb533ff5e", + "tpc-ds/templates/query_templates/query15.tpl": "9d39dbdeffa56ea3722870bee703d5bd2293343de4e8431a8daef62ffb83bc57", + "tpc-ds/templates/query_templates/query16.tpl": "59113489221207907f33d4a86834aa3e279debc5471bb903a178583b5b6df4b0", + "tpc-ds/templates/query_templates/query17.tpl": "44a9f3c81f4dc60cef26511c05630b8c289ae0c1be7c5dba37e1e9ff85f12448", + "tpc-ds/templates/query_templates/query18.tpl": "5a09294922e9690fb47aff91cb9c0eb43d64a713f05110c93336409364fae11b", + "tpc-ds/templates/query_templates/query19.tpl": "6299dfd107bebd87ac64010843801996efedd99605efd78a4b11d1f8b9e976f3", + "tpc-ds/templates/query_templates/query2.tpl": "4dd48917556634fb3d7da1a0de242599272e27f22476bfe2b5221b4f15b0afc2", + "tpc-ds/templates/query_templates/query20.tpl": "7e0bc5bc02a305c4c8527711a965e5f419e728b4e76684349456e8d7225e68c9", + "tpc-ds/templates/query_templates/query21.tpl": "4713d6437ea565c54f1543e894280b782dbb7a1c132b677d4c07f11ee42b6c67", + "tpc-ds/templates/query_templates/query22.tpl": "ba0b994760186422f07223d6a08415c4292ec1da5be170a2b03c921e56b6896d", + "tpc-ds/templates/query_templates/query23.tpl": "80c7baff33b3ecfc90316ba7d89cf098cdb3e9deb77a62ade5109347909ee3e0", + "tpc-ds/templates/query_templates/query24.tpl": "71ffb0e79f9525988a211da2b1a321f61a01ea025b34717a657cf95d278714f4", + "tpc-ds/templates/query_templates/query25.tpl": "e5115ba519ea8befb701bf5bbd7043c3df2f0bc6b01a42be788c2aaa471edef2", + "tpc-ds/templates/query_templates/query26.tpl": "74bb32512a2c4f8eb11617e50f0441cfe9a1e4e82051e52118df07452af3d2b3", + "tpc-ds/templates/query_templates/query27.tpl": "08af4b80c1efc52572ff8799a7abfc7c071e5dc3d96b689cfdeeee34b6ae47d6", + "tpc-ds/templates/query_templates/query28.tpl": "33ca27ebc07a8f4d624eae0089d6b6907f26422818426a4ea21841ea32ede987", + "tpc-ds/templates/query_templates/query29.tpl": "84fb042f9acaa59efaf2df09752745844c95854098161e612e133e7aaaf8593c", + "tpc-ds/templates/query_templates/query3.tpl": "9756742590f746ac97703a30af751f278dd9b16a46a2a5f80a1e8eb6a9c5c3f2", + "tpc-ds/templates/query_templates/query30.tpl": "06fff9f6f3072daf5dce68aa9fb5c80f685990fd7cb2d37aac1279e7e53cc46f", + "tpc-ds/templates/query_templates/query31.tpl": "bdae939691ec41da6708632ae527033a703140785ce9b6eda0c979983ba3ad43", + "tpc-ds/templates/query_templates/query32.tpl": "1d533417f573d7aa04b31fb508cd519602f395415e7430500f765b50cf1a519f", + "tpc-ds/templates/query_templates/query33.tpl": "7ef665b6739a6da484147b636155d661cc307272168f06776a52496b219f103b", + "tpc-ds/templates/query_templates/query34.tpl": "6971af3b732977ba39c75b2e12295f5558966d5d50b4f3e7fbd8f0701713facc", + "tpc-ds/templates/query_templates/query35.tpl": "e02104bb39c696768aa5bbeed537f052f5397535525ee83c03cfca37a9f56574", + "tpc-ds/templates/query_templates/query36.tpl": "e9bb7e8daec6f4a514e4c8f2c1d946c7fc3b8cdc2ebe6e858ef0871b452282d3", + "tpc-ds/templates/query_templates/query37.tpl": "95642c6a3dd175cf932a8937a0d60034a2a6537021c011ba1ed612db57e08131", + "tpc-ds/templates/query_templates/query38.tpl": "bc427fdf4fba2dd776b7fd0fb190991cc3a34e29c2735e2113591b3b0f68361e", + "tpc-ds/templates/query_templates/query39.tpl": "9498ef818487075a8f0f5e9628d3c7701d76797faceee6988d38cd79ee7699ca", + "tpc-ds/templates/query_templates/query4.tpl": "0a5b92bffa65fa46ed04f6ee110796d5a49bab2b0819c776d868a0b8850b7bcd", + "tpc-ds/templates/query_templates/query40.tpl": "5164c668e6a38f360300c281293b1e36fb836c1ecdd6857082da4a701a3508d6", + "tpc-ds/templates/query_templates/query41.tpl": "bc21e35bedb844dd8ab7a8e5c20cb926b3e506eddd2c4d31515a23b252c26a17", + "tpc-ds/templates/query_templates/query42.tpl": "ba9b9036ec2eb8bfabc142dc3ed9df6a66392401c42f5b8d33356ffae7adcd89", + "tpc-ds/templates/query_templates/query43.tpl": "e7d1819899194a375c3c762e10f25fde04f947997f5ef7d1c719b7074dddaec8", + "tpc-ds/templates/query_templates/query44.tpl": "9a8959967cce0dd5ba092a541cffab0b20fac59b6f0fd154198213699751f387", + "tpc-ds/templates/query_templates/query45.tpl": "efce1fc309b47eb643bcae147938d443ce1f63dc116074e625dc2f9b9af218d1", + "tpc-ds/templates/query_templates/query46.tpl": "e1d3dd65a7ee00443b0aa2ed5a06db5cd1c38c1f843532e1028cb80ea7203c54", + "tpc-ds/templates/query_templates/query47.tpl": "d5f9457508466915c97d1233b7814db38d74d168756198d6ff04cd11f3ad3b1c", + "tpc-ds/templates/query_templates/query48.tpl": "56ccfeb3e6cdd7511df2b36fb7b4ebc3429a828633a302bd2977775e3821029e", + "tpc-ds/templates/query_templates/query49.tpl": "047749a7cc035264061ee7cc46ff743194bf2aba066873d973894bb16989feef", + "tpc-ds/templates/query_templates/query5.tpl": "e232c642b31f7bd5a27f652a0528f5d33eb38e854f6c45ea6f27e0ad3abaa4de", + "tpc-ds/templates/query_templates/query50.tpl": "585e9248582c650ee1f5c78a42ce0de4806d9aeaf5bd6a9b336d1a0cbebe2947", + "tpc-ds/templates/query_templates/query51.tpl": "4cb6fb3f5dd4e9b7aa26a6c29c759517f29cc7bfe9822706992e14c9b40e7e80", + "tpc-ds/templates/query_templates/query52.tpl": "015e216bad2103b172d26a5dd6dcbb7de0ccdd9c8655784f282174e30d7e66df", + "tpc-ds/templates/query_templates/query53.tpl": "2a12565a0a8f32575667a0c93c212e7d21f7919f03ad318be35a5ededbbf0437", + "tpc-ds/templates/query_templates/query54.tpl": "cde384d141c912b85addbff254f29abdc9028b103d25b2a010b295d28dc58d6e", + "tpc-ds/templates/query_templates/query55.tpl": "dacd58d6fdf24fd54a7d7d77ef608df6d98c0e8c701eff77f3ef65252d3dddef", + "tpc-ds/templates/query_templates/query56.tpl": "7a8f163f71ded650bbc9d89af88114b44b7fcc4caafa776857ac229b855c5a3b", + "tpc-ds/templates/query_templates/query57.tpl": "9833481006694b565ce7e19f513a62b49bb16660731b173e224abbe26f38c240", + "tpc-ds/templates/query_templates/query58.tpl": "452109c63497766415fa73ce4ab69d5ba87c10c396c66b1836e7e41ab339e55f", + "tpc-ds/templates/query_templates/query59.tpl": "5f43ea2aefd5c90096eafa1313b296e4073849f5e54e5b12de4b7daf6dd32bb7", + "tpc-ds/templates/query_templates/query6.tpl": "502f4a7d64d8de504379c750be4e9918ef466b1db28024b1b44fb70de257705e", + "tpc-ds/templates/query_templates/query60.tpl": "97c30bec850e139ec4ab0206c8c54fbd6d96258e9af24669660b8220b32961fe", + "tpc-ds/templates/query_templates/query61.tpl": "9501f48fd73d4ed27111943dfa869480695884d70b9aaaf054a0585ee49c8d6f", + "tpc-ds/templates/query_templates/query62.tpl": "a20540c366048c36a67c2e8779472beee91f81e2a95981918ffe9186a901454e", + "tpc-ds/templates/query_templates/query63.tpl": "cd290b6f8a83e690fcfa8ee29ce49cbd153a88840b7712dc1ced3912f5870459", + "tpc-ds/templates/query_templates/query64.tpl": "dac0f9c9e0cf71596ee8c76d7cd2e32db190124e52f25a7722fb73fa4d041d93", + "tpc-ds/templates/query_templates/query65.tpl": "4769a55171ba59d35d667d6b108a6d7d5e5723f704823e2a875474cf4604c838", + "tpc-ds/templates/query_templates/query66.tpl": "83c4b13212c223c138c2f807f65b67371192a540acff4e4906b747ef4f0b7c13", + "tpc-ds/templates/query_templates/query67.tpl": "3bb0ac8d59ee37e04f223ff90041bfd2e0bb0a8cbf0aedaab3c9c610338e99cc", + "tpc-ds/templates/query_templates/query68.tpl": "b8894f13bdd1b82f9dba3aeae49f8d5687c83b99c29b134c35e239b8ceca8b07", + "tpc-ds/templates/query_templates/query69.tpl": "f02e8b9792ebf3adae7440c25f513492485dcbcef85f6d4a97c183f707aa562a", + "tpc-ds/templates/query_templates/query7.tpl": "1a5fe27db2d5011a0732d9f98db5558f40eb28edfb1ce4741dfccf569eafce2d", + "tpc-ds/templates/query_templates/query70.tpl": "922d98620b44c0dd13740ec6cb94a700a0c0098ed32401af7d75107d3cc961ee", + "tpc-ds/templates/query_templates/query71.tpl": "230bd52957a448c46d8c63264552edf26054f3d5891aa771d34356772303dd01", + "tpc-ds/templates/query_templates/query72.tpl": "5f9117a8a7184d36125ca4c4224110da7b76dcefa24678d4c8c2d7efb0cd31f1", + "tpc-ds/templates/query_templates/query73.tpl": "6f501a02843b22a99d85ecc830999d05e77cf15d814f4278ce7e717af628d037", + "tpc-ds/templates/query_templates/query74.tpl": "a19d9ecdd4d24515c7d8fa7b3a9fa585d91c28369346b47738cf71f2ef070c47", + "tpc-ds/templates/query_templates/query75.tpl": "1b2b723ff63faa6fe1670b316ba9d452c11bb01bc9c69523bfe288b2e691a9d0", + "tpc-ds/templates/query_templates/query76.tpl": "748201a1fd429951aa16c5a7a361aea437cc7f83dbfa0dffa5f6844e86aac05c", + "tpc-ds/templates/query_templates/query77.tpl": "21644fbbc5b7cdab24b51a71647cec937f8bb261ee5f6fd49cc7f8e62a669026", + "tpc-ds/templates/query_templates/query78.tpl": "50007b69c4b2a1a07ccda17781a91e323ccbc935a303722a0fd675b256a14c39", + "tpc-ds/templates/query_templates/query79.tpl": "bc9b85ad7c8448deaded4769a1d03e2dc6816da7c2a7963fe76d1687143db26f", + "tpc-ds/templates/query_templates/query8.tpl": "378c3fec4638dd57cef1732be700659fc70d78d0a5e04b5d2e5f3b12b0a9babe", + "tpc-ds/templates/query_templates/query80.tpl": "94dbb1c80f8c4e88bb1a61c2885234de3f245ea67b2af68ed81b1129d7014e26", + "tpc-ds/templates/query_templates/query81.tpl": "61def14bcc7c4d116cc67febbf1f173650d750731595bd97c526b4fe8dafb958", + "tpc-ds/templates/query_templates/query82.tpl": "e6fbe19cb97f43d473b20934491ba48bdcccc3a1c132dfef5b96bd0fdac08c29", + "tpc-ds/templates/query_templates/query83.tpl": "70f5bc97783e4140e95528d6a43fb72d3fc078d143329f69d69ada43539dd5d5", + "tpc-ds/templates/query_templates/query84.tpl": "7eed933ac66a43af5e776c604dc16d4b6624bef0ac8d10d1e983f5c5648bf7af", + "tpc-ds/templates/query_templates/query85.tpl": "1e95614e7aebdb858f5d9aa76f98ff1940ed69d86cb26bb926237e99c9789025", + "tpc-ds/templates/query_templates/query86.tpl": "9b7722986d6efe9cfabcabd87dab12628f7df9adbb0609cacef6b23247ebdf10", + "tpc-ds/templates/query_templates/query87.tpl": "a521a506634cb62f7cc056d9111e616318cc151eb205e1d14f25adf7b9af5976", + "tpc-ds/templates/query_templates/query88.tpl": "9e0075539c5547530f1e995738fe3edfabf05c36673fbb11684f8fcde0b7dd3c", + "tpc-ds/templates/query_templates/query89.tpl": "a60df27ee23e89fca368f2dab482da8bff61c9303a11dc3787e807ec86dacdfa", + "tpc-ds/templates/query_templates/query9.tpl": "2db1d593f05a0d364831bb4c5b4870743f8b91b68a9bee62f3f71b3023cedb29", + "tpc-ds/templates/query_templates/query90.tpl": "40520ef7e96c93ed3ffacfae84e2d33f2175a795e0db50e36b2c7b1758e24de8", + "tpc-ds/templates/query_templates/query91.tpl": "04586097e383871e74dfe5b36dfd363c39676276d61e76b74b84afcbefa8c986", + "tpc-ds/templates/query_templates/query92.tpl": "a8e6c13375da18c2ca9cce8d044bedf179e66d199e77b0b53ddf0c8c1f289ee2", + "tpc-ds/templates/query_templates/query93.tpl": "94039860c835a6f144c1c2b713fabfc0e19237833d612f14df54807024f44556", + "tpc-ds/templates/query_templates/query94.tpl": "8ac07ea9b9c35daf93d64980825b60676990eec43c70a653c7e6acaad3d1089f", + "tpc-ds/templates/query_templates/query95.tpl": "c4675aad174569ff477d3918c084fd56fd6ab4fac2c5d3c59d078f0889a908ea", + "tpc-ds/templates/query_templates/query96.tpl": "4f153d6102c19ac7fc263575e14ea9ee623d0d72f85e22db8edd2c7411dbf4fc", + "tpc-ds/templates/query_templates/query97.tpl": "aab37cc8fb542b65063b475eadae9116a36e714a12b562d830713cb1ca8d9c83", + "tpc-ds/templates/query_templates/query98.tpl": "0c8c2737bd74229df853f45b38f4e6794251422b3f0ec09620282dfc80ede33f", + "tpc-ds/templates/query_templates/query99.tpl": "a38e5200cecc4731e5f0da0f3eaf798c19372e4c732b5e1a57255e7aee3d2b58", + "tpc-ds/templates/query_templates/sqlserver.tpl": "ba761a117bc7bf33e53b0df5b0072abd9ec643a60cd10f69727fa8d317e607df", + "tpc-ds/templates/query_templates/templates.lst": "6c88bbdb15e1f7704dd986863b6e46d0a79b4425d195da9f0af658406a7a8c30", + "tpc-ds/templates/query_variants/README": "20d0e160750c50cb10fd6c1085199d33f47bb3eaf2e7ed774ebf8ea2dcaa39be", + "tpc-ds/templates/query_variants/query10a.tpl": "6db288cc0b7053a97ea8b2118d08df8b184604dbbcd989a7e6baf9a840c8a536", + "tpc-ds/templates/query_variants/query14a.tpl": "58b44a56687fc642be589c76a9e450d858768cd4d466c2fab5b900edbc2f378e", + "tpc-ds/templates/query_variants/query18a.tpl": "ada84f8d0447e3817c9b86b05300cae174d9ad35218a7db7ecb58cea5fb1331c", + "tpc-ds/templates/query_variants/query22a.tpl": "e13013baba493e19cf45507517feb8804a31c4d34365576dc0d00d130a6bb564", + "tpc-ds/templates/query_variants/query27a.tpl": "1ed4906d4dc66c7241dfef85d5802ae97b8ac15a4066e0f6d6b6e53ca1351ae1", + "tpc-ds/templates/query_variants/query35a.tpl": "179a484845f4a9e3a0b0e46001f0158a8ff5c6c99abe01dcb7c20eaed21f8688", + "tpc-ds/templates/query_variants/query36a.tpl": "51cb85087f571313da05d5dabd0331cab90a80ff483a0642ce82432c76078414", + "tpc-ds/templates/query_variants/query51a.tpl": "23f78387f40a754e11b7bc31bdaf9cb6e019fac6cdc6a0ed0fc76777bc394574", + "tpc-ds/templates/query_variants/query5a.tpl": "4c3459b7ab3da03f451ad9788cbef3a0b61fde28ee281898cd7e0127d0e535f6", + "tpc-ds/templates/query_variants/query67a.tpl": "30dfaa8d00eeb3f8e4a8af15746baadc76c20b733bc843edcc9b429bba18a278", + "tpc-ds/templates/query_variants/query70a.tpl": "61deae917e24b53f2aa199645e57002586d419d47870f7b8bf9d52c06a744f8a", + "tpc-ds/templates/query_variants/query77a.tpl": "4555215ac11d8d46628f44243667b1f053584f04acc8c77c4261810e3d8a44ea", + "tpc-ds/templates/query_variants/query80a.tpl": "c62f4171a1256b57ed3173029152b115cd91321855da2d6eae817292cd6fc07f", + "tpc-ds/templates/query_variants/query86a.tpl": "3b364438c4c259a51111d6f41636a3f3d55f8d9e7c9c4e53c474c7ddd6049684", + "tpc-ds/windows-arm64/checksums.md5": "e95ee0926dd78c0354ceb6836128c6f6923a2ed9a58339127ba714823af49696", + "tpc-ds/windows-arm64/dsdgen.exe": "9d5c9acecc469a90d348777d54992839414b7c21f805d4b019bd630ce841754b", + "tpc-ds/windows-arm64/dsqgen.exe": "659e8d1b83e16ba9e45b4758a722e8aa95d4aa032208d0a1586aaaf7be09cb4f", + "tpc-ds/windows-arm64/tpcds.dst": "7aeb147cec9582168a0fc9fe3979bf2d3371dd08be2528be6a531094c6af3c71", + "tpc-ds/windows-arm64/tpcds.idx": "19c11f3bc9745b342346ae3f5982f9fac33f8dcf0dd88f71db86f737489a2e5a", + "tpc-ds/windows-x86_64/checksums.md5": "7942f7064d202451164c42d66fe6acf11282d2f7d935b4fa612b2a99feb01137", + "tpc-ds/windows-x86_64/dsdgen.exe": "525a6e7cc5da2414398b14de4a87fe7d6a610f5061d0ca91d90950cc6df6652e", + "tpc-ds/windows-x86_64/dsqgen.exe": "2b7dff20ab8d5941ff6e4d9184594736e2137875d631d865e600625f36f0ae2b", + "tpc-ds/windows-x86_64/tpcds.dst": "7aeb147cec9582168a0fc9fe3979bf2d3371dd08be2528be6a531094c6af3c71", + "tpc-ds/windows-x86_64/tpcds.idx": "19c11f3bc9745b342346ae3f5982f9fac33f8dcf0dd88f71db86f737489a2e5a", + "tpc-h/darwin-arm64/checksums.md5": "6b3d0fd12680b4dd8a7b7c75e05b8b4edeba2fc12381c037556e9915a34f2450", + "tpc-h/darwin-arm64/dbgen": "281563c8b78ae427241ed722d861d33257946d18c32f472d25dc7dea2103d3ee", + "tpc-h/darwin-arm64/dists.dss": "4dc56300de1e5ac5e1900519794538e2b5d1b6caf5cd55703bf53000dd748876", + "tpc-h/darwin-arm64/qgen": "a224f5923bc2c59495da0f07e42216ae39e26f95e58ca1862b769020c9f8ccb4", + "tpc-h/darwin-x86_64/checksums.md5": "3d01139d4e7d37608df03ae72e0d2751b44b1c32cfefecd19106bd546cb3a76f", + "tpc-h/darwin-x86_64/dbgen": "cdb7f6bb078564bae624b927fb6423c7f4c3c1d327e38246be7b556c4f51a91a", + "tpc-h/darwin-x86_64/dists.dss": "4dc56300de1e5ac5e1900519794538e2b5d1b6caf5cd55703bf53000dd748876", + "tpc-h/darwin-x86_64/qgen": "b824f4283cd1ed72264c339ff2bc4e56c409211badce61da88f4ffc0e6e81665", + "tpc-h/linux-arm64/checksums.md5": "09ddc5af62251e5c576d28b30fe1fb85f31408b58c360a15aec3b7c2dc46624e", + "tpc-h/linux-arm64/dbgen": "24b6a1dc2a24bcf014b78ff67e5892daff43eaab5f173864a30d4667b2391910", + "tpc-h/linux-arm64/dists.dss": "4dc56300de1e5ac5e1900519794538e2b5d1b6caf5cd55703bf53000dd748876", + "tpc-h/linux-arm64/qgen": "6155856bdd56ea101c2423816e604e4dbdc76054da494b22e7ff4ea4cf5d9991", + "tpc-h/linux-x86_64/checksums.md5": "49a1674ec293f3b270c9eecc4aab49af20dc0b3f22a69daef6d38ce89e613479", + "tpc-h/linux-x86_64/dbgen": "60f58f79eb49b4bec45f0bf7584fcd6201494983162365e63e9843ad8a01b662", + "tpc-h/linux-x86_64/dists.dss": "4dc56300de1e5ac5e1900519794538e2b5d1b6caf5cd55703bf53000dd748876", + "tpc-h/linux-x86_64/qgen": "e7a43195425a4b73b7d17663957d372bbb8f01955085befcdffd7ff69e14bef7", + "tpc-h/templates/dists.dss": "4dc56300de1e5ac5e1900519794538e2b5d1b6caf5cd55703bf53000dd748876", + "tpc-h/templates/queries/1.sql": "2c61084f494f29387bee33b31b09232cef917c5e1b62e37d5e90d819eba51438", + "tpc-h/templates/queries/10.sql": "a9d5eb576a6af292bbf7a953b68dd69616cf3c18290ae0c36ec80cee2e6ec2e7", + "tpc-h/templates/queries/11.sql": "ea8fac566948f7af7e0a45c9e5a1c1eab424533983b04e0d356d68aec3f7d5e4", + "tpc-h/templates/queries/12.sql": "d86fcded63680d30aed2093f9be49105ab4eaa498ea16beb05c61493a479ee7e", + "tpc-h/templates/queries/13.sql": "33bf54c44ca37cb848d9da905ce3d3b53b43224de49de1679248548f2e27bd83", + "tpc-h/templates/queries/14.sql": "41d6bfd4acccf1ae3c554e9713e96d7808a1bc336bd906354aabd404c29540e6", + "tpc-h/templates/queries/15.sql": "c2bca97e9051412ebd1740a0df38fba44134628ce4506d1c63a40ba1c1922f13", + "tpc-h/templates/queries/16.sql": "728892629645bbb63a9285279d9c84928d6d152ea8db15a6a39d60212a387b37", + "tpc-h/templates/queries/17.sql": "4ef17d43755b03bad1cdacfe140c6d44c8302acef766e48cad7f2cacd2de349e", + "tpc-h/templates/queries/18.sql": "865d7b6c52950d9f991ffb7c5af5bca98627726e8638e41a0e4d1002eb4d414c", + "tpc-h/templates/queries/19.sql": "aff19a2f2acd0e6e8a001e10e4c9e6adad9ca0f12515f48a2f629d0bd40bb9dd", + "tpc-h/templates/queries/2.sql": "0137d69e5e066ab3578039d6a4132a5b959e4f69b807f7ffecf53f60cef3d2b0", + "tpc-h/templates/queries/20.sql": "2a0c607b7621d3ee89167d95c2569e89a6eedb52ff528f7bf3574c1f83c92414", + "tpc-h/templates/queries/21.sql": "15623e625f35748d004ed736df399021cae3e6647613f737edcf15d9288284b9", + "tpc-h/templates/queries/22.sql": "baf12c8eebafcd69d588d6505316a6d12f2e4bfa38bd1bc70fb944bae60c2691", + "tpc-h/templates/queries/3.sql": "dd4b6bc1bfa37ccf50d6ef1f555ac4c452becd30f64ce176af7ad77e1f5950e4", + "tpc-h/templates/queries/4.sql": "4670fe71b4a66a90fe91ef30f0f5e0f5e93c5068bf9bc38d8b6b081a44aed490", + "tpc-h/templates/queries/5.sql": "bfad89d1fe3ecc87e5c9fb380c25f0353c9f49a8e7a740f067e8bd3525c949b0", + "tpc-h/templates/queries/6.sql": "c7c2946345940ea42c5483740246da3f144b6d460edb6434fe43f2c74135f496", + "tpc-h/templates/queries/7.sql": "d55ea9efebde8850925af9254bf730acbca6a09fd0e9bef312e8d46a95ce1f90", + "tpc-h/templates/queries/8.sql": "701624e95f81a6dcc308d9dabcadf3ed8e58ddbfb9494926634d982c5a1f6a64", + "tpc-h/templates/queries/9.sql": "26a7dcf2fccc7e769b07ef19dd289cbd0a268abb3c4ce3309c98ba05fb0e8cde", + "tpc-h/templates/variants/12a.sql": "5cb17c15e989356f89a97f06c723d72278e2d5b4a22bcfea1205fce3d720286f", + "tpc-h/templates/variants/13a.sql": "dda3229aea5cff0932f7652c8e674f6696dc034cd94c44ba8160f6b18c83af8b", + "tpc-h/templates/variants/14a.sql": "d47a93785e05b91056b72a8b6b9cae2b86b6c01e55057c02099edbc35f4c87cc", + "tpc-h/templates/variants/15a.sql": "f50ff917af5380ff918bdbdc1ace8a3c36438fee2c986eddd915c2af8f2ea4ad", + "tpc-h/templates/variants/8a.sql": "cabf354853c5bdf14ecf5035e81fead2e65f9c4adaeb5937ba1d6195331a3444", + "tpc-h/windows-arm64/checksums.md5": "54e5cf193050e24238c4927df69c9bf09d7226c1da09b0b92f8ead0676aad84c", + "tpc-h/windows-arm64/dbgen.exe": "735571f2b5ecf76d8e7daba06b96e799726c989c48015cbc14cf08d38d2258f6", + "tpc-h/windows-arm64/dists.dss": "4dc56300de1e5ac5e1900519794538e2b5d1b6caf5cd55703bf53000dd748876", + "tpc-h/windows-arm64/qgen.exe": "99201d67524b61cb73758c8818265e94ac07004b25f13fc58811a286bd179fc1", + "tpc-h/windows-x86_64/checksums.md5": "02370e5732b0ff380580d00d26580d90171987ba3ff085a88e2ce0c7c1e7d44b", + "tpc-h/windows-x86_64/dbgen.exe": "16f823ab7ba1bd6d19dd308ba0ebcfb5b47e2154fb7e1818a822abebd250cca3", + "tpc-h/windows-x86_64/dists.dss": "4dc56300de1e5ac5e1900519794538e2b5d1b6caf5cd55703bf53000dd748876", + "tpc-h/windows-x86_64/qgen.exe": "b92280681f40d49539caa83b242d2de68008e6e79a1dad0ba77ef9da5db0dd4b" + }, + "schema": 1 +} diff --git a/benchbox/_binaries/tpc-h/darwin-arm64/checksums.md5 b/benchbox/_binaries/tpc-h/darwin-arm64/checksums.md5 index 9a036a6ce3..25d14debcd 100644 --- a/benchbox/_binaries/tpc-h/darwin-arm64/checksums.md5 +++ b/benchbox/_binaries/tpc-h/darwin-arm64/checksums.md5 @@ -1,3 +1,3 @@ -47d2822feb2c00fbf3bbe606e5837418 dbgen -944c637af7b0601dbe9dee3abde994c6 qgen +1e0079c2d6116b4789fecacdfdef6e04 dbgen +8df81d3243928ec231c5491f0bc9728f qgen 878d277d8f6989bf5a3ec0d45f1b4696 dists.dss diff --git a/benchbox/_binaries/tpc-h/darwin-arm64/dbgen b/benchbox/_binaries/tpc-h/darwin-arm64/dbgen index f8acc7166b..7a4f818e93 100755 Binary files a/benchbox/_binaries/tpc-h/darwin-arm64/dbgen and b/benchbox/_binaries/tpc-h/darwin-arm64/dbgen differ diff --git a/benchbox/_binaries/tpc-h/darwin-arm64/qgen b/benchbox/_binaries/tpc-h/darwin-arm64/qgen index c8b3acda35..2de9aacb7e 100755 Binary files a/benchbox/_binaries/tpc-h/darwin-arm64/qgen and b/benchbox/_binaries/tpc-h/darwin-arm64/qgen differ diff --git a/benchbox/_binaries/tpc-h/darwin-x86_64/checksums.md5 b/benchbox/_binaries/tpc-h/darwin-x86_64/checksums.md5 index 414756ae4f..a234eb198b 100644 --- a/benchbox/_binaries/tpc-h/darwin-x86_64/checksums.md5 +++ b/benchbox/_binaries/tpc-h/darwin-x86_64/checksums.md5 @@ -1,3 +1,3 @@ -a80e07b0558aa13c38a3533107f2b1b0 dbgen -4828cdd32878cf8705322ab8c4a3a2b3 qgen +5240ca85f36053646f89c8fe1e1d8ad4 dbgen +a3ecd3a3724c1f3148b164742de94fc4 qgen 878d277d8f6989bf5a3ec0d45f1b4696 dists.dss diff --git a/benchbox/_binaries/tpc-h/darwin-x86_64/dbgen b/benchbox/_binaries/tpc-h/darwin-x86_64/dbgen index 1110c826bd..c5278bcd5a 100755 Binary files a/benchbox/_binaries/tpc-h/darwin-x86_64/dbgen and b/benchbox/_binaries/tpc-h/darwin-x86_64/dbgen differ diff --git a/benchbox/_binaries/tpc-h/darwin-x86_64/qgen b/benchbox/_binaries/tpc-h/darwin-x86_64/qgen index 5f96ea408d..3b0c20767b 100755 Binary files a/benchbox/_binaries/tpc-h/darwin-x86_64/qgen and b/benchbox/_binaries/tpc-h/darwin-x86_64/qgen differ diff --git a/benchbox/base.py b/benchbox/base.py index ab6a054c50..21a5627631 100644 --- a/benchbox/base.py +++ b/benchbox/base.py @@ -99,6 +99,11 @@ class BaseBenchmark(BenchmarkResultValidationMixin, VerbosityMixin, ABC): #: instead of constructing first and mutating ``output_dir`` afterward. DATA_SOURCE_BENCHMARK: ClassVar[Optional[str]] = None + #: Set only for benchmarks whose queries need schema objects but no data files. + #: This is distinct from DATA_SOURCE_BENCHMARK=None, which normally means + #: that the benchmark generates its own data. + SKIP_DATA_LOADING: ClassVar[bool] = False + def __init__( self, scale_factor: float = 1.0, diff --git a/benchbox/cli/commands/run.py b/benchbox/cli/commands/run.py index eea2f657c2..07bdc92ca6 100644 --- a/benchbox/cli/commands/run.py +++ b/benchbox/cli/commands/run.py @@ -44,7 +44,12 @@ from benchbox.cli.logo import rich_logo from benchbox.cli.orchestrator import BenchmarkOrchestrator, resolved_deployment_mode from benchbox.cli.output import ResultExporter -from benchbox.cli.platform import get_platform_alias_mode, get_platform_manager, normalize_platform_name +from benchbox.cli.platform import ( + get_platform_alias_mode, + get_platform_manager, + normalize_platform_name, + resolve_platform_selector, +) from benchbox.cli.platform_checks import check_and_setup_platform_credentials from benchbox.cli.platform_hooks import PlatformHookRegistry, PlatformOptionError from benchbox.cli.presentation.system import display_system_recommendations @@ -1065,37 +1070,46 @@ def _resolve_platform_mode(s: types.SimpleNamespace) -> None: if not s.platform_key: return - caps = PlatformRegistry.get_platform_capabilities(s.platform_key) + try: + platform_key = resolve_platform_selector(s.platform_key) + except ValueError as exc: + console.print(f"[red]❌ {escape(str(exc))}[/red]") + if s.logger: + s.logger.error(str(exc)) + s.ctx.exit(1) + return + + caps = PlatformRegistry.get_platform_capabilities(platform_key) if caps is None: - is_df_platform_legacy = is_dataframe_platform(s.platform_key) - is_df_available = is_df_platform_legacy and list_available_dataframe_platforms().get(s.platform_key, False) + is_df_platform_legacy = is_dataframe_platform(platform_key) + is_df_available = is_df_platform_legacy and list_available_dataframe_platforms().get(platform_key, False) if not is_df_available and not s.dry_run: from benchbox.utils.dependencies import get_install_command - console.print(f"[red]❌ Platform '{s.platform_key}' is not available (missing dependencies)[/red]") - install_cmd = get_install_command(s.platform_key) + console.print(f"[red]❌ Platform '{platform_key}' is not available (missing dependencies)[/red]") + install_cmd = get_install_command(platform_key) console.print(f"Run [cyan]{escape(install_cmd)}[/cyan] to install dependencies.") s.ctx.exit(1) s.resolved_mode = "dataframe" else: if s.mode is not None: - if not PlatformRegistry.supports_mode(s.platform_key, s.mode): + if not PlatformRegistry.supports_mode(platform_key, s.mode): supported_modes = [] if caps.supports_sql: supported_modes.append("sql") if caps.supports_dataframe: supported_modes.append("dataframe") - console.print(f"[red]❌ Platform '{s.platform_key}' does not support {s.mode} mode[/red]") + console.print(f"[red]❌ Platform '{platform_key}' does not support {s.mode} mode[/red]") console.print(f"[yellow]Supported modes: {', '.join(supported_modes)}[/yellow]") if s.logger: - s.logger.error(f"Platform {s.platform_key} does not support mode: {s.mode}") + s.logger.error(f"Platform {platform_key} does not support mode: {s.mode}") s.ctx.exit(1) s.resolved_mode = s.mode else: s.resolved_mode = caps.default_mode if s.resolved_mode == "sql": - if s.platform_key == "polars": + if platform_key == "polars": try: import polars # noqa: F401 @@ -1103,24 +1117,24 @@ def _resolve_platform_mode(s: types.SimpleNamespace) -> None: except ImportError: is_available = False else: - is_available = s.platform_manager.is_platform_available(s.platform_key) + is_available = s.platform_manager.is_platform_available(platform_key) else: is_available = caps.supports_dataframe - if is_available and s.platform_key in ["polars", "pandas", "cudf", "dask"]: + if is_available and platform_key in ["polars", "pandas", "cudf", "dask"]: df_platforms = list_available_dataframe_platforms() - legacy_key = f"{s.platform_key}-df" - is_available = df_platforms.get(legacy_key, df_platforms.get(s.platform_key, False)) + legacy_key = f"{platform_key}-df" + is_available = df_platforms.get(legacy_key, df_platforms.get(platform_key, False)) if not is_available and not s.dry_run: from benchbox.utils.dependencies import get_install_command - console.print(f"[red]❌ Platform '{s.platform_key}' is not available (missing dependencies)[/red]") - install_cmd = get_install_command(s.platform_key) + console.print(f"[red]❌ Platform '{platform_key}' is not available (missing dependencies)[/red]") + install_cmd = get_install_command(platform_key) console.print(f"Run [cyan]{escape(install_cmd)}[/cyan] to install dependencies.") s.ctx.exit(1) if s.logger and s.resolved_mode: - s.logger.debug(f"Resolved execution mode for {s.platform_key}: {s.resolved_mode}") + s.logger.debug(f"Resolved execution mode for {platform_key}: {s.resolved_mode}") def _check_benchmark_platform_compatibility(s: types.SimpleNamespace) -> None: @@ -1128,7 +1142,12 @@ def _check_benchmark_platform_compatibility(s: types.SimpleNamespace) -> None: if not s.platform_key or not s.benchmark: return - block_reason = PlatformRegistry.get_benchmark_block_reason(s.platform_key, s.benchmark) + try: + gated_platform = resolve_platform_selector(s.platform_key) + except ValueError: + gated_platform = s.platform_key + + block_reason = PlatformRegistry.get_benchmark_block_reason(gated_platform, s.benchmark) if block_reason is None: return diff --git a/benchbox/cli/dryrun.py b/benchbox/cli/dryrun.py index a00895d47b..d8b98f5ac0 100644 --- a/benchbox/cli/dryrun.py +++ b/benchbox/cli/dryrun.py @@ -9,14 +9,15 @@ from typing import TYPE_CHECKING, Any +import click from rich.panel import Panel from rich.syntax import Syntax from rich.table import Table from rich.text import Text # Import DryRunExecutor from core module -from benchbox.core.dryrun import DryRunExecutor as CoreDryRunExecutor -from benchbox.core.schemas import BenchmarkConfig, DatabaseConfig, DryRunResult +from benchbox.core.dryrun import DryRunExecutor as CoreDryRunExecutor, DryRunQueryExtractionError +from benchbox.core.schemas import BenchmarkConfig, DatabaseConfig, DryRunResult, SystemProfile from benchbox.utils.printing import quiet_console if TYPE_CHECKING: # pragma: no cover - typing only @@ -933,6 +934,18 @@ def __init__(self, output_dir=None): self.console = quiet_console self.display = DryRunDisplay(self.console) + def execute_dry_run( + self, + benchmark_config: BenchmarkConfig, + system_profile: SystemProfile, + database_config: DatabaseConfig | None, + ) -> DryRunResult: + """Execute a dry run and translate rendering failures into clean CLI errors.""" + try: + return super().execute_dry_run(benchmark_config, system_profile, database_config) + except DryRunQueryExtractionError as exc: + raise click.ClickException(str(exc)) from exc + def display_dry_run_results(self, result: DryRunResult): """Display dry run results using the display component.""" self.display.display_dry_run_results(result) diff --git a/benchbox/cli/orchestrator.py b/benchbox/cli/orchestrator.py index 48dfc85ef5..d86ae0ed36 100644 --- a/benchbox/cli/orchestrator.py +++ b/benchbox/cli/orchestrator.py @@ -81,6 +81,21 @@ def _build_failure_result(config: BenchmarkConfig, exc: Exception) -> BenchmarkR ) +def _local_datagen_name(benchmark: Any, benchmark_name: str) -> str: + """Return the datagen folder name a benchmark reads its local data from. + + Benchmarks that share another benchmark's data (``get_data_source_benchmark``) + read the source's folder, except those that generate their own output + (``GENERATES_OWN_OUTPUT``, e.g. tpcds_obt, which transforms TPC-DS data into + its own OBT table). Pointing those at the source folder makes the runner + reuse the source manifest and load the source tables instead of their own. + """ + if getattr(benchmark, "GENERATES_OWN_OUTPUT", False): + return benchmark_name.lower() + data_source = getattr(benchmark, "get_data_source_benchmark", lambda: None)() + return (data_source or benchmark_name).lower() + + class BenchmarkOrchestrator: """Orchestrates benchmark execution using platform adapters.""" @@ -151,11 +166,14 @@ def _get_benchmark_instance(self, config: BenchmarkConfig, system_profile): # the get_data_source_benchmark() instance method (no # DATA_SOURCE_BENCHMARK class attribute) were not resolved at # construction, so redirect them to the shared root now. Class-attr - # sharers were already constructed with it. + # sharers were already constructed with it. Benchmarks that generate + # their own output (e.g. tpcds_obt, which transforms source data into + # a separate OBT table) must keep their own output_dir: redirecting + # would hide their generated files from the loader. if getattr(benchmark_class, "DATA_SOURCE_BENCHMARK", None) is None: - data_source = getattr(benchmark_instance, "get_data_source_benchmark", lambda: None)() - if data_source and self.custom_output_dir is None: - shared_path = self.directory_manager.get_datagen_path(data_source.lower(), config.scale_factor) + source_name = _local_datagen_name(benchmark_instance, config.name) + if source_name != config.name.lower() and self.custom_output_dir is None: + shared_path = self.directory_manager.get_datagen_path(source_name, config.scale_factor) benchmark_instance.output_dir = shared_path return benchmark_instance @@ -387,8 +405,7 @@ def _resolve_custom_output_root(self, config: BenchmarkConfig, benchmark, platfo if not is_cloud_path(self.custom_output_dir): return self.custom_output_dir - data_source = getattr(benchmark, "get_data_source_benchmark", lambda: None)() - source_name = (data_source or config.name).lower() + source_name = _local_datagen_name(benchmark, config.name) local_cache_path = self.directory_manager.get_datagen_path(source_name, config.scale_factor) if is_databricks_path(self.custom_output_dir): diff --git a/benchbox/cli/platform.py b/benchbox/cli/platform.py index 5e9f42f466..9707d86184 100644 --- a/benchbox/cli/platform.py +++ b/benchbox/cli/platform.py @@ -28,6 +28,7 @@ from benchbox.core.platform_registry import PlatformRegistry from benchbox.core.schemas import LibraryInfo, PlatformInfo from benchbox.platforms.adapter_factory import _reject_removed_platform +from benchbox.platforms.clickhouse.deployment_mode import CLICKHOUSE_LEGACY_SELECTOR_MAP from benchbox.utils.printing import quiet_console console = quiet_console @@ -54,6 +55,36 @@ def normalize_platform_name(name: str) -> str: return PLATFORM_ALIASES.get(normalized, normalized) +def resolve_platform_selector(selector: str) -> str: + """Return the platform key that availability and dependency checks apply to. + + A ``:`` selector resolves to the first-class platform + for ClickHouse and otherwise to the base platform, after confirming the base + platform offers that deployment. A platform that declares no deployment modes + accepts only ``local``, matching ``PlatformRegistry.supports_deployment_mode`` + and the adapter factory. A selector without a deployment suffix is returned + unchanged. + + Raises: + ValueError: If the deployment is not offered by the base platform. + """ + key = selector.lower() + if key in CLICKHOUSE_LEGACY_SELECTOR_MAP: + return CLICKHOUSE_LEGACY_SELECTOR_MAP[key] + base, separator, deployment = key.partition(":") + if not separator: + return key + base = normalize_platform_name(base) + if PlatformRegistry.supports_deployment_mode(base, deployment): + return base + available = PlatformRegistry.get_available_deployment_modes(base) + if available: + raise ValueError( + f"Platform '{base}' does not support deployment mode '{deployment}'. Available: {', '.join(available)}" + ) + raise ValueError(f"Platform '{base}' does not support deployment modes. Remove the ':{deployment}' suffix.") + + def get_platform_alias_mode(name: str) -> DefaultMode | None: """Return an execution mode explicitly implied by a scoped CLI alias.""" return PLATFORM_ALIAS_MODES.get(name.lower()) diff --git a/benchbox/cli/tuning.py b/benchbox/cli/tuning.py index bc8c48ba98..cf4efc7c31 100644 --- a/benchbox/cli/tuning.py +++ b/benchbox/cli/tuning.py @@ -276,7 +276,7 @@ def run_tuning_wizard( if not interactive: # Non-interactive: use defaults - return _apply_defaults_to_config(config, defaults, platform) + return _apply_defaults_to_config(config, defaults, platform, benchmark) # Step 1: Select tuning mode (simple/advanced/baseline) console.print("\n[bold cyan]Step 1: Tuning Mode[/bold cyan]") @@ -357,19 +357,19 @@ def _run_simple_wizard( elif platform == "snowflake" and defaults.get("enable_clustering"): if Confirm.ask("Enable clustering keys for improved query performance?", default=True): - config.enable_platform_optimization(TuningType.CLUSTERING) + config.enable_platform_optimization(TuningType.CLUSTERING, benchmark=benchmark) console.print("[green]✓ Clustering enabled[/green]") elif platform == "bigquery": if Confirm.ask("Enable partitioning and clustering?", default=True): - config.enable_platform_optimization(TuningType.PARTITIONING) - config.enable_platform_optimization(TuningType.CLUSTERING) + config.enable_platform_optimization(TuningType.PARTITIONING, benchmark=benchmark) + config.enable_platform_optimization(TuningType.CLUSTERING, benchmark=benchmark) console.print("[green]✓ Partitioning and clustering enabled[/green]") elif platform == "redshift": if Confirm.ask("Enable distribution and sort keys?", default=True): - config.enable_platform_optimization(TuningType.DISTRIBUTION) - config.enable_platform_optimization(TuningType.SORTING) + config.enable_platform_optimization(TuningType.DISTRIBUTION, benchmark=benchmark) + config.enable_platform_optimization(TuningType.SORTING, benchmark=benchmark) console.print("[green]✓ Distribution and sort keys enabled[/green]") # Show summary @@ -419,15 +419,15 @@ def _run_advanced_wizard( if platform == "databricks": _configure_databricks_optimizations(config) elif platform == "snowflake": - _configure_snowflake_optimizations(config) + _configure_snowflake_optimizations(config, benchmark) elif platform == "bigquery": - _configure_bigquery_optimizations(config) + _configure_bigquery_optimizations(config, benchmark) elif platform == "redshift": - _configure_redshift_optimizations(config) + _configure_redshift_optimizations(config, benchmark) elif platform == "duckdb": - _configure_duckdb_optimizations(config, defaults) + _configure_duckdb_optimizations(config, defaults, benchmark) elif platform in {"clickhouse", "clickhouse-local", "clickhouse-server"}: - _configure_clickhouse_optimizations(config) + _configure_clickhouse_optimizations(config, benchmark) # Step 4: Validation Options console.print("\n[bold cyan]Step 4: Data Validation[/bold cyan]") @@ -441,6 +441,29 @@ def _run_advanced_wizard( return config +def _confirm_table_layout( + config: UnifiedTuningConfiguration, + prompt: str, + tuning_type: TuningType, + success_message: str, + benchmark: str = "tpch", + default: bool = True, +) -> None: + """Confirm one table-layout choice and persist it to the config. + + Args: + config: Configuration to populate + prompt: Confirm prompt shown to the user + tuning_type: Table-layout tuning type to enable on confirmation + success_message: Message printed when the choice is confirmed + benchmark: Benchmark name for default table layouts + default: Default answer for the confirm prompt + """ + if Confirm.ask(prompt, default=default): + config.enable_platform_optimization(tuning_type, benchmark=benchmark) + console.print(f"[green]{success_message}[/green]") + + def _configure_databricks_optimizations(config: UnifiedTuningConfiguration) -> None: """Configure Databricks-specific optimizations. @@ -460,53 +483,53 @@ def _configure_databricks_optimizations(config: UnifiedTuningConfiguration) -> N console.print("[green]✓ Auto Compact enabled[/green]") -def _configure_snowflake_optimizations(config: UnifiedTuningConfiguration) -> None: +def _configure_snowflake_optimizations(config: UnifiedTuningConfiguration, benchmark: str = "tpch") -> None: """Configure Snowflake-specific optimizations. Args: config: Configuration to populate + benchmark: Benchmark name for default table layouts """ - if Confirm.ask("Enable clustering keys?", default=True): - config.enable_platform_optimization(TuningType.CLUSTERING) - console.print("[green]✓ Clustering keys enabled[/green]") + _confirm_table_layout( + config, "Enable clustering keys?", TuningType.CLUSTERING, "✓ Clustering keys enabled", benchmark + ) -def _configure_bigquery_optimizations(config: UnifiedTuningConfiguration) -> None: +def _configure_bigquery_optimizations(config: UnifiedTuningConfiguration, benchmark: str = "tpch") -> None: """Configure BigQuery-specific optimizations. Args: config: Configuration to populate + benchmark: Benchmark name for default table layouts """ - if Confirm.ask("Enable table partitioning?", default=True): - config.enable_platform_optimization(TuningType.PARTITIONING) - console.print("[green]✓ Partitioning enabled[/green]") - - if Confirm.ask("Enable clustering?", default=True): - config.enable_platform_optimization(TuningType.CLUSTERING) - console.print("[green]✓ Clustering enabled[/green]") + _confirm_table_layout( + config, "Enable table partitioning?", TuningType.PARTITIONING, "✓ Partitioning enabled", benchmark + ) + _confirm_table_layout(config, "Enable clustering?", TuningType.CLUSTERING, "✓ Clustering enabled", benchmark) -def _configure_redshift_optimizations(config: UnifiedTuningConfiguration) -> None: +def _configure_redshift_optimizations(config: UnifiedTuningConfiguration, benchmark: str = "tpch") -> None: """Configure Redshift-specific optimizations. Args: config: Configuration to populate + benchmark: Benchmark name for default table layouts """ - if Confirm.ask("Enable distribution keys?", default=True): - config.enable_platform_optimization(TuningType.DISTRIBUTION) - console.print("[green]✓ Distribution keys enabled[/green]") - - if Confirm.ask("Enable sort keys?", default=True): - config.enable_platform_optimization(TuningType.SORTING) - console.print("[green]✓ Sort keys enabled[/green]") + _confirm_table_layout( + config, "Enable distribution keys?", TuningType.DISTRIBUTION, "✓ Distribution keys enabled", benchmark + ) + _confirm_table_layout(config, "Enable sort keys?", TuningType.SORTING, "✓ Sort keys enabled", benchmark) -def _configure_duckdb_optimizations(config: UnifiedTuningConfiguration, defaults: dict[str, Any]) -> None: +def _configure_duckdb_optimizations( + config: UnifiedTuningConfiguration, defaults: dict[str, Any], benchmark: str = "tpch" +) -> None: """Configure DuckDB-specific optimizations. Args: config: Configuration to populate defaults: Default settings + benchmark: Benchmark name for default table layouts """ # Show system recommendations first memory_limit = defaults.get("memory_limit_str", "4GB") @@ -517,35 +540,32 @@ def _configure_duckdb_optimizations(config: UnifiedTuningConfiguration, defaults # Prompt for table-level optimizations if Confirm.ask("Enable partitioning for large tables?", default=False): - config.enable_platform_optimization(TuningType.PARTITIONING) + config.enable_platform_optimization(TuningType.PARTITIONING, benchmark=benchmark) console.print("[green]✓ Partitioning enabled[/green]") console.print("[dim] Tables will be partitioned by appropriate columns (e.g., date)[/dim]") if Confirm.ask("Enable sorting (ORDER BY) for query optimization?", default=True): - config.enable_platform_optimization(TuningType.SORTING) + config.enable_platform_optimization(TuningType.SORTING, benchmark=benchmark) console.print("[green]✓ Sorting enabled[/green]") console.print("[dim] Tables will be sorted by frequently queried columns[/dim]") -def _configure_clickhouse_optimizations(config: UnifiedTuningConfiguration) -> None: +def _configure_clickhouse_optimizations(config: UnifiedTuningConfiguration, benchmark: str = "tpch") -> None: """Configure ClickHouse-specific optimizations. Args: config: Configuration to populate + benchmark: Benchmark name for default table layouts """ - if Confirm.ask("Enable partitioning?", default=True): - config.enable_platform_optimization(TuningType.PARTITIONING) - console.print("[green]✓ Partitioning enabled[/green]") - - if Confirm.ask("Enable sorting (ORDER BY)?", default=True): - config.enable_platform_optimization(TuningType.SORTING) - console.print("[green]✓ Sorting enabled[/green]") + _confirm_table_layout(config, "Enable partitioning?", TuningType.PARTITIONING, "✓ Partitioning enabled", benchmark) + _confirm_table_layout(config, "Enable sorting (ORDER BY)?", TuningType.SORTING, "✓ Sorting enabled", benchmark) def _apply_defaults_to_config( config: UnifiedTuningConfiguration, defaults: dict[str, Any], platform: str, + benchmark: str = "tpch", ) -> UnifiedTuningConfiguration: """Apply default settings to configuration for non-interactive mode. @@ -553,6 +573,7 @@ def _apply_defaults_to_config( config: Configuration to populate defaults: Default settings platform: Target platform + benchmark: Benchmark name for default table layouts Returns: Configured tuning settings @@ -565,13 +586,13 @@ def _apply_defaults_to_config( config.enable_platform_optimization(TuningType.Z_ORDERING) config.enable_platform_optimization(TuningType.AUTO_OPTIMIZE) elif platform == "snowflake": - config.enable_platform_optimization(TuningType.CLUSTERING) + config.enable_platform_optimization(TuningType.CLUSTERING, benchmark=benchmark) elif platform == "bigquery": - config.enable_platform_optimization(TuningType.PARTITIONING) - config.enable_platform_optimization(TuningType.CLUSTERING) + config.enable_platform_optimization(TuningType.PARTITIONING, benchmark=benchmark) + config.enable_platform_optimization(TuningType.CLUSTERING, benchmark=benchmark) elif platform == "redshift": - config.enable_platform_optimization(TuningType.DISTRIBUTION) - config.enable_platform_optimization(TuningType.SORTING) + config.enable_platform_optimization(TuningType.DISTRIBUTION, benchmark=benchmark) + config.enable_platform_optimization(TuningType.SORTING, benchmark=benchmark) return config diff --git a/benchbox/core/ai_primitives/benchmark.py b/benchbox/core/ai_primitives/benchmark.py index 0333fa6d3e..e7b97eb9ef 100644 --- a/benchbox/core/ai_primitives/benchmark.py +++ b/benchbox/core/ai_primitives/benchmark.py @@ -26,6 +26,7 @@ format_cost_warning, ) from benchbox.core.ai_primitives.queries import AIQueryManager +from benchbox.utils.cloud_storage import normalize_output_dir from benchbox.utils.path_utils import get_benchmark_runs_datagen_path logger = logging.getLogger(__name__) @@ -152,7 +153,10 @@ def __init__( # Setup directories (reuse TPC-H data) if output_dir is None: output_dir = get_benchmark_runs_datagen_path("tpch", scale_factor) - self.output_dir = Path(output_dir) + # normalize_output_dir keeps a CloudStagingPath/DatabricksPath handler + # intact; Path(...) would stringify it to the local cache and drop the + # cloud upload target the orchestrator resolved at construction time. + self.output_dir = normalize_output_dir(output_dir) # Initialize components self.query_manager: AIQueryManager = AIQueryManager() diff --git a/benchbox/core/benchmark_registry.yaml b/benchbox/core/benchmark_registry.yaml index c10a3db75d..94a3689118 100644 --- a/benchbox/core/benchmark_registry.yaml +++ b/benchbox/core/benchmark_registry.yaml @@ -278,8 +278,8 @@ benchmark_metadata: description: Read operation benchmarks testing SELECT queries category: Primitives support_status: beta - num_queries: 136 - query_description: Multiple read test queries + num_queries: 157 + query_description: 157 SQL queries (153 DuckDB-transpilable); 152 DataFrame queries, 148 gateable supports_streams: false default_scale: 0.01 scale_options: @@ -405,8 +405,8 @@ benchmark_metadata: description: Uniformly-random Join Order schema smoke-test data category: Academic support_status: repo_only - num_queries: 13 - query_description: 13 synthetic smoke queries + num_queries: 113 + query_description: 113 JOB queries (13 historical smoke plus 100 canonical extensions) supports_streams: false default_scale: 1.0 scale_options: @@ -586,7 +586,7 @@ benchmark_metadata: display_name: Vector Search description: Vector similarity search benchmark (kNN, ANN, filtered) category: AI/ML - support_status: beta + support_status: stable num_queries: 6 query_description: 6 queries (kNN cosine/L2, filtered, recall@k, ANN, multi-category) supports_streams: false diff --git a/benchbox/core/dataframe/data_loader.py b/benchbox/core/dataframe/data_loader.py index 9cdada0791..9c291b57f9 100644 --- a/benchbox/core/dataframe/data_loader.py +++ b/benchbox/core/dataframe/data_loader.py @@ -36,7 +36,12 @@ import json import logging import os +import re import shutil +import sys +import uuid +from collections.abc import Iterator +from contextlib import contextmanager from dataclasses import dataclass, field from datetime import datetime from enum import Enum @@ -102,6 +107,32 @@ # destroying raw datagen output (e.g. .tbl files) in the same directory. _KNOWN_FORMAT_DIRS = frozenset(f.value for f in DataFormat) # {"csv", "parquet", "arrow"} +_CACHE_TEMP_FILE_PATTERN = re.compile(r"\.\d+\.[0-9a-f]{32}\.tmp$") + + +def _is_cache_temp_file(name: str) -> bool: + return _CACHE_TEMP_FILE_PATTERN.search(name) is not None + + +def _fsync_path(path: Path) -> None: + fd = os.open(path, os.O_RDWR if sys.platform == "win32" else os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +@contextmanager +def _atomic_cache_write(target_path: Path) -> Iterator[Path]: + tmp_path = target_path.with_name(f"{target_path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + try: + yield tmp_path + _fsync_path(tmp_path) + os.replace(tmp_path, target_path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + class ConversionStatus(Enum): """Status of format conversion.""" @@ -484,7 +515,8 @@ def convert_csv_to_parquet( # Write Parquet logger.debug(f"Writing {target_path}") - pq.write_table(table, target_path, **write_kwargs) + with _atomic_cache_write(target_path) as tmp_path: + pq.write_table(table, tmp_path, **write_kwargs) row_count = table.num_rows logger.info(f"Converted {source_path.name} → {target_path.name}: {row_count:,} rows") @@ -829,7 +861,7 @@ def save_manifest( manifest_path = self.get_manifest_path(benchmark, scale_factor, format) manifest_path.parent.mkdir(parents=True, exist_ok=True) - with open(manifest_path, "w", encoding="utf-8") as f: + with _atomic_cache_write(manifest_path) as tmp_path, open(tmp_path, "w", encoding="utf-8") as f: json.dump(manifest.to_dict(), f, indent=2) def clear_cache( @@ -1464,10 +1496,6 @@ def _convert_table_files( return converted_list, table_entries def _prune_cache_leaf_files(self, cache_path: Path, tracked_files: set[str]) -> int: - """Prune untracked leaf files from a cache directory. - - Keeps `_manifest.json` and the tracked file names for the current run. - """ if not cache_path.exists(): return 0 @@ -1475,7 +1503,7 @@ def _prune_cache_leaf_files(self, cache_path: Path, tracked_files: set[str]) -> preserved.add("_manifest.json") removed = 0 for child in cache_path.iterdir(): - if child.is_dir() or child.name in preserved: + if child.is_dir() or child.name in preserved or _is_cache_temp_file(child.name): continue child.unlink(missing_ok=True) removed += 1 diff --git a/benchbox/core/dataframe/query_resolution.py b/benchbox/core/dataframe/query_resolution.py index b9aedbb73d..c8a042900c 100644 --- a/benchbox/core/dataframe/query_resolution.py +++ b/benchbox/core/dataframe/query_resolution.py @@ -73,8 +73,9 @@ def get_dataframe_queries_for_benchmark( benchmark_config: Any, benchmark_instance: Any | None, stream_id: int | None = None, + *, + bind_parameters: bool = True, ) -> list[Any]: - """Get DataFrame queries for a benchmark in proper execution order.""" benchmark_id = normalize_benchmark_id(benchmark_config.name) if stream_id is None: stream_id = getattr(benchmark_config, "stream_id", 0) @@ -82,7 +83,9 @@ def get_dataframe_queries_for_benchmark( if benchmark_id == "tpch": return get_tpch_dataframe_queries(stream_id) if benchmark_id == "tpcds": - return get_tpcds_dataframe_queries(benchmark_config, benchmark_instance, stream_id) + return get_tpcds_dataframe_queries( + benchmark_config, benchmark_instance, stream_id, bind_parameters=bind_parameters + ) if benchmark_id == "clickbench": return get_clickbench_dataframe_queries(benchmark_config, benchmark_instance, stream_id) @@ -183,9 +186,12 @@ def get_tpcds_legacy_queries(available_query_ids: list[int], stream_id: int) -> return queries -def resolve_tpcds_stream_queries(stream_queries: list[Any], allow_variant_fallback: bool) -> list[Any]: - """Resolve TPC-DS stream query variants to DataFrame query implementations.""" +_TPCDS_FIRST_STATEMENT_VARIANT = "a" + + +def resolve_tpcds_stream_queries(stream_queries: list[Any], allow_variant_fallback: bool = False) -> list[Any]: from benchbox.core.tpcds.dataframe_queries import TPCDS_DATAFRAME_QUERIES + from benchbox.core.tpcds.streams import MULTI_PART_QUERY_IDS queries: list[Any] = [] missing_variants: list[str] = [] @@ -201,6 +207,12 @@ def resolve_tpcds_stream_queries(stream_queries: list[Any], allow_variant_fallba continue variant_id = f"{base_query_id}{stream_query.variant.lower()}" + if ( + stream_query.variant.lower() == _TPCDS_FIRST_STATEMENT_VARIANT + and int(stream_query.query_id) in MULTI_PART_QUERY_IDS + ): + queries.append(replace(base_query, query_id=variant_id)) + continue variant_query = ( TPCDS_DATAFRAME_QUERIES.get(variant_id) or TPCDS_DATAFRAME_QUERIES.get(variant_id.upper()) @@ -212,14 +224,21 @@ def resolve_tpcds_stream_queries(stream_queries: list[Any], allow_variant_fallba missing_variants.append(variant_id) if allow_variant_fallback: + logger.warning( + "TPC-DS DataFrame query %s has no implementation; running %s under its name " + "(tpcds_dataframe_variant_fallback=true), so its result does not answer the SQL query %s", + variant_id, + base_query_id, + variant_id, + ) queries.append(replace(base_query, query_id=variant_id)) if missing_variants and not allow_variant_fallback: missing = ", ".join(sorted(set(missing_variants))) raise RuntimeError( "TPC-DS DataFrame SQL parity check failed: missing variant DataFrame implementations " - f"for [{missing}]. Set option tpcds_dataframe_variant_fallback=true to allow " - "non-parity fallback execution." + f"for [{missing}]. Set option tpcds_dataframe_variant_fallback=true to run the base " + "implementation in their place (timings only: the results answer a different query)." ) return queries @@ -229,13 +248,27 @@ def get_tpcds_dataframe_queries( benchmark_config: Any, benchmark_instance: Any | None, stream_id: int, + *, + bind_parameters: bool = True, +) -> list[Any]: + queries = _resolve_tpcds_dataframe_queries(benchmark_config, benchmark_instance, stream_id) + if not bind_parameters: + return queries + from benchbox.core.tpcds.dataframe_queries.production_binding import bind_power_stream_queries + + return bind_power_stream_queries(queries, benchmark_config, stream_id) + + +def _resolve_tpcds_dataframe_queries( + benchmark_config: Any, + benchmark_instance: Any | None, + stream_id: int, ) -> list[Any]: - """Get TPC-DS DataFrame queries in stream-permuted order with variant resolution.""" from benchbox.core.tpcds.dataframe_queries import TPCDS_DATAFRAME_QUERIES from benchbox.core.tpcds.streams import create_standard_streams options_map = getattr(benchmark_config, "options", {}) or {} - allow_variant_fallback = bool(options_map.get("tpcds_dataframe_variant_fallback", True)) + allow_variant_fallback = bool(options_map.get("tpcds_dataframe_variant_fallback", False)) available_query_ids = sorted( int(qid[1:]) diff --git a/benchbox/core/dataframe/query_validation.py b/benchbox/core/dataframe/query_validation.py index 66598fdc08..25549084be 100644 --- a/benchbox/core/dataframe/query_validation.py +++ b/benchbox/core/dataframe/query_validation.py @@ -91,7 +91,7 @@ def validate_dataframe_query_results( reason = ( "TPC-DS expected row counts are not seed-aligned with this DataFrame run" if benchmark_id == "tpcds" - else "TPC-H row-count evidence requires SF1 reference-equivalent parameters" + else "TPC-H answer-file row counts apply only to SF 1 runs with qgen default parameters (no seed)" ) for row in successful_rows: _attach_skipped_validation(row, reason) @@ -207,12 +207,9 @@ def _apply_expected_row_counts( def _tpch_reference_context(scale_factor: float, seed: int | None) -> bool: - if float(scale_factor) != 1.0: - return False - from benchbox.core.tpch.benchmark import get_reference_seed + from benchbox.core.tpch.benchmark import binds_answer_set_parameters, has_answer_set - reference_seed = get_reference_seed(float(scale_factor)) - return seed is None or seed == reference_seed + return has_answer_set(scale_factor) and binds_answer_set_parameters(seed) def _bounded_message(value: str) -> str: diff --git a/benchbox/core/datavault/benchmark.py b/benchbox/core/datavault/benchmark.py index 84065bc6c8..e5e5b8caa0 100644 --- a/benchbox/core/datavault/benchmark.py +++ b/benchbox/core/datavault/benchmark.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Optional, Union from benchbox.base import BaseBenchmark +from benchbox.core.query_catalog_base import CLOUD_TRANSLATED_DIALECTS, TranslatableQueryMixin if TYPE_CHECKING: from cloudpathlib import CloudPath @@ -28,13 +29,14 @@ get_create_all_tables_sql, get_table_loading_order, ) +from benchbox.utils.cloud_storage import normalize_output_dir from benchbox.utils.path_utils import get_benchmark_runs_datagen_path from benchbox.utils.scale_factor import format_scale_factor logger = logging.getLogger(__name__) -class DataVaultBenchmark(BaseBenchmark): +class DataVaultBenchmark(TranslatableQueryMixin, BaseBenchmark): """Data Vault 2.0 benchmark implementation using TPC-H source data. This benchmark transforms TPC-H's 8 tables into 21 Data Vault tables: @@ -58,6 +60,11 @@ class DataVaultBenchmark(BaseBenchmark): # HashAlgorithm Literal) must stay in sync with this tuple. SUPPORTED_HASH_ALGORITHMS = ("md5", "sha256") + # Queries are written in DuckDB SQL; cloud engines reject its SUBSTRING + # FROM/FOR and INTERVAL '90 days' forms, so translate for them. + _source_dialect = "duckdb" + _translated_dialects = CLOUD_TRANSLATED_DIALECTS + def __init__( self, scale_factor: float = 1.0, @@ -115,9 +122,12 @@ def __init__( self.compression_level = compression_level # Set output_dir from parameter if provided, otherwise use default - # (BaseBenchmark doesn't handle output_dir, so we set it explicitly) + # (BaseBenchmark doesn't handle output_dir, so we set it explicitly). + # normalize_output_dir keeps a CloudStagingPath/DatabricksPath handler + # intact; Path(...) on one would stringify it to the local cache and + # drop the cloud upload target resolved at construction time. if output_dir is not None: - self.output_dir = Path(output_dir) if isinstance(output_dir, str) else output_dir + self.output_dir = normalize_output_dir(output_dir) elif not hasattr(self, "output_dir") or self.output_dir is None: # Honor BENCHBOX_OUTPUT_DIR at construction; falls back to # Path.cwd()/benchmark_runs/datagen when no override is set. @@ -313,11 +323,17 @@ def generate_data( self.tables = dv_files return dv_files - def get_query(self, query_id: Union[int, str]) -> str: + def supported_dialects(self) -> list[str]: + """Return dialects whose query rendering get_queries() actually provides.""" + return ["duckdb", *self._translated_dialects] + + def get_query(self, query_id: Union[int, str], dialect: Optional[str] = None) -> str: """Get the SQL text for a specific Data Vault query. Args: query_id: Query identifier (1-22) + dialect: Optional target SQL dialect; cloud dialects are translated + from the DuckDB source Returns: SQL query text adapted for Data Vault schema @@ -325,7 +341,7 @@ def get_query(self, query_id: Union[int, str]) -> str: Raises: ValueError: If query_id is not valid """ - return self.query_manager.get_query(query_id) + return self.translate_for_dialect(self.query_manager.get_query(query_id), dialect) def get_all_queries(self) -> dict[str, str]: """Get all available Data Vault queries. @@ -342,13 +358,14 @@ def get_queries(self, dialect: Optional[str] = None) -> dict[str, str]: benchmark interface expected by the platform adapters. Args: - dialect: Optional SQL dialect for translation (not yet implemented) + dialect: Optional target SQL dialect; cloud dialects are translated + from the DuckDB source Returns: Dictionary mapping query IDs to SQL text """ # Convert keys to strings for consistency with other benchmarks - return {str(k): v for k, v in self.query_manager.get_all_queries().items()} + return {str(k): self.translate_for_dialect(v, dialect) for k, v in self.query_manager.get_all_queries().items()} def execute_query( self, diff --git a/benchbox/core/datavault/etl/transformer.py b/benchbox/core/datavault/etl/transformer.py index d5c31e0353..ed84165210 100644 --- a/benchbox/core/datavault/etl/transformer.py +++ b/benchbox/core/datavault/etl/transformer.py @@ -19,6 +19,7 @@ ) from benchbox.core.datavault.schema import LOADING_ORDER, TABLES_BY_NAME from benchbox.utils.compression_mixin import CompressionMixin +from benchbox.utils.file_format import detect_compression logger = logging.getLogger(__name__) @@ -354,6 +355,9 @@ def _load_tpch_tables(self, conn: Any, tpch_dir: Path, tables: Optional[list[str # Find the appropriate file pattern for this table file_pattern = self._find_tpch_file_pattern(tpch_dir, table_name) + # Parallel CSV scans can re-pin evicted buffers by seeking, which + # compressed streams cannot support. Keep plain-file scans parallel. + parallel = "false" if detect_compression(file_pattern) else "true" # DuckDB read_csv handles compression automatically based on file extension # and supports glob patterns for reading multiple sharded files @@ -363,7 +367,8 @@ def _load_tpch_tables(self, conn: Any, tpch_dir: Path, tables: Optional[list[str '{file_pattern}', delim='|', header=false, - names={columns} + names={columns}, + parallel={parallel} ) """ conn.execute(sql) diff --git a/benchbox/core/dryrun.py b/benchbox/core/dryrun.py index 5b45b1db3f..093d4a1b61 100644 --- a/benchbox/core/dryrun.py +++ b/benchbox/core/dryrun.py @@ -88,6 +88,10 @@ def _build_table_ddl_entry(tuning_clauses: Any) -> dict[str, Any]: } +class DryRunQueryExtractionError(RuntimeError): + """Raised when adapter-backed dry-run query rendering cannot be trusted.""" + + class DryRunExecutor: """Handles dry run execution and output generation.""" @@ -185,6 +189,8 @@ def execute_dry_run( result.estimated_resources = self._estimate_resources(benchmark, system_profile, benchmark_config.name) + except DryRunQueryExtractionError: + raise except Exception as e: result.warnings.append(f"Dry run execution error: {e}") @@ -439,7 +445,7 @@ def _extract_queries( return self._extract_tpch_maintenance_operations(benchmark, benchmark_config) elif test_execution_type == "combined": # Combined: standard queries (Q1-Q22) + maintenance operations - queries = self._extract_standard_queries(benchmark) + queries = self._extract_standard_queries(benchmark, benchmark_config, platform_adapter) maintenance_ops = self._extract_tpch_maintenance_operations(benchmark, benchmark_config) queries.update(maintenance_ops) return queries @@ -455,7 +461,7 @@ def _extract_queries( return self._extract_tpcds_maintenance_operations(benchmark, benchmark_config) elif test_execution_type == "combined": # Combined: standard queries (Q1-Q99) + maintenance operations - queries = self._extract_standard_queries(benchmark) + queries = self._extract_standard_queries(benchmark, benchmark_config, platform_adapter) maintenance_ops = self._extract_tpcds_maintenance_operations(benchmark, benchmark_config) queries.update(maintenance_ops) return queries @@ -464,8 +470,10 @@ def _extract_queries( benchmark, benchmark_config, test_execution_type, platform_adapter ) - return self._extract_standard_queries(benchmark) + return self._extract_standard_queries(benchmark, benchmark_config, platform_adapter) + except DryRunQueryExtractionError: + raise except Exception: return {} @@ -477,9 +485,40 @@ def _extract_queries_via_real_test_execution( platform_adapter=None, ) -> dict[str, str]: """Return the benchmark's queries for test modes without opening a platform connection.""" - return self._extract_standard_queries(benchmark) + return self._extract_standard_queries(benchmark, benchmark_config, platform_adapter) + + def _extract_standard_queries( + self, + benchmark, + benchmark_config: BenchmarkConfig | None = None, + platform_adapter=None, + ) -> dict[str, str]: + if platform_adapter is not None and hasattr(platform_adapter, "_get_dialect_queries"): + benchmark_name = normalize_benchmark_id(benchmark_config.name) if benchmark_config else "" + try: + queries = platform_adapter._get_dialect_queries( + benchmark, + benchmark_slug=benchmark_name, + connection=None, + strict_translation=True, + ) + if benchmark_config is not None and hasattr(platform_adapter, "_filter_queries"): + options = benchmark_config.options if isinstance(benchmark_config.options, dict) else {} + queries = platform_adapter._filter_queries( + queries, + benchmark, + benchmark_name, + { + "query_subset": benchmark_config.queries, + "categories": options.get("categories"), + }, + ) + except Exception as exc: + raise DryRunQueryExtractionError(f"Dry-run query extraction failed: {exc}") from exc + if queries and isinstance(next(iter(queries.keys())), int): + return {str(k): v for k, v in queries.items()} + return queries - def _extract_standard_queries(self, benchmark) -> dict[str, str]: if hasattr(benchmark, "get_queries"): queries = benchmark.get_queries() if queries: @@ -608,7 +647,7 @@ def _extract_dataframe_queries( family = "pandas" if platform_info.family == DataFrameFamily.PANDAS else "expression" try: - queries = get_dataframe_queries_for_benchmark(benchmark_config, benchmark_instance) + queries = get_dataframe_queries_for_benchmark(benchmark_config, benchmark_instance, bind_parameters=False) result = {} for query in queries: diff --git a/benchbox/core/equivalence/builders/__init__.py b/benchbox/core/equivalence/builders/__init__.py index 290ae5af2c..97f0daf0d6 100644 --- a/benchbox/core/equivalence/builders/__init__.py +++ b/benchbox/core/equivalence/builders/__init__.py @@ -8,12 +8,29 @@ from benchbox.core.equivalence.builders.flightdata import build_flightdata_duckdb from benchbox.core.equivalence.builders.h2odb import build_h2odb_duckdb from benchbox.core.equivalence.builders.joinorder_synthetic import build_joinorder_synthetic_duckdb +from benchbox.core.equivalence.builders.nyctaxi import ( + NYCTAXI_DF_TO_SQL_IDS, + NYCTAXI_SQL_TO_DF_IDS, + build_nyctaxi_duckdb, +) from benchbox.core.equivalence.builders.read_primitives import build_read_primitives_duckdb from benchbox.core.equivalence.builders.ssb import build_ssb_duckdb +from benchbox.core.equivalence.builders.tpcds import build_tpcds_duckdb +from benchbox.core.equivalence.builders.tpch import build_tpch_duckdb +from benchbox.core.equivalence.builders.tpch_skew import build_tpch_skew_duckdb +from benchbox.core.equivalence.builders.tsbs_devops import ( + TSBS_DEVOPS_DF_TO_SQL_IDS, + TSBS_DEVOPS_SQL_TO_DF_IDS, + build_tsbs_devops_duckdb, +) __all__ = [ "CrossSurfaceData", "_load_duckdb_cell", + "NYCTAXI_DF_TO_SQL_IDS", + "NYCTAXI_SQL_TO_DF_IDS", + "TSBS_DEVOPS_DF_TO_SQL_IDS", + "TSBS_DEVOPS_SQL_TO_DF_IDS", "build_amplab_duckdb", "build_clickbench_duckdb", "build_coffeeshop_duckdb", @@ -21,6 +38,11 @@ "build_flightdata_duckdb", "build_h2odb_duckdb", "build_joinorder_synthetic_duckdb", + "build_nyctaxi_duckdb", "build_read_primitives_duckdb", "build_ssb_duckdb", + "build_tpcds_duckdb", + "build_tpch_duckdb", + "build_tpch_skew_duckdb", + "build_tsbs_devops_duckdb", ] diff --git a/benchbox/core/equivalence/builders/base.py b/benchbox/core/equivalence/builders/base.py index 120f177e8b..f6aa85ecce 100644 --- a/benchbox/core/equivalence/builders/base.py +++ b/benchbox/core/equivalence/builders/base.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -27,6 +27,37 @@ class CrossSurfaceData: # comparison stays a single bounded cell. benchmark: Any data_dir: Path + query_parameters: dict[str, Any] = field(default_factory=dict) + + +def _assemble_simple_duckdb_cell( + benchmark: Any, + output_dir: Path, + table_names: Sequence[str], + *, + label: str, + dataframe_query: Callable[[Any], Any], +) -> CrossSurfaceData: + """Generate data, load it into an in-memory DuckDB cell, and wire both surfaces. + + Shared assembly for builders whose SQL and DataFrame surfaces share the same + query ids (the common TPC-H / TPC-DS / TPC-H Skew shape). Builders with + divergent id schemes (NYC Taxi, TSBS DevOps) keep their own assembly plus a + builder-local id map. + """ + output_dir = Path(output_dir) + benchmark.generate_data() + + connection = _load_duckdb_cell(benchmark, output_dir, table_names, label=label) + sql_queries = benchmark.get_queries() + return CrossSurfaceData( + connection=connection, + query_ids=list(sql_queries.keys()), + reference_sql=lambda query_id: sql_queries[query_id], + dataframe_query=dataframe_query, + benchmark=benchmark, + data_dir=output_dir, + ) def _load_duckdb_cell(benchmark: Any, output_dir: Path, table_names: Sequence[str], *, label: str) -> Any: diff --git a/benchbox/core/equivalence/builders/datavault.py b/benchbox/core/equivalence/builders/datavault.py index 0f267efb24..472a8d8d23 100644 --- a/benchbox/core/equivalence/builders/datavault.py +++ b/benchbox/core/equivalence/builders/datavault.py @@ -1,4 +1,4 @@ -"""Data Vault cross-surface gate builder (staged).""" +"""Data Vault cross-surface gate builder.""" from __future__ import annotations diff --git a/benchbox/core/equivalence/builders/nyctaxi.py b/benchbox/core/equivalence/builders/nyctaxi.py new file mode 100644 index 0000000000..a0d1c0fe9f --- /dev/null +++ b/benchbox/core/equivalence/builders/nyctaxi.py @@ -0,0 +1,126 @@ +"""NYC Taxi cross-surface gate builder.""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from benchbox.core.equivalence.builders.base import CrossSurfaceData, _load_duckdb_cell + +if TYPE_CHECKING: + from benchbox.core.nyctaxi.downloader import NYCTaxiDataDownloader + +# Builder-local SQL-slug to DataFrame-ID map. The SQL surface uses slug IDs +# (for example ``trips-per-hour``) while the DataFrame surface uses ``Q`` +# IDs. Most pairs join on the display name; Q20 (``Weekday/Weekend +# Comparison`` vs ``Weekday vs Weekend``) and Q23 (``Single-Day Summary`` vs +# ``Single Day Summary``) are hand-resolved punctuation variants. +NYCTAXI_SQL_TO_DF_IDS: dict[str, str] = { + "trips-per-hour": "Q1", + "trips-per-day": "Q2", + "trips-per-month": "Q3", + "trips-by-day-of-week": "Q4", + "top-pickup-zones": "Q5", + "top-dropoff-zones": "Q6", + "top-routes": "Q7", + "borough-summary": "Q8", + "revenue-by-payment-type": "Q9", + "fare-distribution": "Q10", + "tip-analysis": "Q11", + "surcharge-revenue": "Q12", + "distance-distribution": "Q13", + "passenger-count-analysis": "Q14", + "trip-duration-analysis": "Q15", + "rate-code-summary": "Q16", + "airport-trips": "Q17", + "vendor-comparison": "Q18", + "hourly-zone-heatmap": "Q19", + "weekday-weekend-comparison": "Q20", + "rush-hour-analysis": "Q21", + "monthly-year-over-year": "Q22", + "single-day-summary": "Q23", + "zone-detail": "Q24", + "full-scan-count": "Q25", +} + +NYCTAXI_DF_TO_SQL_IDS: dict[str, str] = {df_id: sql_id for sql_id, df_id in NYCTAXI_SQL_TO_DF_IDS.items()} + + +def _force_offline_synthesis(downloader: NYCTaxiDataDownloader) -> None: + """Replace the network download with direct synthetic generation. + + The gate must stay offline and hermetic: patching ``_process_parquet_file`` + to call ``_generate_synthetic_month`` directly means no + ``urllib.request.urlretrieve`` ever runs, so the gate passes with network + disabled. ``taxi_zones`` generation is already local (embedded + ``TAXI_ZONES_DATA``), so only the trips path needs forcing. + """ + + def _synthetic_only(self: NYCTaxiDataDownloader, url: str, writer: Any, start_trip_id: int) -> int: + return self._generate_synthetic_month(writer, start_trip_id) + + downloader._process_parquet_file = _synthetic_only.__get__(downloader) # type: ignore[method-assign] + + +# Fixed query seed so SQL windows are deterministic across gate runs. The +# query manager draws random date offsets and zone picks per query; without a +# seed the SQL and DF surfaces can never share a window. +NYCTAXI_GATE_SEED = 42 + + +def _extract_sql_windows(sql_queries: dict[str, str]) -> dict[str, dict[str, Any]]: + """Parse each rendered SQL query's date window and zone into DF overrides.""" + overrides: dict[str, dict[str, Any]] = {} + for sql_id, sql in sql_queries.items(): + df_id = NYCTAXI_SQL_TO_DF_IDS[sql_id] + dates = re.findall(r"'(\d{4}-\d{2}-\d{2})(?: \d{2}:\d{2}:\d{2})?'", sql) + params: dict[str, Any] = {} + if len(dates) >= 2: + params["start_date"] = datetime.fromisoformat(dates[0]) + params["end_date"] = datetime.fromisoformat(dates[1]) + zone = re.search(r"pickup_location_id = (\d+)", sql) + if zone: + params["zone_id"] = int(zone.group(1)) + if params: + overrides[df_id] = params + return overrides + + +def build_nyctaxi_duckdb(scale_factor: float, output_dir: Path) -> CrossSurfaceData: + """Generate NYC Taxi data offline, load it into in-memory DuckDB, and wire both surfaces.""" + import urllib.request + + from benchbox.core.nyctaxi.benchmark import NYCTaxiBenchmark + from benchbox.core.nyctaxi.dataframe_queries import NYCTAXI_DATAFRAME_QUERIES + from benchbox.core.nyctaxi.dataframe_queries.parameters import set_parameter_overrides + + output_dir = Path(output_dir) + benchmark = NYCTaxiBenchmark(scale_factor=scale_factor, output_dir=output_dir, seed=NYCTAXI_GATE_SEED) + _force_offline_synthesis(benchmark.downloader) + + def _forbidden_urlretrieve(*args: object, **kwargs: object) -> object: + raise AssertionError("nyctaxi gate must not touch the network") + + urllib.request.urlretrieve = _forbidden_urlretrieve # type: ignore[method-assign] + try: + benchmark.generate_data() + finally: + import importlib + + importlib.reload(urllib.request) + + connection = _load_duckdb_cell(benchmark, output_dir, ["taxi_zones", "trips"], label="NYC Taxi") + sql_queries = benchmark.get_queries() + # Align the DF surface with the seeded SQL windows before wiring queries. + set_parameter_overrides(_extract_sql_windows(sql_queries)) + queries = NYCTAXI_DATAFRAME_QUERIES + return CrossSurfaceData( + connection=connection, + query_ids=list(sql_queries.keys()), + reference_sql=lambda query_id: sql_queries[query_id], + dataframe_query=lambda query_id: queries.get_or_raise(NYCTAXI_SQL_TO_DF_IDS[query_id]), + benchmark=benchmark, + data_dir=output_dir, + ) diff --git a/benchbox/core/equivalence/builders/tpcds.py b/benchbox/core/equivalence/builders/tpcds.py new file mode 100644 index 0000000000..58a90307b6 --- /dev/null +++ b/benchbox/core/equivalence/builders/tpcds.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import dataclasses +from hashlib import sha256 +from pathlib import Path +from typing import Any + +from benchbox.core.equivalence.builders.base import CrossSurfaceData + +TPCDS_GATE_QUERY_IDS = tuple(str(number) for number in range(1, 100)) + ("14b", "23b", "24b", "39b") +_MULTIPART_QUERIES = frozenset({14, 23, 24, 39}) + + +def validate_tpcds_gate_data(data: CrossSurfaceData) -> None: + from benchbox.core.tpcds.dataframe_queries.production_binding import power_parameter_seed, query_number + + if len(data.query_ids) != len(TPCDS_GATE_QUERY_IDS) or set(data.query_ids) != set(TPCDS_GATE_QUERY_IDS): + raise ValueError("TPC-DS requires all 99 base queries and the four b statements") + bindings = getattr(data.dataframe_query, "bindings", {}) + if set(bindings) != set(range(1, 100)): + raise ValueError("TPC-DS requires all 99 parameter bindings") + metadata = data.query_parameters + required = {"seed", "power_stream_id", "rngseed", "dsqgen_stream_id", "scale_factor", "dsqgen_sha256"} + if not required.issubset(metadata): + raise ValueError("TPC-DS requires a complete recorded draw identity") + if metadata["scale_factor"] != data.benchmark.scale_factor: + raise ValueError("TPC-DS draw scale disagrees with its generated data") + if set(metadata.get("bindings", {})) != {str(number) for number in range(1, 100)}: + raise ValueError("TPC-DS requires all 99 canonical binding records") + if set(metadata.get("sql_sha256", {})) != set(TPCDS_GATE_QUERY_IDS): + raise ValueError("TPC-DS requires all 103 SQL statement hashes") + rngseed = power_parameter_seed(metadata["seed"], metadata["power_stream_id"]) + if metadata["rngseed"] != rngseed or metadata["dsqgen_stream_id"] != 0: + raise ValueError("TPC-DS draw identity disagrees with the Power stream") + for number, binding in bindings.items(): + if ( + binding.query_id != number + or binding.scale_factor != metadata["scale_factor"] + or binding.seed != rngseed + or binding.stream_id != metadata["dsqgen_stream_id"] + or binding.dsqgen_sha256 != metadata["dsqgen_sha256"] + or dataclasses.asdict(binding) != metadata["bindings"][str(number)] + ): + raise ValueError(f"TPC-DS Q{number} binding disagrees with its recorded draw") + for query_id in data.query_ids: + sql = data.reference_sql(query_id) + if not sql.strip() or sha256(sql.encode("utf-8")).hexdigest() != metadata["sql_sha256"][query_id]: + raise ValueError(f"TPC-DS {query_id} SQL disagrees with its recorded statement") + query = data.dataframe_query(query_id) + if query.query_id != f"Q{query_id}": + raise ValueError(f"TPC-DS {query_id} is missing its intended DataFrame statement") + number = query_number(query_id) + if number is None: + raise ValueError(f"TPC-DS {query_id} is not a valid statement ID") + binding = bindings[number] + for family in ("expression", "pandas"): + implementation = query.get_impl_for_family(family) + if implementation is None: + raise ValueError(f"TPC-DS {query_id} is missing its {family} implementation") + if getattr(implementation, "parameter_binding", None) is not binding: + raise ValueError(f"TPC-DS {query_id} {family} is missing its captured binding") + + +def build_tpcds_duckdb( + scale_factor: float, output_dir: Path, *, seed: int | None = None, stream_id: int = 0 +) -> CrossSurfaceData: + import sqlglot + from sqlglot import exp + + from benchbox.core.equivalence.builders.base import _load_duckdb_cell + from benchbox.core.tpcds.dataframe_queries import TPCDS_DATAFRAME_QUERIES + from benchbox.core.tpcds.dataframe_queries.parameter_adapters import ADAPTERS + from benchbox.core.tpcds.dataframe_queries.production_binding import ( + bind_power_stream, + bind_queries, + power_parameter_seed, + query_number, + ) + from benchbox.core.tpcds.schema.registry import TABLES + from benchbox.tpcds import TPCDS + + if stream_id < 0 or (seed is not None and seed < 0): + raise ValueError("TPC-DS seed and Power stream must be nonnegative") + if set(ADAPTERS) != set(range(1, 100)): + raise ValueError("TPC-DS requires parameter adapters for every query from 1 through 99") + if set(TPCDS_DATAFRAME_QUERIES.get_query_ids()) != {f"Q{query_id}" for query_id in TPCDS_GATE_QUERY_IDS}: + raise ValueError("TPC-DS DataFrame registry must contain exactly the 103 intended statements") + + benchmark = TPCDS(scale_factor=scale_factor, output_dir=Path(output_dir)) + dsqgen = benchmark._impl.query_manager.dsqgen + bindings = bind_power_stream( + range(1, 100), scale_factor=scale_factor, seed=seed, stream_id=stream_id, dsqgen=dsqgen + ) + if set(bindings) != set(range(1, 100)): + raise ValueError("TPC-DS requires all 99 parameter bindings") + queries = dict( + zip( + TPCDS_GATE_QUERY_IDS, + bind_queries( + (TPCDS_DATAFRAME_QUERIES.get_or_raise(f"Q{query_id}") for query_id in TPCDS_GATE_QUERY_IDS), bindings + ), + strict=True, + ) + ) + sql_queries = {} + for query_id in TPCDS_GATE_QUERY_IDS: + number = query_number(query_id) + if number is None: + raise ValueError(f"TPC-DS {query_id} is not a valid statement ID") + binding = bindings[number] + variant = query_id[-1] if query_id.endswith("b") else "a" if number in _MULTIPART_QUERIES else None + template_id = f"{number}{variant or ''}" + raw = dsqgen.generate_with_parameters( + template_id, dict(binding.logged), scale_factor=scale_factor, seed=binding.seed + ) + translated = benchmark._impl.translate_query_text(raw, "netezza", "duckdb") + sql = benchmark._impl._apply_target_dialect_overrides(number, translated, "duckdb", variant=variant) + statements = sqlglot.parse(sql, read="duckdb") + if len(statements) != 1 or not isinstance(statements[0], exp.Query): + raise ValueError(f"TPC-DS {query_id} must render exactly one SQL query") + sql_queries[query_id] = sql + + def dataframe_query(query_id: str) -> Any: + return queries[query_id] + + dataframe_query.bindings = bindings + data = CrossSurfaceData( + connection=None, + query_ids=TPCDS_GATE_QUERY_IDS, + reference_sql=sql_queries.__getitem__, + dataframe_query=dataframe_query, + benchmark=benchmark, + data_dir=Path(output_dir), + query_parameters={ + "seed": seed, + "power_stream_id": stream_id, + "rngseed": power_parameter_seed(seed, stream_id), + "dsqgen_stream_id": 0, + "scale_factor": scale_factor, + "dsqgen_sha256": sha256(dsqgen.dsqgen_path.read_bytes()).hexdigest(), + "bindings": {str(number): dataclasses.asdict(binding) for number, binding in bindings.items()}, + "sql_sha256": {query_id: sha256(sql.encode("utf-8")).hexdigest() for query_id, sql in sql_queries.items()}, + }, + ) + validate_tpcds_gate_data(data) + benchmark.generate_data() + connection = _load_duckdb_cell(benchmark, Path(output_dir), [table.name for table in TABLES], label="TPC-DS") + return dataclasses.replace(data, connection=connection) diff --git a/benchbox/core/equivalence/builders/tpch.py b/benchbox/core/equivalence/builders/tpch.py new file mode 100644 index 0000000000..eb1df58e8e --- /dev/null +++ b/benchbox/core/equivalence/builders/tpch.py @@ -0,0 +1,30 @@ +"""TPC-H cross-surface gate builder.""" + +from __future__ import annotations + +from pathlib import Path + +from benchbox.core.equivalence.builders.base import CrossSurfaceData, _assemble_simple_duckdb_cell + + +def build_tpch_duckdb(scale_factor: float, output_dir: Path) -> CrossSurfaceData: + """Generate TPC-H data, load it into in-memory DuckDB, and wire both surfaces.""" + from benchbox.core.tpch.schema import TABLES + from benchbox.tpch import TPCH + + def _dataframe_query(query_id: str) -> object: + import benchbox.core.dataframe.benchmark_suite # noqa: F401 # break circular import + from benchbox.core.tpch.dataframe_queries import TPCH_DATAFRAME_QUERIES, set_scale_factor_for_benchmark + + # Q11's value threshold is scale-dependent (0.0001/SF): align the + # shared DataFrame parameter seam with this run's scale. + set_scale_factor_for_benchmark("tpch", scale_factor) + return TPCH_DATAFRAME_QUERIES.get_or_raise(f"Q{query_id}") + + return _assemble_simple_duckdb_cell( + TPCH(scale_factor=scale_factor, output_dir=Path(output_dir)), + Path(output_dir), + [table.name for table in TABLES], + label="TPC-H", + dataframe_query=_dataframe_query, + ) diff --git a/benchbox/core/equivalence/builders/tpch_skew.py b/benchbox/core/equivalence/builders/tpch_skew.py new file mode 100644 index 0000000000..beeb45abc9 --- /dev/null +++ b/benchbox/core/equivalence/builders/tpch_skew.py @@ -0,0 +1,40 @@ +"""TPC-H Skew cross-surface gate builder.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +from benchbox.core.equivalence.builders.base import CrossSurfaceData, _assemble_simple_duckdb_cell + +# Fixed generator seed so the skewed cell is deterministic across gate runs. +# Without this the Zipfian sampler draws fresh randomness per build and +# reference row counts drift between runs (Q17 NULL vs valued, Q8/Q20 +# vacuous vs populated). +TPCH_SKEW_GATE_SEED = 42 + + +def build_tpch_skew_duckdb(scale_factor: float, output_dir: Path) -> CrossSurfaceData: + """Generate TPC-H Skew data, load it into in-memory DuckDB, and wire both surfaces.""" + from benchbox.core.tpch.schema import TABLES + from benchbox.core.tpch_skew.skew_config import SkewPreset, get_preset_config + from benchbox.tpch_skew import TPCHSkew + + # Q11's value threshold is scale-dependent (0.0001/SF): align the shared + # DataFrame parameter seam with this run's scale on each query lookup. + def _dataframe_query(query_id: str) -> object: + import benchbox.core.dataframe.benchmark_suite # noqa: F401 # break circular import + from benchbox.core.tpch.dataframe_queries import set_scale_factor_for_benchmark + from benchbox.core.tpch_skew.dataframe_queries import TPCH_SKEW_DATAFRAME_QUERIES + + set_scale_factor_for_benchmark("tpch_skew", scale_factor) + return TPCH_SKEW_DATAFRAME_QUERIES.get_or_raise(f"Q{query_id}") + + skew_config = replace(get_preset_config(SkewPreset.MODERATE), seed=TPCH_SKEW_GATE_SEED) + return _assemble_simple_duckdb_cell( + TPCHSkew(scale_factor=scale_factor, output_dir=Path(output_dir), skew_config=skew_config), + Path(output_dir), + [table.name for table in TABLES], + label="TPC-H Skew", + dataframe_query=_dataframe_query, + ) diff --git a/benchbox/core/equivalence/builders/tsbs_devops.py b/benchbox/core/equivalence/builders/tsbs_devops.py new file mode 100644 index 0000000000..446d8a4b11 --- /dev/null +++ b/benchbox/core/equivalence/builders/tsbs_devops.py @@ -0,0 +1,89 @@ +"""TSBS DevOps cross-surface gate builder.""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +from benchbox.core.equivalence.builders.base import CrossSurfaceData, _load_duckdb_cell + +# Builder-local SQL-slug to DataFrame-ID map. The SQL surface uses slug IDs +# (for example ``single-host-12-hr``) while the DataFrame surface uses +# ``Q`` IDs. All 18 pairs are hand-authored: the display names differ in +# punctuation and unit phrasing (``12 Hours`` vs ``12hr``, ``5 Minutes`` vs +# ``5min``), so no mechanical join is reliable. +TSBS_DEVOPS_SQL_TO_DF_IDS: dict[str, str] = { + "single-host-12-hr": "Q1", + "single-host-1-hr": "Q2", + "cpu-max-all-1-hr": "Q3", + "cpu-max-all-8-hr": "Q4", + "double-groupby-1-hr": "Q5", + "double-groupby-5-min": "Q6", + "high-cpu-1-hr": "Q7", + "high-cpu-12-hr": "Q8", + "mem-by-host-1-hr": "Q9", + "low-memory-hosts": "Q10", + "disk-iops-1-hr": "Q11", + "disk-latency": "Q12", + "net-throughput-1-hr": "Q13", + "net-errors": "Q14", + "resource-utilization": "Q15", + "lastpoint": "Q16", + "by-region": "Q17", + "by-service": "Q18", +} + +TSBS_DEVOPS_DF_TO_SQL_IDS: dict[str, str] = {df_id: sql_id for sql_id, df_id in TSBS_DEVOPS_SQL_TO_DF_IDS.items()} + + +# Fixed query seed so SQL windows are deterministic across gate runs. +TSBS_DEVOPS_GATE_SEED = 42 + + +def _extract_sql_windows(sql_queries: dict[str, str]) -> dict[str, dict[str, Any]]: + """Parse each rendered SQL query's window and host into DF overrides.""" + overrides: dict[str, dict[str, Any]] = {} + for sql_id, sql in sql_queries.items(): + df_id = TSBS_DEVOPS_SQL_TO_DF_IDS[sql_id] + times = re.findall(r"'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})'", sql) + params: dict[str, Any] = {} + if len(times) >= 2: + params["start_time"] = datetime.fromisoformat(times[0]) + params["end_time"] = datetime.fromisoformat(times[1]) + host = re.search(r"hostname = '([^']+)'", sql) + if host: + params["hostname"] = host.group(1) + region = re.search(r"(?:t\.region|region) = '([^']+)'", sql) + if region: + params["region"] = region.group(1) + if params: + overrides[df_id] = params + return overrides + + +def build_tsbs_devops_duckdb(scale_factor: float, output_dir: Path) -> CrossSurfaceData: + """Generate TSBS DevOps data, load it into in-memory DuckDB, and wire both surfaces.""" + from benchbox.core.tsbs_devops.benchmark import TSBSDevOpsBenchmark + from benchbox.core.tsbs_devops.dataframe_queries import TSBS_DEVOPS_DATAFRAME_QUERIES + from benchbox.core.tsbs_devops.dataframe_queries.parameters import set_parameter_overrides + from benchbox.core.tsbs_devops.schema import TSBS_DEVOPS_SCHEMA + + output_dir = Path(output_dir) + benchmark = TSBSDevOpsBenchmark(scale_factor=scale_factor, output_dir=output_dir, seed=TSBS_DEVOPS_GATE_SEED) + benchmark.generate_data() + + connection = _load_duckdb_cell(benchmark, output_dir, list(TSBS_DEVOPS_SCHEMA.keys()), label="TSBS DevOps") + sql_queries = benchmark.get_queries() + # Align the DF surface with the seeded SQL windows before wiring queries. + set_parameter_overrides(_extract_sql_windows(sql_queries)) + queries = TSBS_DEVOPS_DATAFRAME_QUERIES + return CrossSurfaceData( + connection=connection, + query_ids=list(sql_queries.keys()), + reference_sql=lambda query_id: sql_queries[query_id], + dataframe_query=lambda query_id: queries.get_or_raise(TSBS_DEVOPS_SQL_TO_DF_IDS[query_id]), + benchmark=benchmark, + data_dir=output_dir, + ) diff --git a/benchbox/core/equivalence/cross_surface.py b/benchbox/core/equivalence/cross_surface.py index ab40e832c9..44bab0bbb6 100644 --- a/benchbox/core/equivalence/cross_surface.py +++ b/benchbox/core/equivalence/cross_surface.py @@ -61,7 +61,12 @@ flightdata (20 SQL and 20 DataFrame ids overlap verbatim; one synthetic month at ``scale_factor=0.01``, which stays offline), and datavault (22 SQL ids ``"1"`` .. ``"22"`` map 1:1 to the DataFrame ids by a mechanical ``Q`` prefix: -``"Q1"`` .. ``"Q22"``). Additional dual-surface +``"Q1"`` .. ``"Q22"``). ``tpch``, ``tpch_skew``, ``nyctaxi``, ``tsbs_devops`` +and ``tpcds`` are enforced too; TPC-DS compares all 103 statements on the +default Power draw at ``SF=0.01`` on Polars, pandas and DataFusion, with each +empty cell classified. :data:`STAGED_GATES` holds gates that run in report +mode only; it is currently empty. +Additional dual-surface benchmarks are added by registering a :class:`CrossSurfaceGate` in :data:`GATES`. Waiver review policy. A ``known_divergences`` entry may carry an OPTIONAL @@ -103,8 +108,13 @@ build_flightdata_duckdb, build_h2odb_duckdb, build_joinorder_synthetic_duckdb, + build_nyctaxi_duckdb, build_read_primitives_duckdb, build_ssb_duckdb, + build_tpcds_duckdb, + build_tpch_duckdb, + build_tpch_skew_duckdb, + build_tsbs_devops_duckdb, ) from benchbox.core.equivalence.dataframe_surface import ( DATAFRAME_BACKENDS, @@ -113,6 +123,9 @@ find_surface_divergences, materialize_rows, ) +from benchbox.utils.printing import quiet_console as console + +_BACKEND_FAMILIES = {"expression": "expression", "pandas": "pandas", "datafusion": "expression"} if TYPE_CHECKING: # pragma: no cover - typing only # Deferred at runtime: importing benchbox.core.tpchavoc.validation triggers @@ -351,6 +364,96 @@ def _order_by_result_key(sql: str) -> list[int] | None: return resolved +def _derived_order_violation(sql: str, columns: Sequence[tuple[str, str]], rows: list[tuple[Any, ...]]) -> str | None: + """Check returned row order against an ``ORDER BY`` that :func:`_order_by_result_key` cannot map. + + ``columns`` holds the query's output ``(name, type)`` pairs. Each ``ORDER BY`` + term is rewritten over those output columns and evaluated on ``rows`` in a + separate DuckDB connection, using the query's own directions and NULL order. + The rows are in order when sorting them by those keys leaves the key sequence + unchanged, so rows that tie on every key may appear in any order. Returns a + description of the first out-of-order row, or ``None`` when the order holds or + cannot be checked: no ``ORDER BY``, an unparseable query, duplicate output + names, or a term that references a qualified or missing column. An error + while evaluating the sort keys over the returned rows is reported as a + violation, so the check fails closed instead of passing unverified. + """ + import duckdb + import sqlglot + from sqlglot import exp + + if len(rows) < 2: + return None + try: + tree = sqlglot.parse_one(sql, read="duckdb") + except Exception: # noqa: BLE001 - an unparseable query is just "not checkable" + return None + order = tree.args.get("order") if isinstance(tree, exp.Select) else None + names = [name.lower() for name, _ in columns] + if order is None or len(set(names)) != len(names): + return None + positions = {name: index for index, name in enumerate(names)} + + terms = [] + for index, ordered in enumerate(order.expressions): + target = ordered.this.copy() + if isinstance(target, exp.Literal) and target.is_int: + ordinal = int(target.name) + if not 1 <= ordinal <= len(names): + return None + target = exp.column(f"__c{ordinal - 1}") + else: + references = list(target.find_all(exp.Column)) + if not references: + # A term with no column reference, such as DuckDB's ORDER BY ALL, + # cannot be evaluated over the returned columns. + return None + for column in references: + # A qualified reference names an input column, which SQL resolves + # before output names, so it cannot be read from the result. + position = None if column.table else positions.get(column.name.lower()) + if position is None: + return None + replacement = exp.column(f"__c{position}") + if column is target: + target = replacement + else: + column.replace(replacement) + key = ordered.copy() + key.set("this", exp.column(f"__k{index}")) + terms.append((target.sql(dialect="duckdb"), key.sql(dialect="duckdb"))) + + def column_type(declared: str) -> str: + return "DOUBLE" if declared.upper().startswith(("DECIMAL", "NUMERIC", "FLOAT", "REAL")) else declared + + def cell(value: Any) -> Any: + return None if isinstance(value, float) and value != value else value + + connection = duckdb.connect() + try: + definitions = ", ".join(f"__c{index} {column_type(declared)}" for index, (_, declared) in enumerate(columns)) + connection.execute(f"CREATE TABLE candidate (__pos BIGINT, {definitions})") + placeholders = ", ".join("?" for _ in range(len(columns) + 1)) + connection.executemany( + f"INSERT INTO candidate VALUES ({placeholders})", + [(position, *(cell(value) for value in row)) for position, row in enumerate(rows)], + ) + keys = ", ".join(f"{expression} AS __k{index}" for index, (expression, _) in enumerate(terms)) + keyed = f"SELECT __pos, {keys} FROM candidate" + returned = connection.execute(f"SELECT * EXCLUDE (__pos) FROM ({keyed}) ORDER BY __pos").fetchall() + required = connection.execute( + f"SELECT * EXCLUDE (__pos) FROM ({keyed}) ORDER BY {', '.join(key for _, key in terms)}, __pos" + ).fetchall() + except duckdb.Error as exc: + return f"the ORDER BY check could not evaluate the sort keys over the returned rows: {exc}" + finally: + connection.close() + for position, (actual, expected) in enumerate(zip(returned, required, strict=True)): + if actual != expected: + return f"returned row {position} breaks the ORDER BY: sort key {actual}, expected {expected}" + return None + + def _resolve_order_term( target: Any, projections: Sequence[Any], @@ -440,6 +543,12 @@ class CrossSurfaceGate: # mismatch the benchmark's own parameters cannot bridge), with a written # reason here. NEVER add an entry to mute a query you could make discriminating. legitimately_empty: dict[Any, str] = field(default_factory=dict) + # False for a run whose parameter draw differs from the one the + # ``legitimately_empty`` entries describe (a non-default TPC-DS seed or Power + # stream). Vacuous queries are then still excluded from coverage and listed, + # but neither an unclassified nor a stale emptiness fails the run; divergences + # and flaky cells still do. + vacuity_classified: bool = True backends: tuple[str, ...] = DATAFRAME_BACKENDS tolerance: float = 1e-10 surface_independence: str = SURFACE_INDEPENDENCE_SHARED_SPEC @@ -477,6 +586,7 @@ def find_cross_surface_divergences( validator: ResultValidator, backends: tuple[str, ...] = DATAFRAME_BACKENDS, reference_row_counts: dict[Any, int] | None = None, + all_null_references: set[Any] | None = None, ) -> list[SurfaceDivergence]: """Compare each query's DataFrame surface to its own SQL surface on DuckDB. @@ -505,6 +615,11 @@ def find_cross_surface_divergences( (it has no row count); it surfaces as a ``reference`` divergence instead. Default ``None`` keeps behavior unchanged for callers (e.g. the fast-lane integration tests) that do not need the vacuity audit. + all_null_references: Optional mutable set; when supplied it collects the + query ids whose reference returned one all-NULL row. Those queries + are still recorded as 0 rows above, and :func:`_report` lists them + separately so a physical zero-row result and an all-NULL aggregate + row are never conflated. Returns: One :class:`SurfaceDivergence` per query/backend cell whose DataFrame @@ -516,7 +631,16 @@ def find_cross_surface_divergences( def reference_rows(query_id: Any) -> list[tuple[Any, ...]]: rows = fetch_reference_rows(connection, reference_sql(query_id)) if reference_row_counts is not None: - reference_row_counts[query_id] = len(rows) + # Scalar-aggregation queries (SELECT MIN(...) with no GROUP BY) + # collapse an empty join to one all-NULL row, which len() == 1 + # misreads as discriminating. Treat all-NULL single rows as + # 0-row vacuous so None == None can never pass as coverage. + if len(rows) == 1 and all(value is None for value in rows[0]): + reference_row_counts[query_id] = 0 + if all_null_references is not None: + all_null_references.add(query_id) + else: + reference_row_counts[query_id] = len(rows) return rows def candidate_cells( @@ -551,6 +675,21 @@ def candidate_cells( # catches a real unique-final-key value bug. Every backend of a query shares # the SAME reference, so the extra LIMIT n+1 query runs once, not per backend. probe_result: bool | None = None # None = not yet computed (memo sentinel) + output_columns: list[tuple[str, str]] | None = None + + def derived_order_violation(candidate: list[tuple[Any, ...]]) -> str | None: + # An ORDER BY that does not map to result columns (a CASE or arithmetic + # key, or an outer SELECT *) is checked by evaluating it over the + # candidate's own rows; the shared reference supplies the column names. + nonlocal output_columns + if order_by is not None: + return None + if output_columns is None: + try: + output_columns = [(row[0], row[1]) for row in connection.execute(f"DESCRIBE {sql}").fetchall()] + except Exception: # noqa: BLE001 - an undescribable query keeps the unordered comparison + output_columns = [] + return _derived_order_violation(sql, output_columns, candidate) if output_columns else None def final_key_tied(reference: list[tuple[Any, ...]]) -> bool: nonlocal probe_result @@ -565,7 +704,7 @@ def final_key_tied(reference: list[tuple[Any, ...]]) -> bool: return probe_result for backend in backends: - impl = query.get_impl_for_family(backend) + impl = query.get_impl_for_family(_BACKEND_FAMILIES.get(backend, backend)) if impl is None: # The DataFrame surface does not implement this backend; there is # nothing to compare. Skip it rather than report a false divergence. @@ -592,6 +731,9 @@ def check( order_by=order_by, final_key_tied_beyond_limit=final_key_tied(reference), ) + violation = derived_order_violation(candidate) + if violation is not None: + raise ValidationError(f"Q{query_id}: {violation}") yield backend, check @@ -622,7 +764,7 @@ def count_executed_cells( for query_id in query_ids: query = dataframe_query(query_id) for backend in backends: - if query.get_impl_for_family(backend) is not None: + if query.get_impl_for_family(_BACKEND_FAMILIES.get(backend, backend)) is not None: coverage[backend] += 1 return coverage @@ -740,7 +882,7 @@ def find_cross_surface_dtype_divergences( key = f"{query_id}_{backend}" if key in skip_keys: continue - impl = query.get_impl_for_family(backend) + impl = query.get_impl_for_family(_BACKEND_FAMILIES.get(backend, backend)) if impl is None: continue try: @@ -785,6 +927,7 @@ def find_cross_surface_dtype_divergences( _PRODUCTION_ADAPTERS: dict[str, str] = { "expression": "benchbox.platforms.dataframe.polars_df:PolarsDataFrameAdapter", "pandas": "benchbox.platforms.dataframe.pandas_df:PandasDataFrameAdapter", + "datafusion": "benchbox.platforms.dataframe.datafusion_df:DataFusionDataFrameAdapter", } @@ -907,6 +1050,143 @@ def _is_clickbench_q18_arbitrary_topn(divergence: SurfaceDivergence) -> bool: ("Q20", "Q23", "Q28", "Q29", "Q39", "Q40", "Q41", "Q42"), _CLICKBENCH_VACUOUS ) +_DATAVAULT_Q17_VACUOUS = ( + "0 discriminating rows at the bounded gate cell: Q17 is a scalar SUM(...) with no " + "GROUP BY over the small-quantity-order conjunction (brand + container literals " + "against part/partsupp/lineitem). Verified 2026-09-26 by executing the reference " + "SQL over every brand/container combination present in the built cell (25 brands x " + "40 containers = 1000 combos): every combination returns the single all-NULL row, " + "so neither surface can discriminate anything. Data/literal artifact of the bounded " + "cell, not a load or logic bug. Tracked: plant a satisfying brand/container pair " + "in the generator or gate a larger cell (do NOT change the canonical Q17 query)." +) +_DATAVAULT_LEGITIMATELY_EMPTY: dict[Any, str] = dict.fromkeys(("Q17",), _DATAVAULT_Q17_VACUOUS) + +_TPCH_Q17_VACUOUS = ( + "0 discriminating rows at the bounded gate cell (SF=0.01): Q17 is a scalar SUM(...) with " + "no GROUP BY whose default literals (Brand#23, MED BOX) match no qualifying part/lineitem " + "rows in the small cell, so the reference returns the single all-NULL row and neither " + "surface can discriminate anything: the gate asserts only that both surfaces also return " + "NULL, so a defect in the join, the correlated subquery or the aggregate that still " + "returns NULL would pass. Verified 2026-10-01 by executing the reference SQL over every " + "brand/container combination present in the built cell (25 brands x 40 containers = " + "1000 combos): 856 combinations return a non-NULL value and the other 144, including " + "the default pair, return the all-NULL row. The all-NULL result is a literal artifact " + "of the default parameters against the small cell, not a load or logic bug, and the " + "query is not vacuous in general. Remediation: gate Q17 with a " + "brand/container pair the cell satisfies on both surfaces (do NOT change the canonical " + "Q17 query)." +) + +_TPCH_SKEW_Q17_VACUOUS = ( + "0 discriminating rows at the bounded gate cell (SF=0.01, seed 42): Q17 is a scalar " + "SUM(...) with no GROUP BY whose default literals (Brand#23, MED BOX) match no " + "qualifying part/lineitem rows in the skewed data, so the reference returns the single " + "all-NULL row and neither surface can discriminate anything: the gate asserts only that " + "both surfaces also return NULL, so a defect in the join, the correlated subquery or the " + "aggregate that still returns NULL would pass. Verified 2026-10-01 by executing the " + "reference SQL over every brand/container combination present in the built cell (25 " + "brands x 40 containers = 1000 combos): 496 combinations return a non-NULL value and " + "the other 504, including the default pair, return the all-NULL row. The all-NULL " + "result is a literal artifact of the default parameters against the skewed " + "distribution, not a load or logic bug, and the query is not vacuous in general. " + "Remediation: gate Q17 with a brand/container pair the cell satisfies on " + "both surfaces (do NOT change the canonical Q17 query)." +) + +_JOINORDER_SYNTHETIC_VACUOUS = ( + "Synthetic selectivity, not a bug: the query's multi-table conjunction needs coordinated " + "real-world literals (specific keywords, notes, countries, ratings, link types) that the " + "bounded synthetic cell does not plant on one entity set, so the reference join is empty on " + "BOTH surfaces. Golden entities back the highest-traffic conjunctions (34 discriminating); " + "this tail stays classified until its literals are planted too. Any query NOT listed here " + "that goes vacuous fails the gate." +) +_JOINORDER_SYNTHETIC_LEGITIMATELY_EMPTY: dict[Any, str] = dict.fromkeys( + ( + "1a", + "1c", + "2b", + "2c", + "3b", + "5b", + "7a", + "7b", + "7c", + "8a", + "8b", + "9a", + "9b", + "9c", + "9d", + "11a", + "11b", + "11c", + "12b", + "13b", + "13c", + "14a", + "14b", + "14c", + "15a", + "15b", + "15c", + "15d", + "17a", + "17b", + "17c", + "17d", + "18a", + "18b", + "18c", + "19a", + "19b", + "19c", + "19d", + "20a", + "20b", + "20c", + "21a", + "21b", + "21c", + "22a", + "22b", + "22c", + "22d", + "23a", + "23b", + "23c", + "24a", + "24b", + "25a", + "25b", + "25c", + "26a", + "26b", + "26c", + "27a", + "27b", + "27c", + "28a", + "28b", + "28c", + "29a", + "29b", + "29c", + "30a", + "30b", + "30c", + "31a", + "31b", + "31c", + "32a", + "33a", + "33b", + "33c", + ), + _JOINORDER_SYNTHETIC_VACUOUS, +) + # H2O-DB bounded-cell scale. Its generator base is the 10M-row small tier, so the # shared EQUIVALENCE_SCALE (0.1) would emit ~1M rows; 0.01 gives a ~100k-row cell # (comparable to ClickBench's SF=0.1 cell) that is cheap yet keeps every H2O-DB @@ -1393,6 +1673,110 @@ def _read_primitives_polars_map_gap(divergence: SurfaceDivergence) -> bool: ), } +# TPC-DS deliberately runs its complete 103-statement matrix at SF=0.01 with the +# default Power draw (seed None, stream 0). A full SF=0.05 matrix exceeds the +# gate's short-run budget. Each entry below describes that bounded cell and the +# value its draw binds, not emptiness at every scale or draw: the SQL reference +# returns no rows (or, where noted, one all-NULL aggregate row) and every +# DataFrame backend matches it. The SF=0.01 data has one store (market 2, GMT +# offset -5, Williamson County TN), one return reason, and catalog sales only +# through 2001-11-11, which explains several entries. Every listed statement +# returns rows in the official SF=1 answer set, and the SF=1 qualification +# runner compares it there on nonempty data. A stale entry fails the gate. +_TPCDS_LEGITIMATELY_EMPTY: dict[Any, str] = { + "3": "No November store sale is for manufacturer 808; dropping that filter alone yields 100 rows.", + "4": ( + "No customer has web-channel year totals for both 1998 and 1999 beside the store and catalog totals; " + "dropping either web-year requirement yields rows." + ), + "8": ( + "The only preferred-customer ZIP group with more than ten customers is the NULL ZIP (12 customers), " + "so the ZIP cohort the store prefix must match is empty." + ), + "10": ( + "No customer in the five drawn counties with a January-April 2002 store purchase also bought on the web " + "or by catalog then; dropping that requirement yields a row." + ), + "11": ( + "No customer has both store and web year totals for 1998 and 1999; dropping any one channel-year " + "requirement yields rows." + ), + "13": ( + "No store sale satisfies both the demographic and price bands and the " + "address and profit bands; dropping either group yields a row." + ), + "18": ( + "No 2000 catalog sale matches male, Advanced Degree, the six birth months and " + "the seven states together; dropping any one of those filters yields rows." + ), + "20": "Catalog sales end on 2001-11-11, before the drawn 30-day window from 2002-05-11.", + "23": ( + "No item sells more than four times on one day in 1999-2002 (the maximum " + "is four), so the frequent-item set is empty." + ), + "23b": "No item sells more than four times on one day in 1999-2002, so the frequent-item set is empty.", + "24": "The only store is in market 2, but the draw binds market 10.", + "24b": "The only store is in market 2, but the draw binds market 10.", + "31": "No county has web sales in each of the first three quarters of 2000.", + "32": ( + "No catalog sale in the 90 days from 2000-01-13 is for manufacturer 942; dropping that filter yields a row." + ), + "37": ( + "No item from manufacturers 894, 865, 737 or 959 meets the price, date and inventory filters; dropping " + "the manufacturer filter alone yields rows." + ), + "39": ( + "No warehouse item has a January and February 2000 inventory coefficient of variation above one in both " + "months; dropping either month filter yields rows." + ), + "39b": "The highest January 2000 inventory coefficient of variation is 1.31, below the 1.5 bound.", + "41": ( + "No item from manufacturers 894-934 has a manufacturer with the correlated color, unit and size " + "attributes; dropping that correlated count yields rows." + ), + "49": ( + "No December 2000 sale in any channel has a return amount above 10000 with the other sale filters; " + "dropping the year, the month or any one channel's threshold yields rows." + ), + "54": "No customer address is in Williamson County TN, the only store's county.", + "55": ("No November 1999 store sale is for an item of manager 8; dropping the manager filter yields 66 rows."), + "58": ( + "Five items sell in all three channels in the week of 2000-04-24, but none has revenues within ten " + "percent of each other in every channel." + ), + "61": ("The only store has GMT offset -5, but the draw binds -6; dropping that filter yields a row."), + "64": ( + "No cross-year store sale and catalog return pair meets the item-color filter; dropping the six drawn " + "colors yields 47 rows." + ), + "65": ( + "No item's store revenue in month sequence 1176-1187 is at or below ten percent of its store's average; " + "dropping that bound yields 90 rows." + ), + "69": ( + "No WY, IA or KY customer has an April-June 2003 store purchase without web or catalog purchases then; " + "dropping the store requirement or the year yields rows." + ), + "73": ( + "No 1998-2000 Williamson County ticket on days one or two has one to five items for the household " + "filters; dropping the item-count bound yields 12 rows." + ), + "82": ( + "No item from manufacturers 759, 224, 231 or 687 meets the price, date and inventory filters; dropping " + "the manufacturer filter alone yields rows." + ), + "85": ("No 2000 web return meets the demographic, state and profit bands; dropping the year filter yields a row."), + "90": ( + "Web page character counts never reach the template's fixed 5000-5200 range; dropping that range yields a row." + ), + "91": ( + "No December 1998 call-center return meets the demographic and GMT-offset filters; dropping the month " + "filter yields a row." + ), + "92": ("No web sale in the 90 days from 2000-01-13 is for manufacturer 942; dropping that filter yields a row."), + "93": "The reason table has one row, and it is not the drawn 'reason 33'.", +} + # Registry of ENFORCED gated benchmarks: clean, blocking cross-surface gates whose # DataFrame surface matches its SQL surface. The oracle coverage map reads this set @@ -1400,6 +1784,11 @@ def _read_primitives_polars_map_gap(divergence: SurfaceDivergence) -> bool: # belong here (registering a red gate here would be coverage theater). _FLIGHTDATA_SCALE = 0.01 _DATAVAULT_SCALE = 0.01 +_TPCH_SCALE = 0.01 +_TPCDS_SCALE = 0.01 +_NYCTAXI_SCALE = 0.01 +_TSBS_DEVOPS_SCALE = 0.01 +_TPCH_SKEW_SCALE = 0.01 GATES: dict[str, CrossSurfaceGate] = { "ssb": CrossSurfaceGate( @@ -1454,6 +1843,7 @@ def _read_primitives_polars_map_gap(divergence: SurfaceDivergence) -> bool: "joinorder_synthetic": CrossSurfaceGate( name="joinorder_synthetic", build=build_joinorder_synthetic_duckdb, + legitimately_empty=_JOINORDER_SYNTHETIC_LEGITIMATELY_EMPTY, surface_independence=SURFACE_INDEPENDENCE_SHARED_SPEC, surface_independence_rationale=( "Both DataFrame families are generated through shared JoinOrder translation helpers, so the " @@ -1535,6 +1925,7 @@ def _read_primitives_polars_map_gap(divergence: SurfaceDivergence) -> bool: "datavault": CrossSurfaceGate( name="datavault", build=build_datavault_duckdb, + legitimately_empty=_DATAVAULT_LEGITIMATELY_EMPTY, surface_independence=SURFACE_INDEPENDENCE_SEPARATE, surface_independence_rationale=( "Data Vault expression and pandas DataFrame implementations are separately handwritten for each " @@ -1542,6 +1933,90 @@ def _read_primitives_polars_map_gap(divergence: SurfaceDivergence) -> bool: ), scale_factor=_DATAVAULT_SCALE, ), + # TPC-DS: 103 statements (99 templates plus the B statements of Q14, Q23, + # Q24 and Q39) on the default Power draw at SF=0.01. Polars, pandas and native + # DataFusion are all enforced; CI runs pandas as its own step for the budget. + # Repeats and other seeds and streams run after each merge (trunk.yml). + "tpcds": CrossSurfaceGate( + name="tpcds", + build=build_tpcds_duckdb, + legitimately_empty=_TPCDS_LEGITIMATELY_EMPTY, + backends=("expression", "pandas", "datafusion"), + surface_independence=SURFACE_INDEPENDENCE_SEPARATE, + surface_independence_rationale=( + "TPC-DS expression and pandas DataFrame implementations are separately handwritten for each query." + ), + scale_factor=_TPCDS_SCALE, + ), + "tpch": CrossSurfaceGate( + name="tpch", + build=build_tpch_duckdb, + legitimately_empty={"17": _TPCH_Q17_VACUOUS}, + surface_independence=SURFACE_INDEPENDENCE_SEPARATE, + surface_independence_rationale=( + "TPC-H expression and pandas DataFrame implementations are separately handwritten for each query." + ), + scale_factor=_TPCH_SCALE, + ), + # NYC Taxi: 25 SQL slugs map 1:1 to DataFrame Q1..Q25 via the builder-local + # hand-authored dict. Offline synthetic cell (12k trips at SF=0.01); the + # builder forces synthesis and fails if any network download runs. + # airport-trips is legitimately empty: the synthetic generator hardcodes + # rate_code_id=1 while the query filters IN (2, 3), so the reference + # itself returns 0 rows independent of either surface. + "nyctaxi": CrossSurfaceGate( + name="nyctaxi", + build=build_nyctaxi_duckdb, + legitimately_empty={ + "airport-trips": "Synthetic generator hardcodes rate_code_id=1; query filters rate_code_id IN (2, 3), so the reference returns 0 rows.", + }, + surface_independence=SURFACE_INDEPENDENCE_SEPARATE, + surface_independence_rationale=( + "NYC Taxi expression and pandas DataFrame implementations are separately handwritten for each query." + ), + scale_factor=_NYCTAXI_SCALE, + ), + # TSBS DevOps: 18 SQL slugs map 1:1 to DataFrame Q1..Q18 via the + # builder-local hand-authored dict. Fully offline generator. Three + # threshold queries are legitimately empty: the generator caps usage_user + # at ~55 (base 10-30 x diurnal 1.5 + noise 10) so usage_user > 90 never + # fires, and available_percent never drops below ~17 so the < 10 filter + # never fires, at any probed scale (0.01/0.05/0.1). + "tsbs_devops": CrossSurfaceGate( + name="tsbs_devops", + build=build_tsbs_devops_duckdb, + legitimately_empty={ + "high-cpu-1-hr": "Generator caps usage_user at ~55; usage_user > 90 never fires at SF 0.01-0.1.", + "high-cpu-12-hr": "Generator caps usage_user at ~55; HAVING COUNT(*) > 10 over usage_user > 90 never fires.", + "low-memory-hosts": "Generator floor for available_percent is ~17; MIN(available_percent) < 10 never fires.", + }, + surface_independence=SURFACE_INDEPENDENCE_SEPARATE, + surface_independence_rationale=( + "TSBS DevOps expression and pandas DataFrame implementations are separately handwritten for each query." + ), + scale_factor=_TSBS_DEVOPS_SCALE, + ), + # TPC-H Skew: 22 SQL ids ("1".."22") map 1:1 to the DataFrame ids by the + # mechanical Q prefix ("Q1".."Q22"), the same convention as amplab and + # datavault. Bounded cell at the shared small scale. Q8 and Q17 are + # legitimately empty: Q8 because the skewed generator emits no part rows + # with p_type 'ECONOMY ANODIZED STEEL' at any probed scale (0.01/0.05/0.1), + # Q17 because its default literals match nothing in the skewed cell (see + # _TPCH_SKEW_Q17_VACUOUS). Either way the reference itself returns no + # discriminating rows independent of either surface. + "tpch_skew": CrossSurfaceGate( + name="tpch_skew", + build=build_tpch_skew_duckdb, + legitimately_empty={ + "8": "Skewed generator emits zero 'ECONOMY ANODIZED STEEL' part rows at SF 0.01-0.1; reference returns 0 rows.", + "17": _TPCH_SKEW_Q17_VACUOUS, + }, + surface_independence=SURFACE_INDEPENDENCE_SEPARATE, + surface_independence_rationale=( + "TPC-H Skew expression and pandas DataFrame implementations are separately handwritten for each query." + ), + scale_factor=_TPCH_SKEW_SCALE, + ), } # Staged gates: a load-faithful builder is wired and runnable in report mode, @@ -1552,6 +2027,7 @@ def _read_primitives_polars_map_gap(divergence: SurfaceDivergence) -> bool: # enforcement. # The next gateable benchmarks (nyctaxi, # tpcds_obt, tpch_skew, tsbs_devops) land here first when their builders are wired. + STAGED_GATES: dict[str, CrossSurfaceGate] = {} @@ -1585,6 +2061,7 @@ def _apply_baseline_update( coverage: dict[str, int], reference_row_counts: dict[Any, int], vacuous_cells: int, + all_null_references: set[Any] | None = None, ) -> int: """Drop this gate's resolved known-divergence entries from the YAML baseline, but ONLY when the run is otherwise completely clean. @@ -1626,6 +2103,7 @@ def _apply_baseline_update( legitimately_empty=gate.legitimately_empty, scale_factor=gate.scale_factor, vacuous_cells=vacuous_cells, + all_null_references=all_null_references, ) if exit_code != 0: @@ -1649,7 +2127,31 @@ def _apply_baseline_update( return exit_code -def run_gate(gate: CrossSurfaceGate, *, update_baseline: bool = False) -> int: +def find_flaky_cells(runs: list[list[SurfaceDivergence]]) -> list[tuple[str, str, list[str | None]]]: + per_run = [{(str(d.query_id), d.cell): d.detail for d in divergences} for divergences in runs] + keys = {key for seen in per_run for key in seen} + outcomes = {key: [seen.get(key) for seen in per_run] for key in keys} + flaky = [(query, cell, results) for (query, cell), results in outcomes.items() if len(set(results)) > 1] + return sorted(flaky, key=lambda item: (item[0].zfill(8), item[1])) + + +def _report_flaky(name: str, flaky: list[tuple[str, str, list[str | None]]], repeats: int, *, enforced: bool) -> None: + from rich.text import Text + + from benchbox.utils.printing import emit + + def line(message: str) -> None: + emit(Text(message, no_wrap=True, overflow="ignore"), quiet=False) + + verdict = "FAIL (enforced gate)" if enforced else "report only (staged gate)" + line(f"\n[flaky - outcome changed between {repeats} identical runs] {name}: {len(flaky)} cell(s), {verdict}") + for query, cell, results in flaky: + diverged = sum(1 for result in results if result is not None) + detail = next(result for result in results if result is not None) + line(f" {query}_{cell}: diverged in {diverged} of {repeats} runs; e.g. {detail[:200]}") + + +def run_gate(gate: CrossSurfaceGate, *, update_baseline: bool = False, repeats: int = 1) -> int: """Run one benchmark's cross-surface gate and print a categorized report. When ``update_baseline`` is set, any resolved known-divergence entries are @@ -1664,7 +2166,18 @@ def run_gate(gate: CrossSurfaceGate, *, update_baseline: bool = False) -> int: data = gate.build(gate.scale_factor, Path(tmp)) connection = data.connection reference_row_counts: dict[Any, int] = {} + all_null_references: set[Any] = set() try: + if gate.name == "tpcds": + from benchbox.core.equivalence.builders.tpcds import validate_tpcds_gate_data + from benchbox.core.results.canonical_json import canonical_json_text + + if not gate.backends or len(set(gate.backends)) != len(gate.backends): + raise ValueError("TPC-DS requires distinct nonempty backend selection") + validate_tpcds_gate_data(data) + console.print( + canonical_json_text(data.query_parameters), markup=False, highlight=False, soft_wrap=True, end="" + ) contexts = build_production_contexts( data.benchmark, data.data_dir, backends=gate.backends, scale_factor=gate.scale_factor ) @@ -1677,7 +2190,21 @@ def run_gate(gate: CrossSurfaceGate, *, update_baseline: bool = False) -> int: validator=gate.build_validator(), backends=gate.backends, reference_row_counts=reference_row_counts, + all_null_references=all_null_references, ) + repeat_runs = [divergences] + for _ in range(repeats - 1): + repeat_runs.append( + find_cross_surface_divergences( + connection, + query_ids=data.query_ids, + reference_sql=data.reference_sql, + dataframe_query=data.dataframe_query, + contexts=contexts, + validator=gate.build_validator(), + backends=gate.backends, + ) + ) coverage = count_executed_cells(data.query_ids, data.dataframe_query, gate.backends) # Count vacuous CELLS exactly (one per backend a vacuous query # actually implements), not an estimate: a future gate may implement @@ -1693,11 +2220,18 @@ def run_gate(gate: CrossSurfaceGate, *, update_baseline: bool = False) -> int: total = len(data.query_ids) * len(gate.backends) vacuous_cells_total = sum(vacuous_cells.values()) + flaky = find_flaky_cells(repeat_runs) if repeats > 1 else [] + enforced = gate.name in GATES if update_baseline: - return _apply_baseline_update(gate, divergences, total, coverage, reference_row_counts, vacuous_cells_total) + if flaky: + _report_flaky(gate.name, flaky, repeats, enforced=True) + return 1 + return _apply_baseline_update( + gate, divergences, total, coverage, reference_row_counts, vacuous_cells_total, all_null_references + ) - return _report( + exit_code = _report( divergences, total, coverage, @@ -1707,7 +2241,14 @@ def run_gate(gate: CrossSurfaceGate, *, update_baseline: bool = False) -> int: legitimately_empty=gate.legitimately_empty, scale_factor=gate.scale_factor, vacuous_cells=vacuous_cells_total, + all_null_references=all_null_references, + enforce_vacuity=gate.vacuity_classified, ) + if flaky: + _report_flaky(gate.name, flaky, repeats, enforced=enforced) + if enforced: + return exit_code or 1 + return exit_code def _classification(known: dict[str, str | ClassifiedDivergence], divergence: SurfaceDivergence) -> str | None: @@ -1731,6 +2272,14 @@ def _requires_live_divergence(entry: str | ClassifiedDivergence) -> bool: return entry.requires_live_divergence if isinstance(entry, ClassifiedDivergence) else True +def _emit_report_lines(lines: list[str]) -> None: + from benchbox.utils.printing import get_console + + output = get_console(quiet=False) + for line in lines: + output.print(line, markup=False, highlight=False, soft_wrap=True) + + def _report( divergences: list[SurfaceDivergence], total: int, @@ -1742,6 +2291,8 @@ def _report( legitimately_empty: dict[Any, str] | None = None, scale_factor: float = EQUIVALENCE_SCALE, vacuous_cells: int | None = None, + all_null_references: set[Any] | None = None, + enforce_vacuity: bool = True, ) -> int: """Print a categorized divergence report and return the gate exit code. @@ -1754,7 +2305,9 @@ def _report( * any VACUOUS query - one whose SQL reference returns 0 rows, so every backend compares empty-vs-empty and trivially "matches" without discriminating anything - UNLESS it is explicitly classified in - ``legitimately_empty`` with a rationale. + ``legitimately_empty`` with a rationale, and + * any ``legitimately_empty`` entry whose reference now returns rows, so a + classification cannot outlive the emptiness it describes. The "compared N of M cells" line reports DISCRIMINATING cells only: a vacuous query's cells are excluded from the discriminating count and reported @@ -1769,6 +2322,7 @@ def _report( """ legitimately_empty = legitimately_empty or {} reference_row_counts = reference_row_counts or {} + all_null_references = all_null_references or set() found = {d.key for d in divergences} # A divergence is classified only if its key is baselined AND (for a detail-aware @@ -1800,6 +2354,12 @@ def _report( vacuous = sorted(qid for qid, count in reference_row_counts.items() if count == 0) classified_empty = [qid for qid in vacuous if qid in legitimately_empty] unclassified_empty = [qid for qid in vacuous if qid not in legitimately_empty] + all_null = [qid for qid in vacuous if qid in all_null_references] + stale_empty = sorted( + (qid for qid in legitimately_empty if reference_row_counts.get(qid, 0) > 0), key=lambda qid: str(qid) + ) + if not enforce_vacuity: + classified_empty, unclassified_empty, stale_empty = [], [], [] # Each vacuous query compares one trivially-matching cell per gated backend it # implements; exclude those from the discriminating count so coverage is @@ -1821,8 +2381,17 @@ def _report( print( f" compared {discriminating} of {total} query-backend cells " f"({total - executed} not implemented by the DataFrame surface, " - f"{vacuous_executed} vacuous empty-vs-empty) - {len(divergences)} divergent\n" + f"{vacuous_executed} vacuous empty-vs-empty) - {len(divergences)} divergent" ) + lines = [] + if vacuous: + lines.append(f" vacuous queries: {len(vacuous) - len(all_null)} zero-row, {len(all_null)} single all-NULL row") + if not enforce_vacuity: + lines.append( + " vacuity is classified for the default parameter draw only; this draw lists it without failing: " + f"{vacuous}" + ) + _emit_report_lines([*lines, ""]) by_class: dict[str, list[SurfaceDivergence]] = {} for divergence in sorted(divergences, key=lambda d: d.key): @@ -1837,7 +2406,8 @@ def _report( if classified_empty: print(" [legitimately-empty - classified, NON-discriminating]") for qid in classified_empty: - print(f" {qid}: {legitimately_empty[qid]}") + label = " [all-NULL row]" if qid in all_null_references else "" + print(f" {qid}{label}: {legitimately_empty[qid]}") print() if missing_backends: @@ -1849,6 +2419,13 @@ def _report( "GATE FAILURE - vacuous empty-vs-empty queries (0 reference rows) not classified " f"legitimately_empty: {unclassified_empty} - make them discriminating or classify them with a rationale" ) + if stale_empty: + _emit_report_lines( + [ + f"GATE FAILURE - legitimately_empty entries whose reference now returns rows: {stale_empty} " + "- remove the stale classification in a reviewed change" + ] + ) if resolved: print( "GATE FAILURE - previously-known divergences now equivalent: " @@ -1859,15 +2436,17 @@ def _report( entry = known[key] assert isinstance(entry, ClassifiedDivergence) # narrowed by review_due's filter above print(f"WAIVER REVIEW DUE - {key}: review_by {entry.review_by} has passed - {entry.reason}") - if not new and not resolved and not missing_backends and not unclassified_empty: + if not new and not resolved and not missing_backends and not unclassified_empty and not stale_empty: suffix = " (modulo classified exceptions)" if (known or classified_empty) else "" print(f"SQL and DataFrame surfaces are equivalent{suffix}.") - return 1 if (new or resolved or missing_backends or unclassified_empty) else 0 + return 1 if (new or resolved or missing_backends or unclassified_empty or stale_empty) else 0 def main(argv: list[str] | None = None) -> int: """Run the cross-surface gate for the chosen benchmark.""" import argparse + from dataclasses import replace + from functools import partial parser = argparse.ArgumentParser(description="Cross-surface SQL<->DataFrame equivalence gate.") parser.add_argument( @@ -1887,8 +2466,38 @@ def main(argv: list[str] | None = None) -> int: "untouched and still fails the command (non-zero exit). Idempotent on a second run." ), ) + parser.add_argument( + "--repeats", + type=int, + default=1, + help=( + "Run the comparison this many times against the same data. A cell whose outcome differs " + "between runs is reported as flaky and fails an enforced gate (a staged gate only reports " + "it). Default 1: output, exit code and runtime are unchanged." + ), + ) + parser.add_argument("--backend", action="append", choices=sorted(_PRODUCTION_ADAPTERS), help="Backend to compare.") + parser.add_argument("--seed", type=int, help="TPC-DS Power Test query seed.") + parser.add_argument("--power-stream", type=int, help="TPC-DS Power stream ID (default: 0).") args = parser.parse_args(argv) - return run_gate(get_gate(args.benchmark), update_baseline=args.update_baseline) + if args.repeats < 1: + parser.error("--repeats must be at least 1") + if args.benchmark != "tpcds" and (args.seed is not None or args.power_stream is not None): + parser.error("--seed and --power-stream apply only to TPC-DS") + if (args.seed is not None and args.seed < 0) or (args.power_stream is not None and args.power_stream < 0): + parser.error("--seed and --power-stream must be nonnegative") + if args.backend is not None and len(set(args.backend)) != len(args.backend): + parser.error("--backend must not repeat a backend") + if args.update_baseline and (args.backend is not None or args.seed is not None or args.power_stream is not None): + parser.error("--update-baseline requires the default draw and backend selection") + gate = get_gate(args.benchmark) + if args.backend is not None: + gate = replace(gate, backends=tuple(args.backend)) + if args.benchmark == "tpcds": + gate = replace(gate, build=partial(build_tpcds_duckdb, seed=args.seed, stream_id=args.power_stream or 0)) + if args.seed is not None or args.power_stream: + gate = replace(gate, vacuity_classified=False) + return run_gate(gate, update_baseline=args.update_baseline, repeats=args.repeats) if __name__ == "__main__": # pragma: no cover - CLI entry point diff --git a/benchbox/core/equivalence/dataframe_surface.py b/benchbox/core/equivalence/dataframe_surface.py index 118e600772..9f07e5c58a 100644 --- a/benchbox/core/equivalence/dataframe_surface.py +++ b/benchbox/core/equivalence/dataframe_surface.py @@ -162,7 +162,7 @@ def materialize_rows(result: Any) -> list[tuple[Any, ...]]: frame, or a Pandas frame - and produces plain row tuples in the frame's column order with values normalized via :func:`_normalize_value`. """ - native = getattr(result, "native", result) + native = result if hasattr(result, "collect") else getattr(result, "native", result) if hasattr(native, "collect"): native = native.collect() if hasattr(native, "rows"): @@ -170,7 +170,11 @@ def materialize_rows(result: Any) -> list[tuple[Any, ...]]: elif hasattr(native, "itertuples"): raw_rows = native.itertuples(index=False, name=None) else: - raise TypeError(f"cannot materialize result of type {type(native).__name__}") + import pyarrow as pa + + if not isinstance(native, pa.Table): + raise TypeError(f"cannot materialize result of type {type(native).__name__}") + raw_rows = zip(*(column.to_pylist() for column in native.columns)) return [tuple(_normalize_value(value) for value in row) for row in raw_rows] diff --git a/benchbox/core/expected_results/loader.py b/benchbox/core/expected_results/loader.py index 6833d18ddb..df09d557ef 100644 --- a/benchbox/core/expected_results/loader.py +++ b/benchbox/core/expected_results/loader.py @@ -8,10 +8,14 @@ Licensed under the MIT License. See LICENSE file in the project root for details. """ +import json import logging +import re +from dataclasses import dataclass from pathlib import Path logger = logging.getLogger(__name__) +_TPCH_VALUE_DIGEST_REFERENCE_PATH = Path(__file__).with_name("reference_digests") / "tpch_value_digests_sf1.json" def parse_tpch_answer_file(answer_file_path: Path) -> int: @@ -132,6 +136,142 @@ def parse_tpcds_answer_file(answer_file_path: Path) -> int: raise RuntimeError(f"Unexpected: no encoding worked for {answer_file_path}") +@dataclass(frozen=True) +class TpcdsAnswerBlock: + columns: tuple[str, ...] + rows: tuple[tuple[str | None, ...], ...] + + +_SEPARATOR_LINE = re.compile(r"[-\s|+]*--[-\s|+]*") + +_NOISE_LINE = re.compile( + r"\s*(\(\d+ rows?\)|\d+ rows? selected\.?|\d+ record\(s\) selected\.?|Warning:.*|SQL>.*|INSERT \d+ \d+|-+ OUTPUT Query \d+)\s*", + re.IGNORECASE, +) +_TAB_STOP = 8 + + +_MAX_COLUMN_DRIFT = 2 +_FIELD = re.compile(r"\S+(?: \S+)*") + + +def _read_answer_text(answer_file_path: Path) -> str: + raw = answer_file_path.read_bytes() + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("latin-1") + + +def _cells_from_pipes(line: str) -> tuple[str | None, ...]: + return tuple((cell.strip() or None) for cell in line.split("|")) + + +def _cells_from_spans(line: str, spans: list[tuple[int, int]], source: str) -> tuple[str | None, ...]: + text = line.expandtabs(_TAB_STOP) + cells: list[str | None] = [] + for index, (start, end) in enumerate(spans): + left = 0 if index == 0 else start + right = spans[index + 1][0] if index + 1 < len(spans) else len(text) + if index + 1 < len(spans) and text[end : spans[index + 1][0]].strip(): + raise ValueError(f"{source}: text falls between columns {index + 1} and {index + 2}: {line.strip()[:80]!r}") + cells.append(text[left:right].strip() or None) + return tuple(cells) + + +def _cells_from_drifting_spans(line: str, spans: list[tuple[int, int]], source: str) -> tuple[str | None, ...]: + text = line.expandtabs(_TAB_STOP) + fields = list(_FIELD.finditer(text)) + if len(fields) != len(spans): + raise ValueError(f"{source}: found {len(fields)} fields for {len(spans)} columns: {line.strip()[:80]!r}") + last = len(spans) - 1 + for index, (field, (start, end)) in enumerate(zip(fields, spans)): + if field.start() < start - _MAX_COLUMN_DRIFT or (index < last and field.end() > end + _MAX_COLUMN_DRIFT): + raise ValueError(f"{source}: field {index + 1} is not in its column: {line.strip()[:80]!r}") + return tuple(field.group() for field in fields) + + +def _header_from_spans(line: str, spans: list[tuple[int, int]], source: str) -> tuple[str | None, ...]: + try: + return _cells_from_spans(line, spans, source) + except ValueError: + words = tuple(line.split()) + if len(words) != len(spans): + raise + return words + + +def parse_tpcds_answer_values(answer_file_path: Path) -> tuple[TpcdsAnswerBlock, ...]: + if not answer_file_path.exists(): + raise FileNotFoundError(f"TPC-DS answer file not found: {answer_file_path}") + source = answer_file_path.name + lines = _read_answer_text(answer_file_path).splitlines() + blocks: list[TpcdsAnswerBlock] = [] + + def is_data(index: int) -> bool: + return bool(lines[index].strip()) and not _NOISE_LINE.fullmatch(lines[index]) + + separators = [i for i, line in enumerate(lines) if _SEPARATOR_LINE.fullmatch(line) and line.strip()] + if not separators: + data = [i for i in range(len(lines)) if is_data(i)] + if not data or "|" not in lines[data[0]]: + raise ValueError(f"{source}: no separator line and no pipe-delimited header") + header = _cells_from_pipes(lines[data[0]]) + starts = [data[0]] + for previous, current in zip(data, data[1:]): + if current > previous + 1 and _cells_from_pipes(lines[current]) == header: + starts.append(current) + for number, (start, end) in enumerate(zip(starts, [*starts[1:], len(lines)]), start=1): + rows = tuple(_cells_from_pipes(lines[i]) for i in range(start + 1, end) if is_data(i)) + try: + _check_widths(source, header, rows) + except ValueError as exc: + raise ValueError(f"{exc} (result set {number} of {len(starts)}, header at line {start + 1})") from exc + blocks.append(TpcdsAnswerBlock(tuple(name or "" for name in header), rows)) + return tuple(blocks) + + for number, separator in enumerate(separators, start=1): + try: + blocks.append(_parse_separated_block(lines, separator, source, is_data)) + except ValueError as exc: + raise ValueError( + f"{exc} (result set {number} of {len(separators)}, separator at line {separator + 1})" + ) from exc + return tuple(blocks) + + +def _parse_separated_block(lines: list[str], separator: int, source: str, is_data) -> TpcdsAnswerBlock: + header_index = next((i for i in range(separator - 1, -1, -1) if is_data(i)), None) + if header_index is None: + raise ValueError(f"{source}: separator at line {separator + 1} has no header") + end = separator + 1 + while end < len(lines) and is_data(end): + end += 1 + if "|" in lines[separator]: + header = _cells_from_pipes(lines[header_index]) + rows = tuple(_cells_from_pipes(lines[i]) for i in range(separator + 1, end)) + else: + spans = [(m.start(), m.end()) for m in re.finditer(r"-+", lines[separator])] + header = _header_from_spans(lines[header_index], spans, source) + try: + rows = tuple(_cells_from_spans(lines[i], spans, source) for i in range(separator + 1, end)) + except ValueError as exact_error: + if not _MAX_COLUMN_DRIFT: + raise + try: + rows = tuple(_cells_from_drifting_spans(lines[i], spans, source) for i in range(separator + 1, end)) + except ValueError: + raise exact_error from None + _check_widths(source, header, rows) + return TpcdsAnswerBlock(tuple(name or "" for name in header), rows) + + +def _check_widths(source: str, header: tuple[str | None, ...], rows: tuple[tuple[str | None, ...], ...]) -> None: + for number, row in enumerate(rows, start=1): + if len(row) != len(header): + raise ValueError(f"{source}: row {number} has {len(row)} cells but the header has {len(header)}") + + def _find_tpch_answers_dir() -> Path: """Locate the TPC-H answer files directory. @@ -221,6 +361,29 @@ def load_tpch_expected_results(scale_factor: float = 1.0) -> dict[str, int]: return expected_results +def load_tpch_value_digest_seed() -> int | None: + """Return the qgen seed the stored TPC-H value digests were generated with. + + ``None`` means the snapshot was generated with qgen ``-d`` default parameters, + so the gate must run without ``--seed``. The key is required, and any value + other than an integer or null is rejected. + """ + payload = json.loads(_TPCH_VALUE_DIGEST_REFERENCE_PATH.read_text(encoding="utf-8")) + if ( + not isinstance(payload, dict) + or payload.get("benchmark") != "tpch" + or type(payload.get("scale_factor")) not in (int, float) + or payload["scale_factor"] != 1.0 + ): + raise ValueError("TPC-H value-digest reference must describe the SF=1 TPC-H snapshot") + if "reference_seed" not in payload: + raise ValueError("TPC-H value-digest reference must record reference_seed (an integer or null)") + seed = payload["reference_seed"] + if seed is not None and type(seed) is not int: + raise ValueError("TPC-H value-digest reference_seed must be an integer or null") + return seed + + def load_tpch_value_digests(scale_factor: float = 1.0) -> dict[str, str]: """Load stored reference VALUE digests for TPC-H queries at a given scale. @@ -239,13 +402,11 @@ def load_tpch_value_digests(scale_factor: float = 1.0) -> dict[str, str]: if scale_factor != 1.0: return {} - digest_file = Path(__file__).with_name("reference_digests") / "tpch_value_digests_sf1.json" + digest_file = _TPCH_VALUE_DIGEST_REFERENCE_PATH if not digest_file.exists(): logger.warning("TPC-H value-digest reference file not found: %s", digest_file) return {} - import json - try: payload = json.loads(digest_file.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: @@ -376,3 +537,27 @@ def load_tpcds_expected_results(scale_factor: float = 1.0) -> dict[str, int]: logger.info(f"Loaded expected results for {len(expected_results)} TPC-DS queries at SF={scale_factor}") return expected_results + + +def load_tpcds_answer_values( + scale_factor: float = 1.0, null_order: str = "first" +) -> dict[str, tuple[TpcdsAnswerBlock, ...]]: + if scale_factor != 1.0: + raise ValueError(f"TPC-DS answer sets are only available for scale factor 1.0. Requested: {scale_factor}.") + if null_order not in ("first", "last"): + raise ValueError(f"null_order must be 'first' or 'last', not {null_order!r}") + + answers_dir = _find_tpcds_answers_dir() + suffix = f"_NULLS_{null_order.upper()}" + answers: dict[str, tuple[TpcdsAnswerBlock, ...]] = {} + for query_num in range(1, 100): + for name in (f"{query_num}.ans", f"{query_num}{suffix}.ans"): + answer_file = answers_dir / name + if not answer_file.exists(): + continue + try: + answers[str(query_num)] = parse_tpcds_answer_values(answer_file) + except ValueError as exc: + raise ValueError(f"Cannot parse TPC-DS answer file {answer_file}: {exc}") from exc + break + return answers diff --git a/benchbox/core/expected_results/reference_digests/tpch_value_digests_sf1.json b/benchbox/core/expected_results/reference_digests/tpch_value_digests_sf1.json index 0cae3319b1..966a9ee0bb 100644 --- a/benchbox/core/expected_results/reference_digests/tpch_value_digests_sf1.json +++ b/benchbox/core/expected_results/reference_digests/tpch_value_digests_sf1.json @@ -1,7 +1,7 @@ { "benchmark": "tpch", "scale_factor": 1.0, - "reference_seed": 17039360, + "reference_seed": null, "digest": { "primitive": "benchbox.core.tpchavoc.validation.calculate_checksum", "algorithm": "md5-of-order-normalized-rows", @@ -9,29 +9,29 @@ "emitter": "benchbox.core.results.result_digest.compute_result_digest" }, "provenance": { - "generated_with_duckdb": "1.3.2", + "generated_with_duckdb": "1.5.5", "generated_with_platform": "duckdb", "phases": "generate,load,power", - "note": "Reference VALUE digests for the bounded correctness-gate TPC-H queries at SF=1 with the pinned reference qgen seed. REGENERATE with `make correctness-gate-digests-regen` (_project/scripts/regenerate_correctness_gate_digests.py), which runs the same gate configuration with BENCHBOX_EMIT_RESULT_DIGEST=1 and writes this file -- do NOT hand-copy. This oracle is a REGRESSION SNAPSHOT vs a DuckDB-pinned baseline (it detects change from the frozen benchbox-on-DuckDB answer), NOT an independent correctness oracle: a conceptual value bug present at freeze time is enshrined, not caught. The digest is a value+TYPE-rendering digest (int renders exactly, float/Decimal at fixed significant figures), so it is DuckDB-pinned; cross-engine reuse is deferred (see _project/analysis/value-digest-cross-engine-independence-decision.md). Digests are tied to the DuckDB build pinned in uv.lock; a DuckDB bump that changes a query's emitted values requires regenerating this file." + "note": "Reference VALUE digests for the bounded correctness-gate TPC-H queries at SF=1 with the qgen -d default substitution parameters. REGENERATE with `make correctness-gate-digests-regen` (_project/scripts/regenerate_correctness_gate_digests.py), which runs the same gate configuration with BENCHBOX_EMIT_RESULT_DIGEST=1 and writes this file -- do NOT hand-copy. This oracle is a REGRESSION SNAPSHOT vs a DuckDB-pinned baseline (it detects change from the frozen benchbox-on-DuckDB answer), NOT an independent correctness oracle: a conceptual value bug present at freeze time is enshrined, not caught. The digest is a value+TYPE-rendering digest (int renders exactly, float/Decimal at fixed significant figures), so it is DuckDB-pinned; cross-engine reuse is deferred (see _project/analysis/value-digest-cross-engine-independence-decision.md). Digests are tied to the DuckDB build pinned in uv.lock; a DuckDB bump that changes a query's emitted values requires regenerating this file." }, "digests": { - "1": "d38718127eb3d68cccfef8c41c94c536", - "2": "9e98f3d582e9c8cf16c106e6d51d1417", - "3": "975269739f453a1474e96916fcbba336", - "4": "5108dddc1d008a283dc2b2a2f47c542f", - "5": "25f857d3c8bfa69119e97c39450a5e2e", - "6": "dd3928aa71a856036e6f483549f4249d", - "7": "efb636348f198e98e74161cfc576d8b1", - "8": "a690a43d7fcbe2e3632e659f1c97039a", - "9": "c26d63720f9f8259496609d00642a5e0", - "10": "b1ed69129a8851b94c876bd40e6e2625", - "12": "71b94cfdba724e44a6501b4856d66bd0", - "13": "dd63022d52a7aace7b367d2b2bddf596", - "14": "ab2c9cac10d0aa0bc2acc4602d8cb75a", - "15": "da98d50d52d5feb91794d7ea6af9d05b", - "17": "cf28a9f9416ca848e991ec00a1124ec0", - "19": "af8e9131586fda7b75d59088f72b71d2", - "21": "2964d9f6de0ed47c8e976975d0c5652c", - "22": "5380803b80ab3272f1c71b6ce5e1e4a2" + "1": "45f5f52875c7077e8e1bdd77dfd51960", + "2": "457effac6a92468fc364a0c0963140e6", + "3": "6524452df7bb8ffebdf62f4553dc8979", + "4": "84c2061f218660ea71dcbee1b308ab1a", + "5": "74d8a2a67f35a4c3348054617589bd9d", + "6": "89eec3fd2eea76cf6ddbfdab00712a1d", + "7": "37dfe27b0cf7c755d7c323f80fde3a2b", + "8": "8028fbb14cbddf14cc7fd2d511c2f753", + "9": "c34dc2f4a2509b1a7214497c610e7b55", + "10": "7a4af9e6b89d38716c5394585652e05b", + "12": "271d70a5ed9963264c015d824439b45d", + "13": "59fe8f063cf903e3d24e5225cd02a627", + "14": "33438928c92b6aa06e97ddeb0190e843", + "15": "4864a2d322f1040440fdb909391cabd4", + "17": "985ec3d086775bc2c2b33d6721358030", + "19": "2312f5353efac8444a3065955374f593", + "21": "4ed785880a105e6ac006bada220e1c3d", + "22": "827f4d90057f4df3b760f61901b4feac" } } diff --git a/benchbox/core/expected_results/registry.py b/benchbox/core/expected_results/registry.py index e949ce2ac2..9682df633d 100644 --- a/benchbox/core/expected_results/registry.py +++ b/benchbox/core/expected_results/registry.py @@ -195,21 +195,6 @@ def get_expected_result_detailed( return expected_result, LoadOutcome.HIT - def _get_benchmark_results(self, benchmark_key: str, scale_factor: float) -> BenchmarkExpectedResults | None: - """Get or load benchmark results for a specific scale factor. - - Thread-safe: Uses lock to protect cache and single-flight pattern to prevent duplicate loads. - - Args: - benchmark_key: Normalized benchmark name (lowercase) - scale_factor: Scale factor - - Returns: - Benchmark expected results, or None if not available - """ - results, _ = self._load_benchmark_results(benchmark_key, scale_factor) - return results - def _load_benchmark_results( self, benchmark_key: str, scale_factor: float ) -> tuple[BenchmarkExpectedResults | None, LoadOutcome]: diff --git a/benchbox/core/expected_results/tpch_results.py b/benchbox/core/expected_results/tpch_results.py index 4c1ac109c6..7812ae5c32 100644 --- a/benchbox/core/expected_results/tpch_results.py +++ b/benchbox/core/expected_results/tpch_results.py @@ -1,149 +1,5 @@ -"""TPC-H expected query results provider. - -This module provides expected row counts for TPC-H queries based on the official -TPC-H answer sets. It supports scale factor 1.0 from parsed answer files. - -VALIDATION MODES -================ - -TPC-H supports multiple validation modes to handle different testing scenarios: - -1. EXACT Mode (Default for SF=1.0) - - Requires exact row count match - - Used for queries whose output cardinality is fixed for SF=1.0 - - Reference seed for SF=1.0: 0o0101000000 (17039360 decimal) - - Example: benchbox run --platform duckdb --benchmark tpch --scale 1 - -2. LOOSE Mode (Q16, non-reference seeds only) - - Allows ±50% tolerance by default - - Used for Q16 because its eight size parameters have a combinatorial - substitution domain that is not tractable to exhaustively bound - - Guards against 0-row failures (always fails if expecting >0 but get 0) - - Applied to Q16 only under a non-reference seed; a reference-seed run keeps - EXACT validation against the answer-file count (see SEED-BASED PARAMETER - GENERATION) - -3. RANGE Mode (Q11/Q18/Q20, non-reference seeds only) - - Validates min/max bounds - - Applied under a non-reference seed to queries whose row count is a - spec-sanctioned function of their substitution parameters -- see - NON-DETERMINISTIC ROW-COUNT QUERIES below. A reference-seed run keeps EXACT - validation against the answer-file count instead. Bounds are the exact - min/max row count over the query's full substitution-parameter domain - (spanning both the literal qgen "-d" defaults used for the official - answer files AND the qgen "-r"/"-s" randomized-substitution range), - derived by exhaustive enumeration against real SF=1.0 data -- not - estimated or padded with a safety margin. - -4. DISABLED Mode - - Skips all validation - - Useful for performance testing without correctness checks - - Example: benchbox run --platform duckdb --benchmark tpch --validation-mode disabled - -SEED-BASED PARAMETER GENERATION -================================ - -TPC-H queries use substitution parameters (:1, :2, etc.) generated by qgen tool. -Different seeds produce different parameter values, which affect query results. - -The official TPC-H answer files for SF=1.0 were generated using: - qgen -r 0101000000 # Octal seed = 17039360 decimal - -When using a different seed, the query parameters will differ from the answer files. -For most TPC-H queries this is harmless -- their row count is a fixed function of -the schema (e.g. Q1 always groups into <=4 rows), not of the substitution -parameters -- so EXACT validation still matches regardless of seed. The exception -is the small set of queries listed under NON-DETERMINISTIC ROW-COUNT QUERIES below, -whose row count genuinely varies with the chosen parameters. Those queries carry -EXACT as their canonical validation_mode (their reference-seed answer-file count is -known and must still be matched exactly on a reference/qgen-default run) and also -carry the RANGE bounds / LOOSE tolerance below. QueryValidator relaxes them from -EXACT to RANGE/LOOSE at run time ONLY when the TPC-H power/throughput driver -signals a non-reference seed via set_reference_seed_context(False); a reference -seed (or an unset context) keeps the exact comparison. This is the tightest-safe -policy: full regression detection at the reference seed, and a bound (not an -unconditional skip) under legitimate parameter variance. - -NON-DETERMINISTIC ROW-COUNT QUERIES (RANGE / LOOSE mode assignment) -===================================================================== - -Per the TPC-H specification, queries 11, 16, 18, and 20 contain a substitution -parameter that directly gates which/how many rows qualify (an inequality -threshold, a fraction-of-total cutoff, or a LIKE-prefix/date-range filter), so -their row count is not schema-fixed the way most other queries' are. The bounds -below are the EXACT min/max row count over each query's full substitution -domain (spec ranges from _sources/tpc-h/dbgen/dists.dss and dss.h/varsub.c), -verified by exhaustive enumeration against BenchBox's canonical SF=1.0 DuckDB -dataset (which reproduces the official answer-file counts exactly, confirming -the dataset itself is the standard deterministic dbgen SF=1.0 generation): - -* Q11 (Important Stock Identification) -> RANGE [514, 1469] - ":1" is a nation name drawn from the 25-entry "nations" distribution - (dists.dss); ":2" (the value-share fraction) is the fixed constant - Q11_FRACTION/SF = 0.0001/SF for every seed (dss.h), so nation choice is the - only source of variance. Enumerated all 25 nations against real SF=1.0 data: - min 514 (IRAQ), max 1469 (JORDAN). The qgen "-d" default (GERMANY -> 1048, - matching q11.out) is one of these 25 and falls inside the range. - -* Q16 (Parts/Supplier Relationship) -> LOOSE (unchanged default +-50% tolerance) - ":1"/":2" exclude one of 25 brands and one of ~150 type-prefixes - (dists.dss); ":3".. ":10" are 8 distinct sizes drawn from a permutation of - 1-50 (C(50,8) ~ 5.4e8 combinations). This combinatorial domain is not - tractable to exhaustively enumerate, so no RANGE bound is asserted here - (would otherwise violate the "do not invent bounds" rule). Empirically - sampling 910 real substitution draws (7 base seeds x 8 stream_ids x 22 - positions, using the actual get_query()/qgen path) against SF=1.0 data - produced row counts 18,073-18,456 -- only +-1.3% around the reference - 18,314 (q16.out) -- comfortably inside the existing LOOSE +-50% tolerance, - so LOOSE (not RANGE) is the correct, already-adequate mode for this query. - -* Q18 (Large Volume Customer) -> RANGE [9, 57] - ":1" is the sum(l_quantity) HAVING threshold. The qgen "-d" default embeds - the literal spec constant 300 (confirmed via get_query(seed=None)), giving - 57 rows (matches q18.out) -- NOT part of the randomized-substitution range. - Randomized substitution draws :1 uniformly from [312, 315] (varsub.c case - 18); all 4 discrete values were enumerated against real SF=1.0 data: 312 -> - 10, 313/314/315 -> 9. The two regimes are disjoint, so the range spans both: - [9, 57]. - -* Q20 (Potential Part Promotion) -> RANGE [131, 231] - ":1" is a part-name color prefix from the 92-entry "colors" distribution - (dists.dss); ":2" is a year drawn via UnifInt(93,97) i.e. 1993-1997 - (varsub.c case 20); ":3" is a nation from the 25-entry "nations2" - distribution (dists.dss; same 25 nations as Q11's "nations"). The full - 92 x 5 x 25 = 11,500-combination domain was exhaustively enumerated (460 - grouped queries) against real SF=1.0 data: min 131 (lawn/1996/MOROCCO), max - 231 (mint/1994/IRAQ). The qgen "-d" default (forest/1994/CANADA -> 186, - matching q20.out) falls inside the range. - -All other TPC-H queries keep EXACT validation_mode -- their output cardinality -does not depend on substitution parameters (fixed group-by arity, TOP-N caps -that rarely bind at SF=1.0, or single-row aggregates), so a mismatch under any -seed is a genuine correctness signal, not spec-sanctioned variance. - -USAGE EXAMPLES -============== - -# Auto-detect mode (qgen defaults = reference seed, so every query validates EXACT at SF=1.0) -benchbox run --platform datafusion --benchmark tpch --scale 1 - -# Custom seed (Q11/Q18/Q20 use RANGE; Q16 uses LOOSE) -benchbox run --platform datafusion --benchmark tpch --scale 1 --seed 99 - -# Explicit LOOSE mode (±50% tolerance) -benchbox run --platform datafusion --benchmark tpch --scale 1 --validation-mode loose - -# Disable validation (performance testing) -benchbox run --platform datafusion --benchmark tpch --scale 1 --validation-mode disabled - -# Force run-level EXACT mode with a custom seed (the parameter-sensitive -# queries will likely fail, since a non-reference seed shifts their row counts) -benchbox run --platform datafusion --benchmark tpch --scale 1 --seed 99 --validation-mode exact - -Copyright 2026 Joe Harris / BenchBox Project - -Licensed under the MIT License. See LICENSE file in the project root for details. -""" +# Copyright 2026 Joe Harris / BenchBox Project +# Licensed under the MIT License. See LICENSE file in the project root for details. import logging @@ -156,13 +12,6 @@ logger = logging.getLogger(__name__) -# Queries whose result cardinality is sensitive to the specific substitution -# parameters used (not just the scale factor) -- TPC-H's "answer-set boundary" -# queries. They validate EXACT against the answer file at the reference seed and -# relax to the bounds/tolerance below only under a non-reference seed (see the -# module docstring and query_validation.QueryValidator). -# -# Exact SF=1.0 row-count bounds for the parameter domains documented above. TPCH_RANGE_ROW_COUNT_BOUNDS: dict[str, tuple[int, int]] = { "11": (514, 1469), "18": (9, 57), @@ -221,23 +70,12 @@ def get_tpch_expected_results(scale_factor: float = 1.0) -> BenchmarkExpectedRes for query_id, row_count in row_counts.items(): is_scale_independent = scale_independent_queries.get(query_id, False) - # Parameter-sensitive "answer-set boundary" queries (Q11/16/18/20) keep - # EXACT as their canonical validation_mode: under the reference seed - # (qgen defaults) their answer-file cardinality is known exactly, so a - # reference-seed regression must still be caught by an exact comparison - # (e.g. Q11 -> 1048, not merely "somewhere in 514-1469"). The RANGE - # bounds (Q11/18/20) / LOOSE tolerance (Q16) ride along on the same - # result and are applied by QueryValidator ONLY when a caller signals a - # non-reference seed (set_reference_seed_context(False)); under different - # substitution parameters the exact count no longer holds, but the bound - # still guards against 0-row / wildly-wrong results. See the module - # docstring and benchbox.core.validation.query_validation. range_bounds = TPCH_RANGE_ROW_COUNT_BOUNDS.get(query_id) if range_bounds is not None: expected_row_count_min, expected_row_count_max = range_bounds mode_notes = ( f"Parameter-sensitive TPC-H query; EXACT against the answer file at the " - f"reference seed, non-reference seeds accept the SF=1.0 row-count range " + f"qgen -d defaults; randomized parameters accept the SF=1.0 row-count range " f"{expected_row_count_min}-{expected_row_count_max}." ) elif query_id in TPCH_LOOSE_QUERY_IDS: @@ -245,7 +83,7 @@ def get_tpch_expected_results(scale_factor: float = 1.0) -> BenchmarkExpectedRes expected_row_count_max = None mode_notes = ( "Parameter-sensitive TPC-H query; EXACT against the answer file at the " - "reference seed, non-reference seeds use the default ±50% loose row-count tolerance." + "qgen -d defaults; randomized parameters use the default ±50% loose row-count tolerance." ) else: expected_row_count_min = None diff --git a/benchbox/core/flightdata/benchmark.py b/benchbox/core/flightdata/benchmark.py index 1fa128dd38..e96f4ffac9 100644 --- a/benchbox/core/flightdata/benchmark.py +++ b/benchbox/core/flightdata/benchmark.py @@ -69,6 +69,7 @@ def __init__( verbose: int | bool = 0, quiet: bool = False, force_regenerate: bool = False, + allow_synthetic_fallback: bool = False, **kwargs: Any, ) -> None: """Initialize Flight Data benchmark. @@ -81,6 +82,7 @@ def __init__( verbose: Verbosity level quiet: Suppress output force_regenerate: Force data regeneration + allow_synthetic_fallback: Permit synthetic months after BTS download errors at SF >= 0.1 **kwargs: Additional options """ # Resolve through the shared helper so BENCHBOX_OUTPUT_DIR is honored at @@ -116,6 +118,7 @@ def __init__( verbose=verbose, quiet=quiet, force_redownload=force_regenerate, + allow_synthetic_fallback=allow_synthetic_fallback, **compression_kwargs, ) @@ -168,6 +171,10 @@ def ensure_auxiliary_data_files(self) -> None: # marker; the runner reuses such caches without regenerating. self.downloader.backfill_csv_dialect_metadata() + def manifest_matches_datagen_identity(self, manifest: dict[str, Any]) -> bool: + """Reject reused data whose source months or fallback policy are unproven.""" + return self.downloader.manifest_matches_source_identity(manifest) + @staticmethod def _flatten_table_paths(tables: dict[str, Path | list[Path]]) -> list[Path]: paths: list[Path] = [] @@ -429,6 +436,7 @@ def _load_table_data(self, connection: DatabaseConnection, table_name: str, data from benchbox.core.hooks.benchmark_hooks import ( # noqa: E402 BenchmarkHookRegistry, BenchmarkOptionSpec, + parse_bool, parse_int, ) @@ -452,5 +460,12 @@ def _load_table_data(self, connection: DatabaseConnection, table_name: str, data help="Force data regeneration", aliases=("force-regenerate",), ), + BenchmarkOptionSpec( + name="allow_synthetic_fallback", + parser=parse_bool, + default=False, + help="Allow synthetic months when a BTS download fails at SF >= 0.1", + aliases=("allow-synthetic-fallback",), + ), benchmark_class=FlightDataBenchmark, ) diff --git a/benchbox/core/flightdata/downloader.py b/benchbox/core/flightdata/downloader.py index 83d35cafc3..5529a06a2c 100644 --- a/benchbox/core/flightdata/downloader.py +++ b/benchbox/core/flightdata/downloader.py @@ -74,6 +74,29 @@ def _load_downloader_specs() -> dict[str, Any]: LAST_AVAILABLE_YEAR = int(_DATA_COVERAGE["last_available_year"]) APPROXIMATE_MONTHLY_FLIGHTS = int(_DATA_COVERAGE["approximate_monthly_flights"]) MONTHS_PER_SCALE_FACTOR = int(_DATA_COVERAGE["months_per_scale_factor"]) + + +def _unavailable_months() -> set[tuple[int, int]]: + """Expand ``unavailable_months`` ranges from the downloader spec. + + Each entry is ``[start_year, start_month, end_year, end_month]`` covering + BTS PREZIP archives the provider no longer serves (verified by HTTP + probing, not assumed). Returns an empty set when the spec lists none. + """ + missing: set[tuple[int, int]] = set() + for entry in _DATA_COVERAGE.get("unavailable_months") or []: + start_year, start_month, end_year, end_month = (int(v) for v in entry) + year, month = start_year, start_month + while (year, month) <= (end_year, end_month): + missing.add((year, month)) + month += 1 + if month == 13: + month = 1 + year += 1 + return missing + + +UNAVAILABLE_MONTHS = _unavailable_months() FLIGHTS_SHARD_ROW_TARGET = int(_FLIGHT_SHARDS["row_target"]) FLIGHTS_SHARD_DIRNAME = _FLIGHT_SHARDS["dirname"] FLIGHTS_SHARD_PREFIX = _FLIGHT_SHARDS["prefix"] @@ -138,13 +161,30 @@ def _months_sequence( result = [] year, month = end_year, end_month for _ in range(num_months): - result.append((year, month)) + if (year, month) in UNAVAILABLE_MONTHS: + logger.warning( + "FlightData: BTS archive for %d-%02d is unavailable; substituting an older available month.", + year, + month, + ) + else: + result.append((year, month)) month -= 1 if month == 0: month = 12 year -= 1 if year < FIRST_AVAILABLE_YEAR: break + # Backfill past skipped months so the window keeps its month count when + # older archives exist; if the corpus edge is reached first, the window + # is shorter and _scale_to_months already logged the ceiling. + while len(result) < num_months and year >= FIRST_AVAILABLE_YEAR: + if (year, month) not in UNAVAILABLE_MONTHS: + result.append((year, month)) + month -= 1 + if month == 0: + month = 12 + year -= 1 return result @@ -163,6 +203,7 @@ def __init__( verbose: int | bool = 0, quiet: bool = False, force_redownload: bool = False, + allow_synthetic_fallback: bool = False, **kwargs: Any, ) -> None: """Initialize downloader. @@ -174,6 +215,7 @@ def __init__( verbose: Verbosity level quiet: Suppress all output force_redownload: Re-download even if files exist + allow_synthetic_fallback: Permit synthetic months after download errors at SF >= 0.1 """ super().__init__(**kwargs) @@ -181,12 +223,25 @@ def __init__( self.output_dir = Path(output_dir) self.seed = seed if seed is not None else 42 self.force_redownload = force_redownload + self.allow_synthetic_fallback = allow_synthetic_fallback self.logger = logger verbosity_settings = compute_verbosity(verbose, quiet) self.apply_verbosity(verbosity_settings) self._rng = random.Random(self.seed) self._num_months = _scale_to_months(scale_factor) self._months = _months_sequence(self._num_months) + if len(self._months) < self._num_months: + # Gaps in the provider archive (see unavailable_months in the + # downloader spec) mean fewer months are downloadable than the + # scale factor requests. Report the effective count so manifests + # and logs describe the corpus that was actually generated. + logger.warning( + "FlightData: only %d of %d requested months are available; " + "corpus is capped at the downloadable window.", + len(self._months), + self._num_months, + ) + self._num_months = len(self._months) self._stats: dict[str, Any] = { "source": "bts-transtats", "scale_factor": scale_factor, @@ -194,6 +249,8 @@ def __init__( "months": list(self._months), "months_downloaded": 0, "months_synthetic": 0, + "downloaded_months": [], + "synthetic_months": [], "total_flights": 0, } self._table_row_counts: dict[str, int] = {} @@ -222,6 +279,10 @@ def source_provenance(self) -> dict[str, Any]: ) return { "source": source, + "months_downloaded": downloaded_count, + "months_synthetic": synthetic_count, + "downloaded_months": list(self._stats["downloaded_months"]), + "synthetic_months": list(self._stats["synthetic_months"]), "expected_sha256": expected, "observed_sha256": observed, "promotion_eligible": eligible, @@ -251,7 +312,15 @@ def download(self) -> dict[str, Path | list[Path]]: ) self.force_redownload = True else: - self._restore_persisted_provenance() + try: + persisted_manifest = load_manifest(self.output_dir / MANIFEST_FILENAME) + except (OSError, ValueError): + persisted_manifest = {} + if self.manifest_matches_source_identity(persisted_manifest): + self._restore_persisted_provenance() + else: + logger.warning("FlightData cache lacks complete month provenance; regenerating") + self.force_redownload = True flights_path = self.output_dir / self.get_compressed_filename("flights.csv") airlines_path = self.output_dir / self.get_compressed_filename("airlines.csv") @@ -271,7 +340,7 @@ def download(self) -> dict[str, Path | list[Path]]: try: flight_files = self._ensure_flights_data(flights_path) - except ChecksumMismatchError: + except (ChecksumMismatchError, RuntimeError): self.force_redownload = True self._remove_flights_outputs(flights_path) with contextlib.suppress(OSError): @@ -764,17 +833,27 @@ def _process_month(self, writer: csv.writer, year: int, month: int, start_id: in # network calls in CI/testing. Users wanting real data should use SF >= 0.1. if self.scale_factor < 0.1: self._stats["months_synthetic"] += 1 + self._stats["synthetic_months"].append(f"{year}-{month:02d}") return self._generate_synthetic_month(writer, year, month, start_id) url = BTS_BASE_URL.format(year=year, month=month) try: rows = self._download_bts_month(writer, url, year, month, start_id) self._stats["months_downloaded"] += 1 + self._stats["downloaded_months"].append(f"{year}-{month:02d}") return rows - except (urllib.error.URLError, urllib.error.HTTPError, OSError, zipfile.BadZipFile) as e: + except (urllib.error.URLError, urllib.error.HTTPError, OSError, zipfile.BadZipFile, ValueError) as e: + # ValueError covers archive/content parsing failures: a valid ZIP + # with no CSV, and UnicodeDecodeError (a ValueError subclass) from + # undecodable rows. Without this, those errors bypass the fallback + # and the top-level cleanup, leaving partial output beside the old + # manifest, which a later run could mislabel as complete. + if not self.allow_synthetic_fallback: + raise RuntimeError(f"BTS download failed for {year}-{month:02d}; real data is required") from e logger.warning(f"BTS download failed for {year}-{month:02d}: {e}. Using synthetic data.") rows = self._generate_synthetic_month(writer, year, month, start_id) self._stats["months_synthetic"] += 1 + self._stats["synthetic_months"].append(f"{year}-{month:02d}") return rows def _download_bts_month(self, writer: csv.writer, url: str, year: int, month: int, start_id: int) -> int: @@ -1116,6 +1195,7 @@ def source_contract(self) -> dict[str, Any]: "source": "bts-transtats", "base_url": BTS_BASE_URL, "csv_encoding": BTS_CSV_ENCODING, + "allow_synthetic_fallback": self.allow_synthetic_fallback, "months": list(self._months), "urls": [BTS_BASE_URL.format(year=year, month=month) for year, month in self._months], "expected_sha256": { @@ -1134,6 +1214,30 @@ def source_contract_id(self) -> str: canonical = json.dumps(self.source_contract(), sort_keys=True, default=str) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + def manifest_matches_source_identity(self, manifest: dict[str, Any]) -> bool: + """Require a complete month partition under the active source policy.""" + if manifest.get("source_contract_id") != self.source_contract_id(): + return False + provenance = manifest.get("source_provenance") + if not isinstance(provenance, dict): + return False + expected = {f"{year}-{month:02d}" for year, month in self._months} + downloaded = provenance.get("downloaded_months") + synthetic = provenance.get("synthetic_months") + if not isinstance(downloaded, list) or not isinstance(synthetic, list): + return False + if not all(isinstance(month, str) for month in downloaded + synthetic): + return False + if len(downloaded) + len(synthetic) != len(expected) or set(downloaded) & set(synthetic): + return False + if set(downloaded + synthetic) != expected: + return False + if provenance.get("months_downloaded") != len(downloaded): + return False + if provenance.get("months_synthetic") != len(synthetic): + return False + return self.scale_factor < 0.1 or self.allow_synthetic_fallback or not synthetic + def _persisted_source_contract_id(self) -> str | None: """Return the contract id recorded in the existing manifest, if any.""" manifest_path = Path(self.output_dir) / MANIFEST_FILENAME @@ -1155,11 +1259,14 @@ def _restore_persisted_provenance(self) -> None: self._content_hashes = {str(url): str(digest) for url, digest in hashes.items()} provenance = manifest.get("source_provenance") if isinstance(provenance, dict): - source = provenance.get("source") - if source in {"synthetic", "mixed"}: - self._stats["months_synthetic"] = self._num_months - if source in {"remote", "mixed"}: - self._stats["months_downloaded"] = len(self._content_hashes) + for count_key, list_key in ( + ("months_synthetic", "synthetic_months"), + ("months_downloaded", "downloaded_months"), + ): + months = provenance.get(list_key) + if isinstance(months, list) and all(isinstance(value, str) for value in months): + self._stats[list_key] = months + self._stats[count_key] = len(months) def get_download_stats(self) -> dict[str, Any]: """Return statistics about the download operation.""" diff --git a/benchbox/core/flightdata/downloader_specs.yaml b/benchbox/core/flightdata/downloader_specs.yaml index 66e3d41763..331a26ac4c 100644 --- a/benchbox/core/flightdata/downloader_specs.yaml +++ b/benchbox/core/flightdata/downloader_specs.yaml @@ -16,6 +16,13 @@ data_coverage: last_available_year: 2024 approximate_monthly_flights: 600000 months_per_scale_factor: 41 + # BTS PREZIP archives verified 2026-09-25: Jan 1987 - Sep 1987 and + # Jan 1990 - Dec 1999 return HTTP 404 for every probed month, while + # 1987-10..1989-12 and 2000-01 onward return 200. Windows must skip + # these gaps instead of failing. + unavailable_months: + - [1987, 1, 1987, 9] + - [1990, 1, 1999, 12] flight_shards: row_target: 1000000 dirname: flights diff --git a/benchbox/core/joinorder/queries.py b/benchbox/core/joinorder/queries.py index 8cebdb2730..a23d8e8ca0 100644 --- a/benchbox/core/joinorder/queries.py +++ b/benchbox/core/joinorder/queries.py @@ -136,7 +136,11 @@ def _normalize_portable_aliases(query_id: str, query: str) -> str: """Avoid aliases that are reserved words on supported local engines.""" if query_id not in _DUCKDB_RESERVED_ALIAS_QUERY_IDS: return query + # Table alias with or without AS (`aka_title AS at` and the legal + # `aka_title at`): normalize the declaration first so bare qualifiers + # below cannot orphan the alias. query = re.sub(r"\bAS\s+at\b", "AS at1", query) + query = re.sub(r"(?i)(\bFROM\s+aka_title|\bJOIN\s+aka_title)\s+at\b(?!\.)", r"\1 at1", query) return re.sub(r"\bat\.", "at1.", query) @@ -170,7 +174,7 @@ def _load_queries_from_files(self) -> dict[str, str]: try: content = query_file.read_text(encoding="utf-8").strip() if content: - queries[query_id] = content + queries[query_id] = _normalize_portable_aliases(query_id, content) except Exception as e: emit(f"Warning: Could not load query {query_id}: {e}") diff --git a/benchbox/core/joinorder_replicated/__init__.py b/benchbox/core/joinorder_replicated/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/benchbox/core/joinorder_replicated/replicator.py b/benchbox/core/joinorder_replicated/replicator.py new file mode 100644 index 0000000000..d41a13b3d9 --- /dev/null +++ b/benchbox/core/joinorder_replicated/replicator.py @@ -0,0 +1,476 @@ +"""Replicated-IMDB scale-up prototype (deferred direction, measurement baseline). + +Offset-replicates the canonical IMDb-2013 tables by an integer scale factor: +replica ``r`` (1-based) copies every replicated-table row with primary and +foreign keys shifted by ``r * key_space`` where ``key_space`` exceeds the +canonical max key per table. Predicate (non-key) values are preserved +verbatim, so per-predicate selectivity is scale-invariant by construction — +this is a measurement baseline for scale-stress infrastructure, not a model +of a larger IMDb. + +Foreign keys shift by the TARGET table's key space, not the referencing +table's. A ``movie_keyword.keyword_id`` value lives in ``keyword``'s key +space, so replica ``r`` adds ``r * key_space["keyword"]``; using the +referencing table's space would break the join against ``keyword.id``. +``REPLICATED_FK_TARGETS`` models every replicated FK as +``(referencing_table, column, target_table)`` and ``shifted_fk`` resolves +the offset from the target entry of ``key_space_per_table``. + +Small lookup tables (``LOOKUP_TABLES``) stay single-copy: every replica +references their unchanged IDs, per the scale-stress framework's scale +semantics (``_project/decisions/joinorder-scale-stress-decision-2026-06-30.md``, +"Scale semantics"). Lookup foreign keys (``LOOKUP_FK_COLUMNS``) are never +shifted. + +Key range and INT64 promotion: canonical key columns are INTEGER (INT32, +max 2,147,483,647). A shifted key is ``max_id + (scale_factor - 1) * +key_space[target]`` at most, so the largest scale factor that fits INT32 is +``min over key columns of ((INT32_MAX - max_id) // key_space[target] + 1)`` +(see ``max_supported_scale_factor``). With canonical maxima near 36M and key +spaces near 40M the ceiling is on the order of 50x. Any scale factor above +the ceiling requires promoting every replicated key column to BIGINT (INT64) +BEFORE data generation: ``ALTER ... TYPE BIGINT`` or the Parquet/Arrow +equivalent, then generate replicas. ``requires_bigint_promotion`` and +``promoted_key_type`` encode that decision; the prototype itself generates +no data (see deferral below), so promotion is specified here, not executed. + +Key spaces are derived from source Parquet metadata, never guessed: +``derive_key_spaces`` reads per-column maxima from the canonical Parquet +files and sets each table's space above every key value that lands in it; +``validate_key_spaces`` fails loudly (``ValueError``) on any collision risk. +``build_replicated_manifest`` wires derivation and validation into manifest +construction. + +Deferral: the Track-2 scaling direction +(``_project/decisions/joinorder-track2-scaling-direction-2026-07-04.md``) +defers ``replicated_imdb`` until a future decision re-approves an upward +axis. This prototype is measurement-only scaffolding (offset math, identity, +oracle scaling invariants); it performs no replication, ships no derived +archive, and stays unwired from benchmark registration until that gate is +satisfied. ``REQUIRES_REAPPROVAL`` is the machine-checkable form of that +gate: it must remain ``True`` in any commit that does not cite the +re-approving decision note. + +Derived identity is explicit: ``replicated_imdb``, never ``canonical_imdb``. +Module separation from ``benchbox.core.joinorder`` enforces the +canonical/derived boundary at the file-system level. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DERIVED_IDENTITY = "replicated_imdb" +SOURCE_IDENTITY = "canonical_imdb" + +# Deferral gate: True until a future decision note re-approves an upward +# scaling axis (see module docstring). Generators and benchmark registration +# must refuse to consume this prototype while the gate is set. +REQUIRES_REAPPROVAL = True + +# Signed 32-bit ceiling for the canonical INTEGER key columns. Shifted keys +# above this value require BIGINT promotion before data generation. +INT32_MAX = 2_147_483_647 + +# Lookup tables stay single-copy: their domain rows are referenced by FK +# from every replica and are never duplicated or key-shifted. Canonical row +# counts are 4-113 (see data_manifest.toml); the 15 fact/entity tables hold +# 30K-36M rows each and are the N x replication targets. +LOOKUP_TABLES: tuple[str, ...] = ( + "company_type", + "comp_cast_type", + "info_type", + "kind_type", + "link_type", + "role_type", +) + +# Foreign keys that reference lookup-table PKs. Values in these columns are +# canonical lookup IDs shared by every replica and must never be shifted. +# Each entry names the referencing table, the FK column, and the lookup +# table it points at (verified against benchbox/core/joinorder/schema_specs.yaml). +LOOKUP_FK_COLUMNS: tuple[tuple[str, str, str], ...] = ( + ("aka_title", "kind_id", "kind_type"), + ("cast_info", "role_id", "role_type"), + ("complete_cast", "subject_id", "comp_cast_type"), + ("complete_cast", "status_id", "comp_cast_type"), + ("movie_companies", "company_type_id", "company_type"), + ("movie_info", "info_type_id", "info_type"), + ("movie_info_idx", "info_type_id", "info_type"), + ("movie_link", "link_type_id", "link_type"), + ("person_info", "info_type_id", "info_type"), + ("title", "kind_id", "kind_type"), +) + +# Integer key columns shifted per replica. (table, column) pairs cover the +# primary keys and the foreign keys that reference replicated tables only. +# Lookup PKs/FKs are excluded by construction; see LOOKUP_TABLES and +# LOOKUP_FK_COLUMNS above. ``keyword`` is a replicated entity table (134K +# canonical rows), so ``movie_keyword.keyword_id`` shifts with its target. +KEY_COLUMNS: tuple[tuple[str, str], ...] = ( + ("aka_name", "id"), + ("aka_name", "person_id"), + ("aka_title", "id"), + ("aka_title", "movie_id"), + ("aka_title", "episode_of_id"), + ("cast_info", "id"), + ("cast_info", "person_id"), + ("cast_info", "movie_id"), + ("cast_info", "person_role_id"), + ("char_name", "id"), + ("company_name", "id"), + ("complete_cast", "id"), + ("complete_cast", "movie_id"), + ("movie_companies", "id"), + ("movie_companies", "movie_id"), + ("movie_companies", "company_id"), + ("movie_info", "id"), + ("movie_info", "movie_id"), + ("movie_info_idx", "id"), + ("movie_info_idx", "movie_id"), + ("movie_keyword", "id"), + ("movie_keyword", "movie_id"), + ("movie_keyword", "keyword_id"), + ("movie_link", "id"), + ("movie_link", "movie_id"), + ("movie_link", "linked_movie_id"), + ("name", "id"), + ("person_info", "id"), + ("person_info", "person_id"), + ("title", "id"), + ("title", "episode_of_id"), + ("keyword", "id"), +) + +# Replicated foreign-key targets. Each entry is +# (referencing_table, column, target_table): the shifted value of +# ``referencing_table.column`` lives in ``target_table``'s key space, so its +# replica offset comes from ``key_space_per_table[target_table]``. Verified +# against benchbox/core/joinorder/schema_specs.yaml; every non-PK entry of +# KEY_COLUMNS appears here exactly once. +REPLICATED_FK_TARGETS: tuple[tuple[str, str, str], ...] = ( + ("aka_name", "person_id", "name"), + ("aka_title", "movie_id", "title"), + ("aka_title", "episode_of_id", "title"), + ("cast_info", "person_id", "name"), + ("cast_info", "movie_id", "title"), + ("cast_info", "person_role_id", "char_name"), + ("complete_cast", "movie_id", "title"), + ("movie_companies", "movie_id", "title"), + ("movie_companies", "company_id", "company_name"), + ("movie_info", "movie_id", "title"), + ("movie_info_idx", "movie_id", "title"), + ("movie_keyword", "movie_id", "title"), + ("movie_keyword", "keyword_id", "keyword"), + ("movie_link", "movie_id", "title"), + ("movie_link", "linked_movie_id", "title"), + ("person_info", "person_id", "name"), + ("title", "episode_of_id", "title"), +) + +_FK_TARGET_INDEX: dict[tuple[str, str], str] = {(t, c): target for t, c, target in REPLICATED_FK_TARGETS} + +# Replicated entity/fact tables: every table not in LOOKUP_TABLES. Each is +# replicated N x with shifted keys; lookup tables stay single-copy. +REPLICATED_TABLES: tuple[str, ...] = ( + "aka_name", + "aka_title", + "cast_info", + "char_name", + "company_name", + "complete_cast", + "keyword", + "movie_companies", + "movie_info", + "movie_info_idx", + "movie_keyword", + "movie_link", + "name", + "person_info", + "title", +) + + +@dataclass +class ReplicatedManifest: + """Provenance for one replicated archive. + + Provenance vocabulary (``dataset_version``, ``data_archive_hash``, + ``source_manifest_hash``) matches the canonical manifest and oracle + payload conventions (``benchbox/core/joinorder/benchmark.py`` + ``get_benchmark_info`` and ``_project/joinorder/reference_cardinalities.json``). + """ + + scale_factor: int + key_space_per_table: dict[str, int] = field(default_factory=dict) + dataset_version: str = "" + data_archive_hash: str = "" + source_manifest_hash: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "derived_identity": DERIVED_IDENTITY, + "source_identity": SOURCE_IDENTITY, + "scale_factor": self.scale_factor, + "key_space_per_table": self.key_space_per_table, + "dataset_version": self.dataset_version, + "data_archive_hash": self.data_archive_hash, + "source_manifest_hash": self.source_manifest_hash, + } + + +def validate_scale_factor(scale_factor: int) -> int: + """Only integer factors >= 1; 1 is the canonical identity replica.""" + if not isinstance(scale_factor, int) or isinstance(scale_factor, bool) or scale_factor < 1: + raise ValueError(f"replicated scale_factor must be an integer >= 1, got {scale_factor!r}") + return scale_factor + + +def replica_offset(replica: int, key_space: int) -> int: + """Key offset for replica ``r`` (0-based canonical replica has offset 0).""" + if replica < 0: + raise ValueError(f"replica must be >= 0, got {replica}") + if key_space <= 0: + raise ValueError(f"key_space must be > 0, got {key_space}") + return replica * key_space + + +def shifted_key(value: int | None, *, replica: int, key_space: int) -> int | None: + """Shift one key value into a replica's key space; NULLs pass through.""" + if value is None: + return None + return int(value) + replica_offset(replica, key_space) + + +def fk_target_table(referencing_table: str, column: str) -> str: + """Return the target table of a replicated foreign-key column. + + Raises: + ValueError: If ``(referencing_table, column)`` is not a replicated + foreign key (primary keys and lookup FKs have no target here). + """ + try: + return _FK_TARGET_INDEX[(referencing_table, column)] + except KeyError: + raise ValueError(f"no replicated FK target for {referencing_table}.{column}") from None + + +def shifted_pk( + value: int | None, + *, + replica: int, + key_space_per_table: dict[str, int], + table: str, +) -> int | None: + """Shift a primary-key value using its own table's key space.""" + if value is None: + return None + try: + key_space = key_space_per_table[table] + except KeyError: + raise ValueError(f"no key_space for table {table!r}") from None + return int(value) + replica_offset(replica, key_space) + + +def shifted_fk( + value: int | None, + *, + replica: int, + key_space_per_table: dict[str, int], + referencing_table: str, + column: str, +) -> int | None: + """Shift a foreign-key value using the TARGET table's key space. + + The shifted FK must land in the same space as the shifted target PK it + references; using the referencing table's space would break the join + whenever per-table spaces differ. + """ + if value is None: + return None + target = fk_target_table(referencing_table, column) + try: + key_space = key_space_per_table[target] + except KeyError: + raise ValueError(f"no key_space for FK target table {target!r}") from None + return int(value) + replica_offset(replica, key_space) + + +def _key_space_owner(table: str, column: str) -> str: + """Return the table whose key space governs ``table.column``.""" + if column == "id": + return table + return fk_target_table(table, column) + + +def parquet_column_max(parquet_path: Path, column: str) -> int: + """Return ``max(column)`` over a source Parquet file. + + Prefers row-group statistics from the Parquet metadata (no full scan); + falls back to a column read when statistics are absent. NULL-only + columns report 0. Raises loudly on missing files or columns so a + collision risk can never pass silently. + """ + import pyarrow.parquet as pq + + parquet_path = Path(parquet_path) + if not parquet_path.exists(): + raise FileNotFoundError(f"source Parquet file not found: {parquet_path}") + parquet_file = pq.ParquetFile(parquet_path) + if column not in parquet_file.schema.names: + raise ValueError(f"column {column!r} not in {parquet_path.name}") + column_index = parquet_file.schema.names.index(column) + maxima: list[int] = [] + for group in range(parquet_file.metadata.num_row_groups): + statistics = parquet_file.metadata.row_group(group).column(column_index).statistics + if statistics is not None and statistics.has_min_max: + maxima.append(int(statistics.max)) + if maxima: + return max(maxima) + values = parquet_file.read(columns=[column]).column(column).to_pylist() + present = [int(v) for v in values if v is not None] + return max(present) if present else 0 + + +def collect_key_maxima(data_dir: Path) -> dict[tuple[str, str], int]: + """Collect ``max(column)`` for every ``KEY_COLUMNS`` entry from Parquet metadata. + + Args: + data_dir: Directory holding one ``.parquet`` file per table. + + Raises: + FileNotFoundError: If any table's Parquet file is missing. + ValueError: If any key column is missing from its file. + """ + data_dir = Path(data_dir) + return {pair: parquet_column_max(data_dir / f"{pair[0]}.parquet", pair[1]) for pair in KEY_COLUMNS} + + +def validate_key_spaces( + key_space_per_table: dict[str, int], + key_maxima: dict[tuple[str, str], int], +) -> dict[str, int]: + """Validate that every key space strictly exceeds the max key landing in it. + + Each PK is governed by its own table's space; each replicated FK is + governed by its TARGET table's space. Raises ``ValueError`` on any + missing space, non-positive space, or collision risk + (``key_space <= max_key``), which would let two replicas share a key. + """ + for (table, column), maximum in key_maxima.items(): + owner = _key_space_owner(table, column) + if owner not in key_space_per_table: + raise ValueError(f"no key_space for table {owner!r} (governs {table}.{column})") + space = key_space_per_table[owner] + if space <= 0: + raise ValueError(f"key_space for {owner!r} must be > 0, got {space}") + if space <= maximum: + raise ValueError( + f"key_space {space} for {owner!r} does not exceed max key {maximum} " + f"(governs {table}.{column}): replicas would collide" + ) + return key_space_per_table + + +def derive_key_spaces(data_dir: Path) -> dict[str, int]: + """Derive a collision-free ``key_space_per_table`` from source Parquet metadata. + + Each table's space is ``max + 1`` over every key value that lands in it: + its own PK maxima plus the maxima of all replicated FKs targeting it. + """ + key_maxima = collect_key_maxima(data_dir) + spaces: dict[str, int] = {} + for table in REPLICATED_TABLES: + landing = [ + maximum + for (owner_table, column), maximum in key_maxima.items() + if _key_space_owner(owner_table, column) == table + ] + spaces[table] = max(landing) + 1 if landing else 1 + return validate_key_spaces(spaces, key_maxima) + + +def build_replicated_manifest( + scale_factor: int, + data_dir: Path, + *, + dataset_version: str = "", + data_archive_hash: str = "", + source_manifest_hash: str = "", +) -> ReplicatedManifest: + """Build a manifest with key spaces derived and validated from source Parquet files.""" + validate_scale_factor(scale_factor) + return ReplicatedManifest( + scale_factor=scale_factor, + key_space_per_table=derive_key_spaces(data_dir), + dataset_version=dataset_version, + data_archive_hash=data_archive_hash, + source_manifest_hash=source_manifest_hash, + ) + + +def max_supported_scale_factor( + key_space_per_table: dict[str, int], + key_maxima: dict[tuple[str, str], int], +) -> int: + """Largest scale factor whose shifted keys still fit signed INT32. + + The largest value landing in a space is ``max_id + (scale - 1) * space``; + the ceiling is the minimum over key columns of + ``(INT32_MAX - max_id) // space + 1``. + """ + validate_key_spaces(key_space_per_table, key_maxima) + return min( + (INT32_MAX - maximum) // key_space_per_table[_key_space_owner(table, column)] + 1 + for (table, column), maximum in key_maxima.items() + ) + + +def requires_bigint_promotion( + scale_factor: int, + key_space_per_table: dict[str, int], + key_maxima: dict[tuple[str, str], int], +) -> bool: + """Whether ``scale_factor`` overflows INT32 and needs BIGINT key columns.""" + return validate_scale_factor(scale_factor) > max_supported_scale_factor(key_space_per_table, key_maxima) + + +def promoted_key_type( + scale_factor: int, + key_space_per_table: dict[str, int], + key_maxima: dict[tuple[str, str], int], +) -> str: + """SQL key-column type required BEFORE generating replicas: ``INTEGER`` or ``BIGINT``.""" + return "BIGINT" if requires_bigint_promotion(scale_factor, key_space_per_table, key_maxima) else "INTEGER" + + +def manifest_hash(manifest: ReplicatedManifest) -> str: + """Content hash of the replication manifest for oracle binding.""" + body = json.dumps(manifest.to_dict(), sort_keys=True) + return hashlib.sha256(body.encode("utf-8")).hexdigest()[:16] + + +def expected_row_count(canonical_rows: int, scale_factor: int) -> int: + """Replication scales a replicated table's row count by exactly the factor.""" + return canonical_rows * validate_scale_factor(scale_factor) + + +def expected_table_row_count(table: str, canonical_rows: int, scale_factor: int) -> int: + """Row count for one table at a scale factor. + + Replicated tables scale N x; lookup tables stay single-copy at their + canonical count so every replica references the same lookup IDs. + + Raises: + ValueError: If ``table`` is neither a replicated nor a lookup table. + """ + validate_scale_factor(scale_factor) + if table in LOOKUP_TABLES: + return canonical_rows + if table in REPLICATED_TABLES: + return canonical_rows * scale_factor + raise ValueError(f"unknown joinorder table {table!r}: not in REPLICATED_TABLES or LOOKUP_TABLES") diff --git a/benchbox/core/joinorder_sampled/__init__.py b/benchbox/core/joinorder_sampled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/benchbox/core/joinorder_sampled/sampler.py b/benchbox/core/joinorder_sampled/sampler.py new file mode 100644 index 0000000000..fc1dc09779 --- /dev/null +++ b/benchbox/core/joinorder_sampled/sampler.py @@ -0,0 +1,382 @@ +"""Sampled-from-real JOB scaling (Track-2, Option B). + +Title-stratified downsampling of the canonical IMDb-2013 archive with +referential-integrity preservation. The sampler keeps a deterministic +fraction of ``title`` rows via seeded-hash stratified sampling (uniform +selection within each ``(kind_id, production_year)`` stratum, ranked by +``sha256(seed, stratum, title_id)``), closes the retained set +transitively over episode parents merged from both ``title.episode_of_id`` +and ``aka_title.episode_of_id``, and then keeps dependent rows: every row +in a title-referencing table whose ``movie_id`` survives, every +``movie_link`` row whose ``movie_id`` and ``linked_movie_id`` both survive, +the person rows referenced by the surviving cast (``name`` carried +verbatim, ``aka_name``/``person_info`` filtered to surviving persons), +plus small dimension tables carried verbatim. + +Derived identity is explicit: output archives are labeled +``joinorder_sampled``, never ``canonical_imdb``. Scale-factor semantics: +``scale_factor`` is the kept title fraction (1.0 = canonical). Only +fractional factors in (0, 1] are accepted; bools are rejected outright. +""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Collection, Container, Iterable, Mapping +from dataclasses import KW_ONLY, dataclass, field +from fractions import Fraction +from typing import Any + +# Tables keyed by a single title id: a row survives when its movie_id is +# retained. movie_link has two title ends and is handled separately: a +# row survives only when both movie_id and linked_movie_id are retained, +# so neither foreign key can dangle. cast_info is the title/person bridge: +# its movie_id side is filtered here; its person_id side feeds +# surviving_person_ids() below. +TITLE_CHILD_TABLES: dict[str, tuple[str, ...]] = { + "aka_title": ("movie_id",), + "cast_info": ("movie_id",), + "movie_companies": ("movie_id",), + "movie_info": ("movie_id",), + "movie_info_idx": ("movie_id",), + "movie_keyword": ("movie_id",), + "complete_cast": ("movie_id",), +} + +# movie_link has two title ends: a row survives only when both movie_id +# and linked_movie_id are retained, so neither foreign key can dangle. +MOVIE_LINK_TABLE = "movie_link" +MOVIE_LINK_ENDPOINTS: tuple[str, str] = ("movie_id", "linked_movie_id") + +# Person-side tables filtered to persons referenced by the surviving cast. +# The base ``name`` table is carried verbatim (see VERBATIM_TABLES), so a +# surviving cast row's person_id never dangles; these child tables close +# over the same surviving person set. +PERSON_CHILD_TABLES: dict[str, tuple[str, ...]] = { + "aka_name": ("person_id",), + "person_info": ("person_id",), +} + +# Small dimension tables carried verbatim (no title/person key). ``name`` +# is verbatim: every person referenced by surviving cast rows keeps a base +# row, while aka_name/person_info are filtered to surviving persons. +VERBATIM_TABLES: tuple[str, ...] = ( + "char_name", + "company_name", + "company_type", + "comp_cast_type", + "info_type", + "keyword", + "kind_type", + "link_type", + "name", + "role_type", +) + +SAMPLING_MODULUS = 1000 + +StratumKey = tuple["int | None", "int | None"] + + +@dataclass +class SampledManifest: + """Provenance for one sampled archive.""" + + scale_factor: float + kept_title_ids: int + total_title_ids: int + seed_note: str = ( + "seeded-hash stratified: uniform within (kind_id, production_year) strata " + "via sha256(seed, stratum, title_id) rank" + ) + per_table_rows: dict[str, int] = field(default_factory=dict) + _: KW_ONLY + seed: int = 0 + source_dataset_version: str = "" + source_manifest_hash: str = "" + source_data_archive_hash: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "derived_identity": "joinorder_sampled", + "source_identity": "canonical_imdb", + "source_dataset_version": self.source_dataset_version, + "source_manifest_hash": self.source_manifest_hash, + "source_data_archive_hash": self.source_data_archive_hash, + "scale_factor": self.scale_factor, + "kept_title_ids": self.kept_title_ids, + "total_title_ids": self.total_title_ids, + "seed": self.seed, + "seed_note": self.seed_note, + "per_table_rows": self.per_table_rows, + } + + +def scale_to_fraction(scale_factor: float | Fraction) -> Fraction: + """Express a (0, 1] scale factor as an exact kept fraction. + + The raw requested value is validated before rounding: non-finite + values and values outside (0, 1] are rejected even when + ``limit_denominator`` would round them back into range. A requested + value below the supported 1/1000 resolution (whose best rational + approximation rounds to zero) is likewise rejected. Bools are + rejected with TypeError: ``True`` would otherwise silently mean 1.0. + """ + if isinstance(scale_factor, bool): + raise TypeError(f"sampled scale_factor must be a number in (0, 1], got bool {scale_factor!r}") + if isinstance(scale_factor, Fraction): + requested = scale_factor + if requested <= 0 or requested > 1: + raise ValueError(f"sampled scale_factor must be in (0, 1], got {scale_factor}") + else: + requested_float = float(scale_factor) + if not math.isfinite(requested_float) or requested_float <= 0 or requested_float > 1: + raise ValueError(f"sampled scale_factor must be in (0, 1], got {scale_factor}") + requested = Fraction(requested_float) + if requested < Fraction(1, SAMPLING_MODULUS): + raise ValueError(f"sampled scale_factor {scale_factor} is below the supported 1/{SAMPLING_MODULUS} resolution") + fraction = requested.limit_denominator(SAMPLING_MODULUS) + if fraction <= 0 or fraction > 1: + raise ValueError(f"sampled scale_factor must be in (0, 1], got {scale_factor}") + return fraction + + +def stratum_key(kind_id: int | None, production_year: int | None) -> StratumKey: + """Normalize a title's stratification key (None marks unknown).""" + return (kind_id, production_year) + + +def title_hash_rank(title_id: int, stratum: StratumKey, seed: int) -> float: + """Uniform rank in [0, 1) for one title within its stratum. + + Ranks are decorrelated across strata and seeds: titles sharing a + numeric id range (IMDb ids cluster by kind/era) do not share keep + decisions, unlike the old ``id % denominator`` predicate. + """ + digest = hashlib.sha256(f"{seed}\x00{stratum[0]}\x00{stratum[1]}\x00{int(title_id)}".encode()).digest() + return int.from_bytes(digest, "big") / 2**256 + + +def keep_title_id( + title_id: int, + fraction: Fraction, + *, + seed: int = 0, + kind_id: int | None = None, + production_year: int | None = None, +) -> bool: + """Seeded-hash keep predicate: keep when the title's stratum rank is below the kept fraction. + + Titles are partitioned by ``(kind_id, production_year)`` strata and + selected uniformly within each stratum via ``title_hash_rank``. The + ``seed`` decorrelates samples across runs; the same seed always keeps + the same titles. + """ + rank = title_hash_rank(title_id, stratum_key(kind_id, production_year), seed) + return rank < float(fraction) + + +def sample_title_ids( + records: Iterable[Mapping[str, int | None]], + fraction: Fraction, + *, + seed: int = 0, +) -> set[int]: + """Select an exact-quota title set with per-stratum proportionality. + + ``records`` maps each carry ``id`` plus optional ``kind_id`` / + ``production_year`` stratum keys. The total kept count is + ``round(fraction * N)``, apportioned across strata by largest + remainder; within a stratum the lowest-ranked titles win, ordered by + ``(title_hash_rank, id)`` for determinism. + """ + rows = list(records) + total = len(rows) + if total == 0: + return set() + target = min(max(int(round(float(fraction) * total)), 0), total) + strata: dict[StratumKey, list[Mapping[str, int | None]]] = {} + for row in rows: + key = stratum_key(row.get("kind_id"), row.get("production_year")) + strata.setdefault(key, []).append(row) + quotas = {key: float(fraction) * len(members) for key, members in strata.items()} + alloc = {key: math.floor(quota) for key, quota in quotas.items()} + remainder = target - sum(alloc.values()) + by_fraction = sorted(quotas, key=lambda key: (quotas[key] - alloc[key], repr(key)), reverse=True) + for key in by_fraction[: max(remainder, 0)]: + alloc[key] += 1 + kept: set[int] = set() + for key, members in strata.items(): + ordered = sorted(members, key=lambda row: (title_hash_rank(int(row["id"]), key, seed), int(row["id"]))) + kept.update(int(row["id"]) for row in ordered[: alloc[key]]) + return kept + + +def merge_episode_parents( + title_parents: Mapping[int, int | None], + aka_title_parents: Mapping[int, int | None], +) -> dict[int, int | None]: + """Merge ``episode_of_id`` maps from ``title`` and ``aka_title``. + + Both tables carry the secondary episode reference keyed by title id; + a surviving episode's parent must be retained whichever table records + it. The ``title`` value wins on conflict; a null on one side falls + back to the other side. + """ + merged: dict[int, int | None] = {} + for title_id in set(title_parents) | set(aka_title_parents): + parent = title_parents.get(title_id) + aka_parent = aka_title_parents.get(title_id) + merged[title_id] = parent if parent is not None else aka_parent + return merged + + +def close_title_set_over_episode_parents( + kept_title_ids: Collection[int], + episode_parents: Mapping[int, int | None], +) -> set[int]: + """Expand a kept title set transitively over ``episode_of_id`` parents. + + A retained episode references its parent title in ``episode_of_id``, + so the parent must be retained too, along with the parent's own + parent for multi-level nesting. Walks parent chains until every kept + title's ancestors are retained; cycles terminate via the visited set. + Pass a map merged with :func:`merge_episode_parents` so parents + recorded only in ``aka_title`` are closed over as well. + """ + closed = set(kept_title_ids) + stack = list(kept_title_ids) + while stack: + parent_id = episode_parents.get(stack.pop()) + if parent_id is not None and parent_id not in closed: + closed.add(parent_id) + stack.append(parent_id) + return closed + + +def _cluster_roots( + title_ids: Collection[int], + episode_parents: Mapping[int, int | None], +) -> dict[int, set[int]]: + """Group titles into episode clusters keyed by ultimate ancestor. + + Each cluster holds a root title plus every episode that reaches it + through transitive ``episode_of_id`` links. Parents referenced but + absent from ``title_ids`` join their cluster so closure never dangles. + """ + parent_of = {int(child): int(parent) for child, parent in episode_parents.items() if parent is not None} + + def root(title_id: int) -> int: + seen: set[int] = set() + while title_id in parent_of and title_id not in seen: + seen.add(title_id) + title_id = parent_of[title_id] + return title_id + + clusters: dict[int, set[int]] = {} + for title_id in list(title_ids) + [parent for parent in parent_of.values() if parent not in set(title_ids)]: + clusters.setdefault(root(int(title_id)), set()).add(int(title_id)) + return clusters + + +def sample_title_ids_with_episode_closure( + records: Iterable[Mapping[str, int | None]], + episode_parents: Mapping[int, int | None], + fraction: Fraction, + *, + seed: int = 0, +) -> set[int]: + """Select whole episode clusters against the scale quota. + + Parent expansion is charged to the quota instead of added on top: + titles are grouped into series clusters (root plus transitive + episodes), the ``round(fraction * N)`` budget is apportioned across + root strata by largest remainder, and clusters fill each stratum + budget in hash-rank order. The retained/total ratio therefore tracks + ``scale_factor`` up to cluster granularity instead of overshooting it. + """ + rows = list(records) + if not rows: + return set() + total = len(rows) + target = min(max(int(round(float(fraction) * total)), 0), total) + meta = {int(row["id"]): stratum_key(row.get("kind_id"), row.get("production_year")) for row in rows} + clusters = _cluster_roots([int(row["id"]) for row in rows], episode_parents) + by_stratum: dict[StratumKey, list[tuple[float, int]]] = {} + for cluster_root, members in clusters.items(): + key = meta.get(cluster_root, (None, None)) + rank = title_hash_rank(cluster_root, key, seed) + by_stratum.setdefault(key, []).append((rank, cluster_root)) + weights = {key: sum(len(clusters[root]) for _, root in ranked) for key, ranked in by_stratum.items()} + quotas = {key: float(fraction) * weight for key, weight in weights.items()} + budgets = {key: math.floor(quota) for key, quota in quotas.items()} + remainder = target - sum(budgets.values()) + by_fraction = sorted(quotas, key=lambda key: (quotas[key] - budgets[key], repr(key)), reverse=True) + for key in by_fraction[: max(remainder, 0)]: + budgets[key] += 1 + kept: set[int] = set() + for key, ranked in by_stratum.items(): + budget = budgets[key] + stratum_kept = 0 + for _, cluster_root in sorted(ranked): + members = clusters[cluster_root] + if stratum_kept + len(members) <= budget: + kept.update(members) + stratum_kept += len(members) + if stratum_kept == 0 and budget > 0 and ranked: + smallest = min(ranked, key=lambda item: (len(clusters[item[1]]), item[0]))[1] + kept.update(clusters[smallest]) + return kept + + +def keep_title_child_row( + row: Mapping[str, int | None], + kept_title_ids: Container[int], + key_columns: Iterable[str], +) -> bool: + """Keep a single-key dependent row when its title reference survives.""" + return all(row.get(column) is not None and row[column] in kept_title_ids for column in key_columns) + + +def keep_movie_link_row( + row: Mapping[str, int | None], + kept_title_ids: Container[int], +) -> bool: + """Keep a movie_link row only when both title endpoints survive.""" + return all(row.get(column) is not None and row[column] in kept_title_ids for column in MOVIE_LINK_ENDPOINTS) + + +def surviving_person_ids( + cast_rows: Iterable[Mapping[str, int | None]], + kept_title_ids: Container[int], +) -> set[int]: + """Collect persons referenced by surviving cast rows. + + ``cast_rows`` are the retained title-side ``cast_info`` rows (already + filtered to surviving ``movie_id`` values); every non-null + ``person_id`` they reference survives into the sampled person + subgraph. + """ + persons: set[int] = set() + for row in cast_rows: + person_id = row.get("person_id") + if row.get("movie_id") in kept_title_ids and person_id is not None: + persons.add(person_id) + return persons + + +def keep_person_child_row( + row: Mapping[str, int | None], + surviving: Container[int], + key_columns: Iterable[str] = ("person_id",), +) -> bool: + """Keep a person-side row (aka_name/person_info) when its person survives.""" + return all(row.get(column) is not None and row[column] in surviving for column in key_columns) + + +def manifest_hash(manifest: SampledManifest) -> str: + """Content hash of the sampling manifest for oracle binding.""" + body = json.dumps(manifest.to_dict(), sort_keys=True) + return hashlib.sha256(body.encode("utf-8")).hexdigest()[:16] diff --git a/benchbox/core/joinorder_synthetic/benchmark.py b/benchbox/core/joinorder_synthetic/benchmark.py index 59136ada1b..8258c5db62 100644 --- a/benchbox/core/joinorder_synthetic/benchmark.py +++ b/benchbox/core/joinorder_synthetic/benchmark.py @@ -29,8 +29,10 @@ class JoinOrderSyntheticBenchmark(GeneratorOutputDirMixin, BaseBenchmark): """Synthetic Join Order Benchmark implementation. - This internal surface is for fast schema and loader smoke tests only. - It is not a cardinality-estimation substitute for canonical JOB. + This internal surface runs the canonical 113-query JOB surface, + verbatim, over fast uniformly-random synthetic data with golden + entities planted so conjunctive filters match. It is not a + cardinality-estimation substitute for canonical JOB. """ # CSV dialect for resolve_csv_dialect path (b) — used when manifest metadata is absent @@ -95,6 +97,7 @@ def __init__( self.queries_dir = queries_dir self._schema = JoinOrderSchema() self._query_manager = JoinOrderQueryManager(queries_dir) + self._dataframe_registry: QueryRegistry | None = None # Pass all kwargs through to generator (includes compression params) generator_kwargs: dict[str, Any] = { @@ -222,7 +225,9 @@ def load_queries_from_directory(self, queries_dir: str) -> None: Args: queries_dir: Path to directory containing Join Order Benchmark .sql files """ + self.queries_dir = queries_dir self._query_manager = JoinOrderQueryManager(queries_dir) + self._dataframe_registry = None def get_table_info(self, table_name: str) -> dict[str, Any]: """Get information about a specific table. @@ -314,15 +319,19 @@ def get_benchmark_info(self) -> dict[str, Any]: def get_dataframe_queries(self) -> QueryRegistry: """Get DataFrame query implementations for JoinOrder. - Returns the QueryRegistry containing DataFrame implementations of all - 13 JoinOrder queries for both expression-family and pandas-family platforms. + The default registry contains all 113 canonical queries. A custom + directory uses the instance's replacement SQL set for both families. Returns: - QueryRegistry with all 13 JoinOrder DataFrame queries + QueryRegistry matching this benchmark's SQL query set """ from benchbox.core.joinorder_synthetic.dataframe_queries import get_dataframe_queries - return get_dataframe_queries() + if self.queries_dir is None: + return get_dataframe_queries() + if self._dataframe_registry is None: + self._dataframe_registry = get_dataframe_queries(self._query_manager) + return self._dataframe_registry def __repr__(self) -> str: """String representation of the benchmark. diff --git a/benchbox/core/joinorder_synthetic/dataframe_queries.py b/benchbox/core/joinorder_synthetic/dataframe_queries.py index 9cb68878c7..dded0da0da 100644 --- a/benchbox/core/joinorder_synthetic/dataframe_queries.py +++ b/benchbox/core/joinorder_synthetic/dataframe_queries.py @@ -1,7 +1,8 @@ """Synthetic JoinOrder DataFrame queries for Expression and Pandas families. -Implements the 13 historical JoinOrder smoke-test queries through a restricted -JOB SQL-to-DataFrame translator. +Implements the 113 canonical JoinOrder queries through a restricted +JOB SQL-to-DataFrame translator sharing the canonical deterministic +join planner. Copyright 2026 Joe Harris / BenchBox Project @@ -10,7 +11,6 @@ from __future__ import annotations -import re from csv import reader from typing import TYPE_CHECKING, Any @@ -27,6 +27,7 @@ _flatten_and, _is_join_equality, _join_key, + _plan_join_sequence, _prefixed_pandas_frame, _qualified, _select_min_columns, @@ -38,17 +39,11 @@ import pandas as pd _QUERY_MANAGER = JoinOrderQueryManager() -_PARENTHESIZED_LIKE_PATTERN = re.compile(r"(NOT\s+LIKE|LIKE)\s+'%\(([^%()]+)\)%'", re.IGNORECASE) -_Q6A_ACTOR_NAME_PATTERN = re.compile(r"n\.name\s+LIKE\s+'%Downey%Robert%'", re.IGNORECASE) -def _query_sql(query_id: str) -> str: - sql = _PARENTHESIZED_LIKE_PATTERN.sub( - lambda match: f"{match.group(1)} '%{match.group(2)}%'", _QUERY_MANAGER.get_query(query_id) - ) - if query_id == "6a": - sql = _Q6A_ACTOR_NAME_PATTERN.sub("n.name LIKE '%Downey%' AND n.name LIKE '%Robert%'", sql) - return sql +def _query_sql(query_id: str, query_manager: JoinOrderQueryManager | None = None) -> str: + """Translate the same predicates the SQL benchmark executes.""" + return (query_manager or _QUERY_MANAGER).get_query(query_id) def _join_predicates(tree: exp.Select) -> tuple[list[tuple[str, str]], list[exp.Expression], list[exp.Expression]]: @@ -85,8 +80,10 @@ def _prefixed_expression_frame( return frame -def _execute_joinorder_expression_query(ctx: DataFrameContext, query_id: str) -> Any: - tree = parse_one(_query_sql(query_id), read="duckdb") +def _execute_joinorder_expression_query( + ctx: DataFrameContext, query_id: str, query_manager: JoinOrderQueryManager | None = None +) -> Any: + tree = parse_one(_query_sql(query_id, query_manager), read="duckdb") tables, predicates, join_predicates = _join_predicates(tree) join_key_columns: dict[str, list[tuple[str, int]]] = {alias: [] for alias, _table in tables} for index, predicate in enumerate(join_predicates): @@ -114,39 +111,18 @@ def _execute_joinorder_expression_query(ctx: DataFrameContext, query_id: str) -> for alias, table_name in tables } - joined_aliases = {tables[0][0]} result = frames[tables[0][0]] - remaining_aliases = {alias for alias, _table in tables[1:]} used_join_predicates: set[int] = set() - - while remaining_aliases: - for predicate_index, predicate in enumerate(join_predicates): - left_alias, left_column = _column_ref(predicate.this) - right_alias, right_column = _column_ref(predicate.expression) - if left_alias in joined_aliases and right_alias in remaining_aliases: - result = result.join( - frames[right_alias], - left_on=_join_key(left_alias, left_column, predicate_index), - right_on=_join_key(right_alias, right_column, predicate_index), - ) - joined_aliases.add(right_alias) - remaining_aliases.remove(right_alias) - used_join_predicates.add(id(predicate)) - break - if right_alias in joined_aliases and left_alias in remaining_aliases: - result = result.join( - frames[left_alias], - left_on=_join_key(right_alias, right_column, predicate_index), - right_on=_join_key(left_alias, left_column, predicate_index), - ) - joined_aliases.add(left_alias) - remaining_aliases.remove(left_alias) - used_join_predicates.add(id(predicate)) - break - else: - alias = remaining_aliases.pop() - result = result.join(frames[alias], how="cross") - joined_aliases.add(alias) + for step in _plan_join_sequence(tables, join_predicates): + if step.predicate_index < 0: + result = result.join(frames[step.new_alias], how="cross") + continue + result = result.join( + frames[step.new_alias], + left_on=_join_key(step.existing_alias, step.existing_column, step.predicate_index), + right_on=_join_key(step.new_alias, step.new_column, step.predicate_index), + ) + used_join_predicates.add(id(join_predicates[step.predicate_index])) for predicate in predicates: if id(predicate) not in used_join_predicates: @@ -160,10 +136,12 @@ def _execute_joinorder_expression_query(ctx: DataFrameContext, query_id: str) -> ) -def _execute_joinorder_pandas_query(ctx: DataFrameContext, query_id: str) -> pd.DataFrame: +def _execute_joinorder_pandas_query( + ctx: DataFrameContext, query_id: str, query_manager: JoinOrderQueryManager | None = None +) -> pd.DataFrame: import pandas as pd - tree = parse_one(_query_sql(query_id), read="duckdb") + tree = parse_one(_query_sql(query_id, query_manager), read="duckdb") tables, predicates, join_predicates = _join_predicates(tree) local_predicates = { alias: [ @@ -177,41 +155,19 @@ def _execute_joinorder_pandas_query(ctx: DataFrameContext, query_id: str) -> pd. alias: _prefixed_pandas_frame(ctx, table_name, alias, local_predicates[alias]) for alias, table_name in tables } - joined_aliases = {tables[0][0]} result = frames[tables[0][0]] - remaining_aliases = {alias for alias, _table in tables[1:]} used_join_predicates: set[int] = set() - - while remaining_aliases: - for predicate in join_predicates: - left_alias, left_column = _column_ref(predicate.this) - right_alias, right_column = _column_ref(predicate.expression) - if left_alias in joined_aliases and right_alias in remaining_aliases: - result = result.merge( - frames[right_alias], - left_on=_qualified(left_alias, left_column), - right_on=_qualified(right_alias, right_column), - how="inner", - ) - joined_aliases.add(right_alias) - remaining_aliases.remove(right_alias) - used_join_predicates.add(id(predicate)) - break - if right_alias in joined_aliases and left_alias in remaining_aliases: - result = result.merge( - frames[left_alias], - left_on=_qualified(right_alias, right_column), - right_on=_qualified(left_alias, left_column), - how="inner", - ) - joined_aliases.add(left_alias) - remaining_aliases.remove(left_alias) - used_join_predicates.add(id(predicate)) - break - else: - alias = remaining_aliases.pop() - result = result.merge(frames[alias], how="cross") - joined_aliases.add(alias) + for step in _plan_join_sequence(tables, join_predicates): + if step.predicate_index < 0: + result = result.merge(frames[step.new_alias], how="cross") + continue + result = result.merge( + frames[step.new_alias], + left_on=_qualified(step.existing_alias, step.existing_column), + right_on=_qualified(step.new_alias, step.new_column), + how="inner", + ) + used_join_predicates.add(id(join_predicates[step.predicate_index])) for predicate in predicates: if id(predicate) not in used_join_predicates: @@ -227,29 +183,50 @@ def _execute_joinorder_pandas_query(ctx: DataFrameContext, query_id: str) -> pd. ) -def _make_expression_impl(query_id: str) -> Any: +def _make_expression_impl(query_id: str, query_manager: JoinOrderQueryManager | None = None) -> Any: def _impl(ctx: DataFrameContext) -> Any: - return _execute_joinorder_expression_query(ctx, query_id) + return _execute_joinorder_expression_query(ctx, query_id, query_manager) _impl.__name__ = f"q{query_id}_expression_impl" _impl.__doc__ = f"{query_id}: generated synthetic JoinOrder DataFrame translation." return _impl -def _make_pandas_impl(query_id: str) -> Any: +def _make_pandas_impl(query_id: str, query_manager: JoinOrderQueryManager | None = None) -> Any: def _impl(ctx: DataFrameContext) -> Any: - return _execute_joinorder_pandas_query(ctx, query_id) + return _execute_joinorder_pandas_query(ctx, query_id, query_manager) _impl.__name__ = f"q{query_id}_pandas_impl" _impl.__doc__ = f"{query_id}: generated synthetic JoinOrder pandas translation." return _impl -_QUERY_IDS = ("1a", "1b", "2a", "3a", "4a", "5a", "6a", "7a", "8a", "9a", "10a", "11a", "12a") +# Impls register here (keyed by "q__impl") instead of mutating +# module globals (F7), so the registry build is statically typed and the +# generated names stay resolvable. See __getattr__ for name-based access. +_IMPLS: dict[str, Any] = {} + + +def __getattr__(name: str) -> Any: + """Resolve factory-built impl names from the registry (PEP 562).""" + if name in _IMPLS: + return _IMPLS[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +# All 113 canonical ids get generated DataFrame impls through the +# restricted translator. SQL text comes from the canonical JOB queries +# via the cached JoinOrderQueryManager. +try: + from benchbox.core.joinorder.queries import CANONICAL_JOINORDER_QUERIES as _CANONICAL_IDS + + _ALL_QUERY_IDS = tuple(_CANONICAL_IDS) +except ImportError: + _ALL_QUERY_IDS = () -for _query_id in _QUERY_IDS: - globals()[f"q{_query_id}_expression_impl"] = _make_expression_impl(_query_id) - globals()[f"q{_query_id}_pandas_impl"] = _make_pandas_impl(_query_id) +for _query_id in _ALL_QUERY_IDS: + _IMPLS[f"q{_query_id}_expression_impl"] = _make_expression_impl(_query_id) + _IMPLS[f"q{_query_id}_pandas_impl"] = _make_pandas_impl(_query_id) JOINORDER_DATAFRAME_QUERIES = QueryRegistry("JoinOrder DataFrame") @@ -272,9 +249,9 @@ def _impl(ctx: DataFrameContext) -> Any: 7a|Biography Movies with Person Criteria|Biography movies with specific person criteria (8 tables)|MJ,AG,FI 8a|Japanese Dubbed Movies|Japanese dubbed movies by specific actress criteria (7 tables)|MJ,AG,FI 9a|American Voice Actress Movies|American voice actress movies (8 tables)|MJ,AG,FI -10a|American Producer Movies|Movies with American producers after 1990 (7 tables)|MJ,AG,FI +10a|Russian Actor Movies|Movies with Russian actors after 2005 (7 tables)|MJ,AG,FI 11a|Non-Polish Sequel Movies with Follow Links|Sequel movies from Film/Warner companies with follow links (9 tables)|MJ,AG,FI -12a|Drama/Horror US Movies with High Ratings|US drama/horror movies with high ratings using double info_type join (8 tables)|MJ,AG,FI +12a|US Movies with High Ratings|US movies with high ratings using double info_type join (8 tables)|MJ,AG,FI """ @@ -284,16 +261,56 @@ def _impl(ctx: DataFrameContext) -> Any: query_name=query_name, description=description, categories=[_CATEGORY_CODES[code] for code in category_codes.split(",")], - expression_impl=globals()[f"q{query_id}_expression_impl"], - pandas_impl=globals()[f"q{query_id}_pandas_impl"], + expression_impl=_IMPLS[f"q{query_id}_expression_impl"], + pandas_impl=_IMPLS[f"q{query_id}_pandas_impl"], ) for query_id, query_name, description, category_codes in reader(_QUERY_METADATA.splitlines(), delimiter="|") ] + +def _categories_for(query_id: str, query_sql: str) -> list[QueryCategory]: + """Classify a query by its parsed table aliases. + + BenchBox convention: plain JOIN below 6 tables, MULTI_JOIN at 6+. + Every JOB query aggregates and filters. + """ + tables = len(_sql_tables(parse_one(query_sql, read="duckdb"))) + join_category = QueryCategory.MULTI_JOIN if tables >= 6 else QueryCategory.JOIN + return ([join_category] if tables > 1 else []) + [QueryCategory.AGGREGATE, QueryCategory.FILTER] + + +_QUERIES.extend( + DataFrameQuery( + query_id=query_id, + query_name=f"JOB {query_id}", + description=f"Canonical JOB query {query_id}; generated synthetic DataFrame translation.", + categories=_categories_for(query_id, _QUERY_MANAGER.get_query(query_id)), + expression_impl=_IMPLS[f"q{query_id}_expression_impl"], + pandas_impl=_IMPLS[f"q{query_id}_pandas_impl"], + ) + for query_id in _ALL_QUERY_IDS + if query_id not in {query.query_id for query in _QUERIES} +) + for _query in _QUERIES: JOINORDER_DATAFRAME_QUERIES.register(_query) -def get_dataframe_queries() -> QueryRegistry: - """Get the JoinOrder DataFrame query registry.""" - return JOINORDER_DATAFRAME_QUERIES +def get_dataframe_queries(query_manager: JoinOrderQueryManager | None = None) -> QueryRegistry: + """Get the shared canonical registry or bind a custom SQL snapshot.""" + if query_manager is None: + return JOINORDER_DATAFRAME_QUERIES + registry = QueryRegistry("JoinOrder DataFrame") + for query_id, sql in query_manager.get_all_queries().items(): + registry.register( + DataFrameQuery( + query_id=query_id, + query_name=f"JOB {query_id}", + description=f"JoinOrder query {query_id} from the benchmark's query directory.", + categories=_categories_for(query_id, sql), + expression_impl=_make_expression_impl(query_id, query_manager), + pandas_impl=_make_pandas_impl(query_id, query_manager), + sql_equivalent=sql, + ) + ) + return registry diff --git a/benchbox/core/joinorder_synthetic/generator.py b/benchbox/core/joinorder_synthetic/generator.py index ffe0cfd025..a1168ee1b5 100644 --- a/benchbox/core/joinorder_synthetic/generator.py +++ b/benchbox/core/joinorder_synthetic/generator.py @@ -153,6 +153,7 @@ def _generate_data_local(self, output_dir: Path) -> dict[str, Path]: # Write all data to files all_data = {**lookup_data, **dimension_data, **relationship_data} + self._plant_golden_entities(dimension_data, relationship_data) for table_name, data in all_data.items(): file_path = self._write_table_data(table_name, data) @@ -382,8 +383,15 @@ def _generate_titles(self, count: int) -> list[tuple]: word2 = random.choice(movie_words) title = f"{prefix} {word1} {word2}".strip() - kind_id = random.randint(1, 7) - production_year = random.randint(1950, 2023) if random.random() > 0.1 else None + if random.random() < 0.30: + # Seed years inside the canonical JOB windows (1990-2010 + # family filters) and movie kind so year/kind predicates + # match instead of scattering over 1950-2023. + kind_id = random.choice([1, 1, 1, 2]) + production_year = random.randint(self._SEED_YEAR_LOW - 15, self._SEED_YEAR_HIGH) + else: + kind_id = random.randint(1, 7) + production_year = random.randint(1950, 2023) if random.random() > 0.1 else None titles.append( ( @@ -404,6 +412,38 @@ def _generate_titles(self, count: int) -> list[tuple]: return titles + # Canonical JOB predicate seeds: values the real JOB queries filter + # on. A fixed share of rows carries these so multi-table conjunctive + # filters match and the cross-surface gate compares real rows instead + # of vacuous all-NULL scalars. random.seed(42) is set in + # _generate_data_local, so placement is deterministic. + _SEED_COMPANIES = ("Warner Bros", "Warner Films", "Universal Films") + _SEED_COUNTRIES = ("[us]", "[us]", "[ru]", "[pl]") + _SEED_NAMES = ("Robert Downey", "Downey Robert", "Angelina Smith") + _SEED_KEYWORDS_EXTRA = ("marvel-cinematic-universe", "sequel") + _GOLDEN_KEYWORDS = ( + "marvel-cinematic-universe", + "murder", + "blood", + "violence", + "superhero", + "sequel", + "character-name-in-title", + ) + _GOLDEN_CI_NOTES = ("(writer)", "(voice)", "(producer)") + _GOLDEN_CI_NOTES_EXTRA = ("(voice) (uncredited)", "(voice) (uncredited)", "(producer)") + _SEED_NOTES = ("(producer)", "(producer)", "(co-production)", "(presents)") + _SEED_MC_NOTES = ( + "(2002) (USA) (theatrical)", + "(2005) (worldwide) (theatrical)", + "(USA) (VHS)", + "(Japan) (Blu-ray)", + "(France) (theatrical)", + ) + _SEED_MI_INFO = ("Sweden", "Germany", "Norwegian", "USA") + _SEED_ROLES = ("producer", "producer", "actor") + _SEED_YEAR_LOW, _SEED_YEAR_HIGH = 2005, 2010 + def _generate_names(self, count: int) -> list[tuple]: """Generate name data.""" names = [] @@ -433,9 +473,12 @@ def _generate_names(self, count: int) -> list[tuple]: ] for i in range(1, count + 1): - first = random.choice(first_names) - last = random.choice(last_names) - name = f"{first} {last}" + if random.random() < 0.08: + name = random.choice(self._SEED_NAMES) + else: + first = random.choice(first_names) + last = random.choice(last_names) + name = f"{first} {last}" gender = random.choice(["m", "f"]) if random.random() > 0.1 else None names.append((i, name, None, None, gender, None, None, None, None)) @@ -477,10 +520,14 @@ def _generate_companies(self, count: int) -> list[tuple]: ] for i in range(1, count + 1): - name_part = random.choice(company_names) - type_part = random.choice(company_types) - name = f"{name_part} {type_part}" - country = random.choice(countries) + if random.random() < 0.10: + name = random.choice(self._SEED_COMPANIES) + country = random.choice(self._SEED_COUNTRIES) + else: + name_part = random.choice(company_names) + type_part = random.choice(company_types) + name = f"{name_part} {type_part}" + country = random.choice(countries) companies.append((i, name, country, None, None, None, None)) @@ -517,6 +564,8 @@ def _generate_keywords(self, count: int) -> list[tuple]: for i in range(1, count + 1): keyword = keyword_list[i - 1] if i <= len(keyword_list) else f"keyword_{i}" + if random.random() < 0.10: + keyword = random.choice(self._SEED_KEYWORDS_EXTRA) keywords.append((i, keyword, None)) @@ -549,8 +598,16 @@ def _generate_cast_info(self, count: int, max_name_id: int, max_title_id: int, m person_id = random.randint(1, max_name_id) movie_id = random.randint(1, max_title_id) person_role_id = random.randint(1, max_char_id) if random.random() > 0.3 else None - role_id = random.randint(1, 12) - note = None + if random.random() < 0.15: + # Canonical queries filter rt.role in (producer, actor): + # role_id 3 = producer, 1 = actor in the lookup table. + role_id = random.choice([3, 3, 1]) + else: + role_id = random.randint(1, 12) + if random.random() < 0.12: + note = random.choice(self._SEED_NOTES) + else: + note = None nr_order = random.randint(1, 20) if random.random() > 0.5 else None cast_info.append((i, person_id, movie_id, person_role_id, note, nr_order, role_id)) @@ -565,7 +622,10 @@ def _generate_movie_companies(self, count: int, max_title_id: int, max_company_i movie_id = random.randint(1, max_title_id) company_id = random.randint(1, max_company_id) company_type_id = random.randint(1, 4) - note = None + if random.random() < 0.12: + note = random.choice(self._SEED_MC_NOTES) + else: + note = None movie_companies.append((i, movie_id, company_id, company_type_id, note)) @@ -586,7 +646,10 @@ def _generate_movie_info(self, count: int, max_title_id: int) -> list[tuple]: movie_id = random.randint(1, max_title_id) info_type_id = random.randint(1, 24) - info = random.choice(info_values[info_type_id]) if info_type_id in info_values else f"info_{i}" + if random.random() < 0.15: + info = random.choice(self._SEED_MI_INFO) + else: + info = random.choice(info_values[info_type_id]) if info_type_id in info_values else f"info_{i}" movie_info.append((i, movie_id, info_type_id, info, None)) @@ -691,6 +754,109 @@ def _generate_aka_title(self, count: int, max_title_id: int) -> list[tuple]: aka_title.append((i, movie_id, title, None, kind_id, production_year, None, None, None, None, None, None)) return aka_title + _GOLDEN_COUNT = 60 + + def _plant_golden_entities( + self, + dimension_data: dict[str, list[tuple]], + relationship_data: dict[str, list[tuple]], + ) -> None: + """Overwrite leading rows with coordinated golden entities. + + Independent per-table seeding leaves conjunctive multi-table + filters empty: each predicate matches ~10%, so a 6-table join + matches ~1e-6 of the cartesian product. Golden entities share one + coordinated key set (golden movie/company/person/keyword ids) so + the canonical JOB conjunctions join to real rows and the gate + discriminates instead of comparing all-NULL scalars. Deterministic + under the random.seed(42) set in _generate_data_local. + """ + titles = dimension_data["title"] + names = dimension_data["name"] + companies = dimension_data["company_name"] + keywords = dimension_data["keyword"] + title_cols = len(titles[0]) if titles else 0 + name_cols = len(names[0]) if names else 0 + company_cols = len(companies[0]) if companies else 0 + keyword_cols = len(keywords[0]) if keywords else 0 + next_title = max(row[0] for row in titles) + 1 if titles else 1 + next_name = max(row[0] for row in names) + 1 if names else 1 + next_company = max(row[0] for row in companies) + 1 if companies else 1 + next_keyword = max(row[0] for row in keywords) + 1 if keywords else 1 + golden_titles = list(range(next_title, next_title + self._GOLDEN_COUNT)) + golden_names = list(range(next_name, next_name + self._GOLDEN_COUNT)) + golden_companies = list(range(next_company, next_company + self._GOLDEN_COUNT)) + golden_keywords = list(range(next_keyword, next_keyword + self._GOLDEN_COUNT)) + for offset in range(self._GOLDEN_COUNT): + title_row = [golden_titles[offset], f"Golden Adventure {offset}", None, 1, 2005 + (offset % 6)] + title_row += [None] * (title_cols - len(title_row)) + # Golden episodes back the aka_name families (t.episode_nr in [50, 100)). + if offset % 3 == 0: + title_row[9] = 50 + (offset % 50) + titles.append(tuple(title_row)) + # Canonical JOB requires Downey before Robert in its LIKE pattern. + name_row = [golden_names[offset], "Downey Robert" if offset % 2 == 0 else "Angelina Smith"] + name_row += [None] * (name_cols - len(name_row)) + names.append(tuple(name_row)) + company_row = [golden_companies[offset], "Warner Films" if offset % 2 == 0 else "Universal Films", "[us]"] + # Alternate [us]/[ru] so country-specific families (US and + # Russian variants) both join to golden companies. + if offset % 4 >= 2: + company_row[2] = "[ru]" + company_row[1] = "Moscow Films" + company_row += [None] * (company_cols - len(company_row)) + companies.append(tuple(company_row)) + keyword_row = [golden_keywords[offset], self._GOLDEN_KEYWORDS[offset % len(self._GOLDEN_KEYWORDS)]] + keyword_row += [None] * (keyword_cols - len(keyword_row)) + keywords.append(tuple(keyword_row)) + cast_info = relationship_data["cast_info"] + for index, row in enumerate(cast_info[: self._GOLDEN_COUNT]): + row_list = list(row) + row_list[1] = golden_names[index % len(golden_names)] + row_list[2] = golden_titles[index % len(golden_titles)] + row_list[4] = self._GOLDEN_CI_NOTES_EXTRA[index % len(self._GOLDEN_CI_NOTES_EXTRA)] + row_list[6] = 3 if index % 2 == 0 else 1 + cast_info[index] = tuple(row_list) + movie_companies = relationship_data["movie_companies"] + for index, row in enumerate(movie_companies[: self._GOLDEN_COUNT]): + row_list = list(row) + row_list[1] = golden_titles[index % len(golden_titles)] + row_list[2] = golden_companies[index % len(golden_companies)] + row_list[4] = "(2005) (USA) (theatrical)" + movie_companies[index] = tuple(row_list) + movie_keyword = relationship_data["movie_keyword"] + for index, row in enumerate(movie_keyword[: self._GOLDEN_COUNT]): + row_list = list(row) + row_list[1] = golden_titles[index % len(golden_titles)] + row_list[2] = golden_keywords[index % len(golden_keywords)] + movie_keyword[index] = tuple(row_list) + movie_info = relationship_data["movie_info"] + for index, row in enumerate(movie_info[: self._GOLDEN_COUNT]): + row_list = list(row) + row_list[1] = golden_titles[index % len(golden_titles)] + row_list[3] = "Sweden" if index % 2 == 0 else "Germany" + movie_info[index] = tuple(row_list) + aka_name = relationship_data["aka_name"] + for index, row in enumerate(aka_name[: self._GOLDEN_COUNT]): + row_list = list(row) + row_list[1] = golden_names[index % len(golden_names)] + row_list[2] = "Golden Alias" + aka_name[index] = tuple(row_list) + complete_cast = relationship_data["complete_cast"] + for index, row in enumerate(complete_cast[: self._GOLDEN_COUNT]): + row_list = list(row) + row_list[1] = golden_titles[index % len(golden_titles)] + row_list[2] = 1 + row_list[3] = 3 + complete_cast[index] = tuple(row_list) + movie_link = relationship_data["movie_link"] + for index, row in enumerate(movie_link[: self._GOLDEN_COUNT]): + row_list = list(row) + row_list[1] = golden_titles[index % len(golden_titles)] + row_list[2] = golden_titles[(index + 1) % len(golden_titles)] + row_list[3] = 1 + movie_link[index] = tuple(row_list) + def _write_table_data(self, table_name: str, data: list[tuple]) -> PathLike: """Write table data to CSV file. diff --git a/benchbox/core/joinorder_synthetic/queries.py b/benchbox/core/joinorder_synthetic/queries.py index 6a68925fee..152213279f 100644 --- a/benchbox/core/joinorder_synthetic/queries.py +++ b/benchbox/core/joinorder_synthetic/queries.py @@ -12,9 +12,19 @@ import os from pathlib import Path +from typing import Any from benchbox.utils.printing import emit +try: + from benchbox.core.joinorder.queries import ( + CANONICAL_JOINORDER_QUERIES, + JoinOrderQueryManager as _CanonicalQueryManager, + ) +except ImportError: + CANONICAL_JOINORDER_QUERIES = {} + _CanonicalQueryManager = None # type: ignore[assignment] + class JoinOrderQueryManager: """Manager for Join Order Benchmark queries.""" @@ -22,12 +32,34 @@ class JoinOrderQueryManager: def __init__(self, queries_dir: str | None = None) -> None: """Initialize the Join Order query manager. + The default surface is exactly the canonical 113-query JOB set: + this manager holds one cached canonical manager and delegates + every lookup to it, so the synthetic surface can never drift from + canonical definitions again. + Args: queries_dir: Path to directory containing Join Order Benchmark query files. - If None, uses embedded queries. + If None, uses the canonical embedded queries. A supplied directory is the + complete replacement query set: only its files are visible, + with no canonical fallback. """ self._queries_dir = queries_dir self._queries = self._load_queries() + # One cached canonical manager per instance (F5): never + # reinstantiate per lookup, and never fall back to raw dicts that + # skip portable-alias normalization. + self._canonical: Any = None + if not self._uses_custom_query_directory() and _CanonicalQueryManager is not None: + self._canonical = _CanonicalQueryManager() + + def _uses_custom_query_directory(self) -> bool: + """Whether a caller-supplied query directory replaces the built-in set. + + Returns: + True when ``queries_dir`` pointed at an existing directory, in which + case the files on disk are the complete query set. + """ + return bool(self._queries_dir) and os.path.exists(self._queries_dir) def _load_queries(self) -> dict[str, str]: """Load all Join Order Benchmark queries. @@ -38,7 +70,7 @@ def _load_queries(self) -> dict[str, str]: if self._queries_dir and os.path.exists(self._queries_dir): return self._load_queries_from_files() - return self._load_embedded_queries() + return {} def _load_queries_from_files(self) -> dict[str, str]: """Load queries from the Join Order Benchmark query files directory. @@ -60,385 +92,12 @@ def _load_queries_from_files(self) -> dict[str, str]: return queries - def _load_embedded_queries(self) -> dict[str, str]: - """Load embedded Join Order Benchmark queries based on the original benchmark. - - Returns: - Dictionary mapping query IDs to SQL text - """ - queries = {} - - # Query 1a: Production companies with top 250 rank - queries["1a"] = """ -SELECT MIN(mc.note) AS production_note, - MIN(t.title) AS movie_title, - MIN(t.production_year) AS movie_year -FROM company_type AS ct, - info_type AS it, - movie_companies AS mc, - movie_info_idx AS mi_idx, - title AS t -WHERE ct.kind = 'production companies' - AND it.info = 'top 250 rank' - AND mc.note NOT LIKE '%(as Metro-Goldwyn-Mayer Pictures)%' - AND (mc.note LIKE '%(co-production)%' - OR mc.note LIKE '%(presents)%') - AND ct.id = mc.company_type_id - AND t.id = mc.movie_id - AND t.id = mi_idx.movie_id - AND mc.movie_id = mi_idx.movie_id - AND it.id = mi_idx.info_type_id; -""" - - # Query 1b: Variant of 1a with different predicates - queries["1b"] = """ -SELECT MIN(mc.note) AS production_note, - MIN(t.title) AS movie_title, - MIN(t.production_year) AS movie_year -FROM company_type AS ct, - info_type AS it, - movie_companies AS mc, - movie_info_idx AS mi_idx, - title AS t -WHERE ct.kind = 'production companies' - AND it.info = 'bottom 10 rank' - AND mc.note NOT LIKE '%(as Metro-Goldwyn-Mayer Pictures)%' - AND (mc.note LIKE '%(co-production)%' - OR mc.note LIKE '%(presents)%') - AND ct.id = mc.company_type_id - AND t.id = mc.movie_id - AND t.id = mi_idx.movie_id - AND mc.movie_id = mi_idx.movie_id - AND it.id = mi_idx.info_type_id; -""" - - # Query 2a: Movies with specific keywords - queries["2a"] = """ -SELECT MIN(t.title) AS movie_title -FROM company_name AS cn, - keyword AS k, - movie_companies AS mc, - movie_keyword AS mk, - title AS t -WHERE cn.country_code ='[de]' - AND k.keyword ='character-name-in-title' - AND cn.id = mc.company_id - AND mc.movie_id = t.id - AND t.id = mk.movie_id - AND mk.keyword_id = k.id - AND mc.movie_id = mk.movie_id; -""" - - # Query 3a: Cast information with specific roles - queries["3a"] = """ -SELECT MIN(t.title) AS movie_title -FROM keyword AS k, - movie_info AS mi, - movie_keyword AS mk, - title AS t -WHERE k.keyword LIKE '%sequel%' - AND mi.info IN ('Sweden', - 'Norway', - 'Germany', - 'Denmark', - 'Swedish', - 'Denish', - 'Norwegian', - 'German', - 'USA', - 'American') - AND t.production_year > 1990 - AND t.id = mi.movie_id - AND t.id = mk.movie_id - AND mk.keyword_id = k.id - AND mi.movie_id = mk.movie_id; -""" - - # Query 4a: Complex join with multiple tables - queries["4a"] = """ -SELECT MIN(mi_idx.info) AS rating, - MIN(t.title) AS movie_title -FROM info_type AS it, - keyword AS k, - movie_info_idx AS mi_idx, - movie_keyword AS mk, - title AS t -WHERE it.info ='rating' - AND k.keyword LIKE '%sequel%' - AND mi_idx.info > '2.0' - AND t.production_year > 1990 - AND t.id = mi_idx.movie_id - AND t.id = mk.movie_id - AND mk.keyword_id = k.id - AND it.id = mi_idx.info_type_id - AND mi_idx.movie_id = mk.movie_id; -""" - - # Query 5a: European movies - queries["5a"] = """ -SELECT MIN(t.title) AS typical_european_movie -FROM company_type AS ct, - info_type AS it, - movie_companies AS mc, - movie_info AS mi, - title AS t -WHERE ct.kind = 'production companies' - AND mc.note LIKE '%(theatrical)%' - AND mc.note LIKE '%(France)%' - AND mi.info IN ('Sweden', - 'Norway', - 'Germany', - 'Denmark', - 'Swedish', - 'Denish', - 'Norwegian', - 'German') - AND t.production_year > 2005 - AND t.id = mi.movie_id - AND t.id = mc.movie_id - AND mc.movie_id = mi.movie_id - AND ct.id = mc.company_type_id - AND it.id = mi.info_type_id; -""" - - # Query 6a: Cast and company information - queries["6a"] = """ -SELECT MIN(k.keyword) AS movie_keyword, - MIN(n.name) AS actor_name, - MIN(t.title) AS hero_movie -FROM cast_info AS ci, - keyword AS k, - movie_keyword AS mk, - name AS n, - title AS t -WHERE k.keyword IN ('superhero', - 'sequel', - 'second-part', - 'marvel-comics', - 'based-on-comic', - 'tv-special', - 'fight', - 'violence') - AND n.name LIKE '%Downey%Robert%' - AND t.production_year > 2000 - AND k.id = mk.keyword_id - AND t.id = mk.movie_id - AND t.id = ci.movie_id - AND ci.person_id = n.id - AND mk.movie_id = ci.movie_id; -""" - - # Query 7a: Production year and company information - queries["7a"] = """ -SELECT MIN(n.name) AS of_person, - MIN(t.title) AS biography_movie -FROM aka_name AS an, - cast_info AS ci, - info_type AS it, - link_type AS lt, - movie_link AS ml, - name AS n, - person_info AS pi, - title AS t -WHERE an.name LIKE '%a%' - AND it.info ='mini biography' - AND lt.link ='features' - AND n.name_pcode_cf LIKE 'D%' - AND n.gender='m' - AND pi.note ='Volker Boehm' - AND t.production_year BETWEEN 1980 AND 1995 - AND n.id = an.person_id - AND n.id = pi.person_id - AND ci.person_id = n.id - AND t.id = ci.movie_id - AND ml.linked_movie_id = t.id - AND lt.id = ml.link_type_id - AND it.id = pi.info_type_id - AND pi.person_id = an.person_id - AND pi.person_id = ci.person_id - AND an.person_id = ci.person_id - AND ci.movie_id = ml.linked_movie_id; -""" - - # Query 8a: Complex cast information - queries["8a"] = """ -SELECT MIN(an1.name) AS actress_pseudonym, - MIN(t.title) AS japanese_movie_dubbed -FROM aka_name AS an1, - cast_info AS ci, - company_name AS cn, - movie_companies AS mc, - name AS n1, - role_type AS rt, - title AS t -WHERE ci.note ='(voice: Japanese version)' - AND cn.country_code ='[jp]' - AND mc.note LIKE '%(Japan)%' - AND mc.note NOT LIKE '%(USA)%' - AND n1.name LIKE '%Yo%' - AND n1.name NOT LIKE '%Yu%' - AND rt.role ='actress' - AND an1.person_id = n1.id - AND n1.id = ci.person_id - AND ci.movie_id = t.id - AND t.id = mc.movie_id - AND mc.company_id = cn.id - AND ci.role_id = rt.id - AND an1.person_id = ci.person_id - AND ci.movie_id = mc.movie_id; -""" - - # Query 9a: Movie information with specific criteria - queries["9a"] = """ -SELECT MIN(an.name) AS alternative_name, - MIN(chn.name) AS voiced_character_name, - MIN(n.name) AS voicing_actress, - MIN(t.title) AS american_movie -FROM aka_name AS an, - char_name AS chn, - cast_info AS ci, - company_name AS cn, - movie_companies AS mc, - name AS n, - role_type AS rt, - title AS t -WHERE ci.note IN ('(voice)', - '(voice: Japanese version)', - '(voice) (uncredited)', - '(voice: English version)') - AND cn.country_code ='[us]' - AND mc.note LIKE '%(USA)%' - AND mc.note NOT LIKE '%(worldwide)%' - AND n.gender ='f' - AND n.name LIKE '%An%' - AND rt.role ='actress' - AND t.production_year BETWEEN 2005 AND 2015 - AND ci.movie_id = t.id - AND t.id = mc.movie_id - AND ci.movie_id = mc.movie_id - AND mc.company_id = cn.id - AND ci.role_id = rt.id - AND n.id = ci.person_id - AND chn.id = ci.person_role_id - AND an.person_id = n.id - AND an.person_id = ci.person_id; -""" - - # Query 10a: Movie ratings and keywords - queries["10a"] = """ -SELECT MIN(chn.name) AS character, - MIN(t.title) AS movie_with_american_producer -FROM char_name AS chn, - cast_info AS ci, - company_name AS cn, - company_type AS ct, - movie_companies AS mc, - role_type AS rt, - title AS t -WHERE ci.note LIKE '%(producer)%' - AND cn.country_code = '[us]' - AND rt.role = 'producer' - AND t.production_year > 1990 - AND t.id = mc.movie_id - AND t.id = ci.movie_id - AND ci.movie_id = mc.movie_id - AND chn.id = ci.person_role_id - AND rt.id = ci.role_id - AND cn.id = mc.company_id - AND ct.id = mc.company_type_id; -""" - - # Include more core queries in demonstrate various join patterns - # These represent the key optimization challenges in the benchmark - - # Query 11a: Multiple table joins with filtering - queries["11a"] = """ -SELECT MIN(cn.name) AS from_company, - MIN(lt.link) AS movie_link_type, - MIN(t.title) AS non_polish_sequel_movie -FROM company_name AS cn, - company_type AS ct, - keyword AS k, - link_type AS lt, - movie_companies AS mc, - movie_info AS mi, - movie_keyword AS mk, - movie_link AS ml, - title AS t -WHERE cn.country_code !='[pl]' - AND (cn.name LIKE '%Film%' - OR cn.name LIKE '%Warner%') - AND ct.kind ='production companies' - AND k.keyword ='sequel' - AND lt.link LIKE '%follow%' - AND mc.note IS NULL - AND mi.info IN ('Sweden', - 'Norway', - 'Germany', - 'Denmark', - 'Swedish', - 'Denish', - 'Norwegian', - 'German', - 'English') - AND t.production_year BETWEEN 1950 AND 2010 - AND lt.id = ml.link_type_id - AND ml.movie_id = t.id - AND t.id = mk.movie_id - AND mk.keyword_id = k.id - AND t.id = mc.movie_id - AND mc.company_type_id = ct.id - AND mc.company_id = cn.id - AND mi.movie_id = t.id - AND ml.movie_id = mk.movie_id - AND ml.movie_id = mc.movie_id - AND mk.movie_id = mc.movie_id - AND ml.movie_id = mi.movie_id - AND mk.movie_id = mi.movie_id - AND mc.movie_id = mi.movie_id; -""" - - # Query 12a: Cast and production information - queries["12a"] = """ -SELECT MIN(cn.name) AS movie_company, - MIN(mi_idx.info) AS rating, - MIN(t.title) AS drama_horror_movie -FROM company_name AS cn, - company_type AS ct, - info_type AS it1, - info_type AS it2, - movie_companies AS mc, - movie_info AS mi, - movie_info_idx AS mi_idx, - title AS t -WHERE cn.country_code = '[us]' - AND ct.kind = 'production companies' - AND it1.info = 'genres' - AND it2.info = 'rating' - AND mi.info IN ('Drama', - 'Horror', - 'Western', - 'Family') - AND mi_idx.info > '8.0' - AND t.production_year BETWEEN 2005 AND 2008 - AND t.id = mi.movie_id - AND t.id = mi_idx.movie_id - AND mi.movie_id = mi_idx.movie_id - AND t.id = mc.movie_id - AND ct.id = mc.company_type_id - AND cn.id = mc.company_id - AND mc.movie_id = mi.movie_id - AND mc.movie_id = mi_idx.movie_id - AND mi.movie_id = mc.movie_id - AND it1.id = mi.info_type_id - AND it2.id = mi_idx.info_type_id; -""" - - return queries - def get_query(self, query_id: str) -> str: """Get a Join Order Benchmark query by ID. + Delegates to the cached canonical manager, which applies + portable-alias normalization (e.g. 15a-d `at` -> `at1`). + Args: query_id: Query identifier (e.g., '1a', '2b', etc.) @@ -448,19 +107,28 @@ def get_query(self, query_id: str) -> str: Raises: ValueError: If query_id is invalid """ - if query_id not in self._queries: - available = ", ".join(sorted(self._queries.keys())) - raise ValueError(f"Invalid query ID: {query_id}. Available: {available}") - - return self._queries[query_id] + if query_id in self._queries: + return self._queries[query_id] + if self._canonical is not None: + return self._canonical.get_query(query_id) + available = ", ".join(sorted(set(self._queries) | set(CANONICAL_JOINORDER_QUERIES))) + raise ValueError(f"Invalid query ID: {query_id}. Available: {available}") def get_all_queries(self) -> dict[str, str]: """Get all Join Order Benchmark queries. + The canonical 113-query set, verbatim. When a custom query + directory was supplied, the directory remains the complete + replacement query set and no canonical queries are merged in. + Returns: Dictionary mapping query IDs to SQL text """ - return self._queries.copy() + if self._uses_custom_query_directory(): + return dict(self._queries) + if self._canonical is not None: + return self._canonical.get_all_queries() + return dict(CANONICAL_JOINORDER_QUERIES) def get_query_ids(self) -> list[str]: """Get list of all query IDs. @@ -468,7 +136,7 @@ def get_query_ids(self) -> list[str]: Returns: List of query IDs in sorted order """ - return sorted(self._queries.keys()) + return sorted(self.get_all_queries().keys()) def get_query_count(self) -> int: """Get total number of queries. @@ -476,17 +144,20 @@ def get_query_count(self) -> int: Returns: Number of queries available """ - return len(self._queries) + return len(self.get_all_queries()) def get_queries_by_complexity(self) -> dict[str, list[str]]: """Categorize queries by complexity based on table count. + Classifies the same merged query set reported by ``get_all_queries``, + so the distribution accounts for all exposed queries. + Returns: Dictionary mapping complexity levels to query IDs """ complexity_map = {"simple": [], "medium": [], "complex": []} - for query_id, query_sql in self._queries.items(): + for query_id, query_sql in self.get_all_queries().items(): # Count FROM clauses and JOIN keywords to estimate complexity from_count = query_sql.upper().count("FROM") join_count = query_sql.upper().count("JOIN") @@ -513,6 +184,9 @@ def get_queries_by_complexity(self) -> dict[str, list[str]]: def get_queries_by_pattern(self) -> dict[str, list[str]]: """Categorize queries by join pattern. + Classifies the same merged query set reported by ``get_all_queries``, + so the distribution accounts for all exposed queries. + Returns: Dictionary mapping join patterns to query IDs """ @@ -522,7 +196,7 @@ def get_queries_by_pattern(self) -> dict[str, list[str]]: "complex_join": [], # Mixed patterns } - for query_id, query_sql in self._queries.items(): + for query_id, query_sql in self.get_all_queries().items(): # Simple heuristic based on table patterns if "movie_companies" in query_sql and "movie_info" in query_sql: pattern_map["star_join"].append(query_id) diff --git a/benchbox/core/loaded_tables.py b/benchbox/core/loaded_tables.py new file mode 100644 index 0000000000..468ebde21a --- /dev/null +++ b/benchbox/core/loaded_tables.py @@ -0,0 +1,45 @@ +"""Checks that a benchmark loaded the tables its queries read.""" + +from __future__ import annotations + +from typing import Any + + +def require_loaded_tables(benchmark: Any, table_stats: dict[str, Any] | None) -> None: + """Fail the run when a table the benchmark's queries need was not loaded. + + A benchmark lists those tables in ``REQUIRED_LOADED_TABLES``. Without this + check a run that loaded the wrong dataset still measures: the schema DDL + creates the required table empty, its queries succeed with no rows, and the + result looks valid. + + Benchmarks without a pinned table list are still refused a vacuous load: + when the benchmark expects data (anything but ``SKIP_DATA_LOADING``), zero + loaded tables or zero loaded rows fail the run instead of validating + vacuously. + """ + if getattr(benchmark, "SKIP_DATA_LOADING", False): + return + required = getattr(benchmark, "REQUIRED_LOADED_TABLES", ()) + if not isinstance(required, (tuple, list, set, frozenset)): + return + loaded = {str(name).lower(): rows for name, rows in (table_stats or {}).items()} + problems = [] + if not required: + if not any( + isinstance(rows, (int, float)) and not isinstance(rows, bool) and rows > 0 for rows in loaded.values() + ): + problems.append("no rows were loaded for any table") + else: + for table in required: + rows = loaded.get(table.lower()) + if rows is None: + problems.append(f"{table} was not loaded") + elif not isinstance(rows, (int, float)) or rows <= 0: + problems.append(f"{table} has {rows} rows") + if problems: + raise RuntimeError( + "Required benchmark tables are missing or empty after load: " + + "; ".join(problems) + + f". Loaded tables: {sorted(loaded)}" + ) diff --git a/benchbox/core/metadata_primitives/benchmark.py b/benchbox/core/metadata_primitives/benchmark.py index db13711b65..15ac258ead 100644 --- a/benchbox/core/metadata_primitives/benchmark.py +++ b/benchbox/core/metadata_primitives/benchmark.py @@ -225,6 +225,8 @@ class MetadataPrimitivesBenchmark(BaseBenchmark): query_manager: Query manager for loading catalog queries """ + SKIP_DATA_LOADING = True + def __init__( self, scale_factor: float = 1.0, diff --git a/benchbox/core/metadata_primitives/catalog/queries.yaml b/benchbox/core/metadata_primitives/catalog/queries.yaml index 287cccd7fa..6ca7858c1a 100644 --- a/benchbox/core/metadata_primitives/catalog/queries.yaml +++ b/benchbox/core/metadata_primitives/catalog/queries.yaml @@ -184,7 +184,7 @@ queries: description: EXPLAIN plan for a simple query sql: "EXPLAIN SELECT * FROM information_schema.tables LIMIT 10;\n" variants: - snowflake: "EXPLAIN SELECT * FROM information_schema.tables LIMIT 10;\n" + snowflake: "SELECT * FROM information_schema.tables LIMIT 10;\n" bigquery: "SELECT * FROM INFORMATION_SCHEMA.TABLES LIMIT 10;\n" clickhouse: "EXPLAIN SELECT * FROM system.tables LIMIT 10;\n" - id: query_explain_join @@ -192,6 +192,7 @@ queries: description: EXPLAIN plan for a join query sql: "EXPLAIN\nSELECT t.table_name, c.column_name\nFROM information_schema.tables t\nJOIN information_schema.columns c\n ON t.table_name = c.table_name\n AND t.table_schema = c.table_schema\nWHERE t.table_schema NOT IN ('information_schema', 'pg_catalog')\nLIMIT 100;\n" variants: + snowflake: "SELECT t.table_name, c.column_name\nFROM information_schema.tables t\nJOIN information_schema.columns c\n ON t.table_name = c.table_name\n AND t.table_schema = c.table_schema\nWHERE t.table_schema NOT IN ('INFORMATION_SCHEMA')\nLIMIT 100;\n" clickhouse: "EXPLAIN\nSELECT t.name AS table_name, c.name AS column_name\nFROM system.tables t\nJOIN system.columns c ON t.name = c.table AND t.database = c.database\nWHERE t.database NOT IN ('system', 'INFORMATION_SCHEMA')\nLIMIT 100;\n" bigquery: "SELECT t.table_name, c.column_name\nFROM INFORMATION_SCHEMA.TABLES t\nJOIN INFORMATION_SCHEMA.COLUMNS c\n ON t.table_name = c.table_name\n AND t.table_schema = c.table_schema\nLIMIT 100;\n" - id: query_explain_aggregate @@ -199,6 +200,7 @@ queries: description: EXPLAIN plan for an aggregate query sql: "EXPLAIN\nSELECT\n table_schema,\n COUNT(*) AS table_count,\n SUM(CASE WHEN table_type = 'BASE TABLE' THEN 1 ELSE 0 END) AS base_tables\nFROM information_schema.tables\nWHERE table_schema NOT IN ('information_schema', 'pg_catalog')\nGROUP BY table_schema;\n" variants: + snowflake: "SELECT\n table_schema,\n COUNT(*) AS table_count,\n SUM(CASE WHEN table_type = 'BASE TABLE' THEN 1 ELSE 0 END) AS base_tables\nFROM information_schema.tables\nWHERE table_schema NOT IN ('INFORMATION_SCHEMA')\nGROUP BY table_schema;\n" clickhouse: "EXPLAIN\nSELECT\n database AS table_schema,\n COUNT(*) AS table_count,\n countIf(engine != 'View') AS base_tables\nFROM system.tables\nWHERE database NOT IN ('system', 'INFORMATION_SCHEMA')\nGROUP BY database;\n" bigquery: "SELECT\n table_schema,\n COUNT(*) AS table_count,\n COUNTIF(table_type = 'BASE TABLE') AS base_tables\nFROM INFORMATION_SCHEMA.TABLES\nGROUP BY table_schema;\n" - id: query_list_constraints @@ -361,6 +363,7 @@ queries: - bigquery - datafusion variants: + snowflake: "SELECT\n table_name,\n constraint_name,\n constraint_type,\n '' AS column_name\nFROM information_schema.table_constraints\nWHERE table_name ILIKE 'benchbox_fk_%'\n AND constraint_type = 'FOREIGN KEY'\nORDER BY table_name, constraint_name;\n" duckdb: "SELECT\n table_name,\n constraint_text AS constraint_name,\n constraint_type,\n '' AS column_name\nFROM duckdb_constraints()\nWHERE table_name LIKE 'benchbox_fk_%'\n AND constraint_type = 'FOREIGN KEY'\nORDER BY table_name;\n" - id: constraint_pk_list category: constraint @@ -371,6 +374,7 @@ queries: - bigquery - datafusion variants: + snowflake: "SELECT\n table_name,\n constraint_name,\n '' AS column_name\nFROM information_schema.table_constraints\nWHERE table_name ILIKE 'benchbox_%'\n AND constraint_type = 'PRIMARY KEY'\nORDER BY table_name, constraint_name;\n" duckdb: "SELECT\n table_name,\n constraint_text AS constraint_name,\n '' AS column_name\nFROM duckdb_constraints()\nWHERE table_name LIKE 'benchbox_%'\n AND constraint_type = 'PRIMARY KEY'\nORDER BY table_name;\n" - id: constraint_referential_integrity category: constraint @@ -382,6 +386,8 @@ queries: - duckdb - starrocks - datafusion + variants: + snowflake: "SELECT\n tc.table_name AS child_table,\n rc.unique_constraint_name,\n ptc.table_name AS parent_table,\n '' AS fk_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.referential_constraints rc\n ON tc.constraint_name = rc.constraint_name\n AND tc.table_schema = rc.constraint_schema\nJOIN information_schema.table_constraints ptc\n ON rc.unique_constraint_name = ptc.constraint_name\n AND rc.unique_constraint_schema = ptc.table_schema\nWHERE tc.table_name ILIKE 'benchbox_fk_%'\nORDER BY child_table;\n" - id: constraint_summary category: constraint description: Summary of constraint types in benchmark tables @@ -499,6 +505,7 @@ queries: - trino - presto - firebolt + - databricks variants: redshift: "SELECT\n identity_name AS grantee,\n namespace_name AS table_schema,\n relation_name AS table_name,\n column_name,\n privilege_type\nFROM svv_column_privileges\nORDER BY relation_name, column_name, identity_name;\n" synapse: "SELECT\n dp.name AS grantee,\n SCHEMA_NAME(t.schema_id) AS table_schema,\n t.name AS table_name,\n c.name AS column_name,\n p.permission_name AS privilege_type\nFROM sys.database_permissions p\nJOIN sys.database_principals dp ON p.grantee_principal_id = dp.principal_id\nJOIN sys.columns c ON p.major_id = c.object_id AND p.minor_id = c.column_id\nJOIN sys.tables t ON c.object_id = t.object_id\nWHERE p.class = 1 AND p.minor_id > 0\nORDER BY t.name, c.name, dp.name;\n" @@ -519,6 +526,7 @@ queries: - trino - presto - starrocks + - databricks variants: duckdb: "-- DuckDB lacks role introspection; return empty result\nSELECT\n CAST(NULL AS VARCHAR) AS role_name,\n CAST(NULL AS BOOLEAN) AS is_superuser,\n CAST(NULL AS BOOLEAN) AS can_create_db,\n CAST(NULL AS BOOLEAN) AS can_create_role\nWHERE 1=0;\n" clickhouse: "SELECT\n name AS role_name,\n 0 AS is_superuser,\n 0 AS can_create_db,\n 0 AS can_create_role\nFROM system.roles\nORDER BY name;\n" @@ -545,6 +553,7 @@ queries: - firebolt - snowflake - starrocks + - databricks variants: clickhouse: "SELECT\n role_name,\n granted_role_name AS member_name,\n with_admin_option AS admin_option\nFROM system.role_grants\nORDER BY role_name, granted_role_name;\n" redshift: "SELECT\n pg_get_userbyid(roleid) AS role_name,\n pg_get_userbyid(member) AS member_name,\n admin_option\nFROM pg_auth_members\nORDER BY role_name, member_name;\n" diff --git a/benchbox/core/nyctaxi/benchmark.py b/benchbox/core/nyctaxi/benchmark.py index b4ee71c5e4..67e7bb2667 100644 --- a/benchbox/core/nyctaxi/benchmark.py +++ b/benchbox/core/nyctaxi/benchmark.py @@ -22,7 +22,12 @@ from typing import TYPE_CHECKING, Any, Union from benchbox.base import BaseBenchmark, GeneratorOutputDirMixin -from benchbox.core.nyctaxi.downloader import GreenTaxiDataDownloader, HVFHVDataDownloader, NYCTaxiDataDownloader +from benchbox.core.nyctaxi.downloader import ( + FHVDataDownloader, + GreenTaxiDataDownloader, + HVFHVDataDownloader, + NYCTaxiDataDownloader, +) from benchbox.core.nyctaxi.queries import NYCTaxiQueryManager from benchbox.core.nyctaxi.schema import ( NYC_TAXI_SCHEMA, @@ -126,7 +131,7 @@ class NYCTaxiBenchmark(GeneratorOutputDirMixin, TranslatableQueryMixin, BaseBenc AVAILABLE_YEARS = list(range(2019, 2026)) # Nested downloaders that must track output_dir reassignment. - OUTPUT_DIR_GENERATOR_ATTRS = ("downloader", "green_downloader", "hvfhv_downloader") + OUTPUT_DIR_GENERATOR_ATTRS = ("downloader", "green_downloader", "hvfhv_downloader", "fhv_downloader") def __init__( self, @@ -212,6 +217,7 @@ def __init__( # Create optional Green and HVFHV downloaders self.green_downloader: GreenTaxiDataDownloader | None = None self.hvfhv_downloader: HVFHVDataDownloader | None = None + self.fhv_downloader: FHVDataDownloader | None = None if TaxiType.GREEN in self.taxi_types: self.green_downloader = GreenTaxiDataDownloader( @@ -239,6 +245,19 @@ def __init__( **compression_kwargs, ) + if TaxiType.FHV in self.taxi_types: + self.fhv_downloader = FHVDataDownloader( + scale_factor=scale_factor, + output_dir=self.output_dir, + year=year, + months=months, + seed=seed, + verbose=verbose, + quiet=quiet, + force_redownload=force_regenerate, + **compression_kwargs, + ) + # Determine date range for queries if months: start_month = min(months) @@ -261,6 +280,7 @@ def __init__( seed=seed, include_green_queries=TaxiType.GREEN in self.taxi_types, include_hvfhv_queries=TaxiType.HVFHV in self.taxi_types, + include_fhv_queries=TaxiType.FHV in self.taxi_types, include_cross_type_queries=(TaxiType.GREEN in self.taxi_types and TaxiType.HVFHV in self.taxi_types), ) @@ -300,6 +320,12 @@ def generate_data(self) -> list[Union[str, Path]]: hvfhv_path = self.hvfhv_downloader.download() self.tables["hvfhv_trips"] = hvfhv_path + # Download FHV if requested + if self.fhv_downloader is not None: + self.log_verbose("Downloading FHV data...") + fhv_path = self.fhv_downloader.download() + self.tables["fhv_trips"] = fhv_path + self._write_manifest() return list(self.tables.values()) @@ -316,6 +342,9 @@ def _write_manifest(self) -> None: if self.hvfhv_downloader is not None: row_counts.update(self.hvfhv_downloader.get_download_stats().get("row_counts", {})) + if self.fhv_downloader is not None: + row_counts.update(self.fhv_downloader.get_download_stats().get("row_counts", {})) + manifest = DataGenerationManifest( output_dir=self.output_dir, benchmark="nyctaxi", @@ -336,6 +365,11 @@ def _write_manifest(self) -> None: if self.hvfhv_downloader is not None else {} ), + **( + {"fhv_trips": self.fhv_downloader.source_provenance()} + if self.fhv_downloader is not None + else {} + ), } }, ) @@ -401,10 +435,12 @@ def get_queries(self, dialect: str | None = None) -> dict[str, str]: from benchbox.sql_compat.context import CompatibilityContext, Phase from benchbox.sql_compat.registry import REGISTRY from benchbox.sql_compat.rules.query_source.nyctaxi_variants import ( + CLICKHOUSE_FHV_BASE_VOLUME_SQL, CLICKHOUSE_RUSH_HOUR_SQL, CLICKHOUSE_TRIP_DURATION_SQL, CLICKHOUSE_TRIPS_BY_DOW_SQL, CLICKHOUSE_WEEKDAY_WEEKEND_SQL, + STARROCKS_FHV_BASE_VOLUME_SQL, STARROCKS_RUSH_HOUR_SQL, STARROCKS_TRIP_DURATION_SQL, STARROCKS_TRIPS_BY_DOW_SQL, @@ -425,12 +461,14 @@ def get_queries(self, dialect: str | None = None) -> dict[str, str]: "weekday-weekend-comparison": STARROCKS_WEEKDAY_WEEKEND_SQL, "rush-hour-analysis": STARROCKS_RUSH_HOUR_SQL, "trip-duration-analysis": STARROCKS_TRIP_DURATION_SQL, + "fhv-base-volume": STARROCKS_FHV_BASE_VOLUME_SQL, }, "clickhouse": { "trips-by-day-of-week": CLICKHOUSE_TRIPS_BY_DOW_SQL, "weekday-weekend-comparison": CLICKHOUSE_WEEKDAY_WEEKEND_SQL, "rush-hour-analysis": CLICKHOUSE_RUSH_HOUR_SQL, "trip-duration-analysis": CLICKHOUSE_TRIP_DURATION_SQL, + "fhv-base-volume": CLICKHOUSE_FHV_BASE_VOLUME_SQL, }, } # Iterate both platforms independently - a real dialect string never matches both. @@ -552,6 +590,8 @@ def _get_active_tables(self) -> list[str]: tables.append("green_trips") if TaxiType.HVFHV in self.taxi_types: tables.append("hvfhv_trips") + if TaxiType.FHV in self.taxi_types: + tables.append("fhv_trips") return tables def get_query_info(self, query_id: str) -> dict[str, Any]: @@ -642,6 +682,8 @@ def _load_data(self, connection: DatabaseConnection) -> None: table_order.append("green_trips") if TaxiType.HVFHV in self.taxi_types: table_order.append("hvfhv_trips") + if TaxiType.FHV in self.taxi_types: + table_order.append("fhv_trips") for table_name in table_order: if table_name not in self.tables: @@ -740,7 +782,7 @@ def _load_table_data(self, connection: DatabaseConnection, table_name: str, data BenchmarkOptionSpec( name="taxi_types", parser=parse_enum_list(TaxiType), - help="Taxi data types to load (yellow,green,hvfhv)", + help="Taxi data types to load (yellow,green,hvfhv,fhv)", aliases=("taxi-types",), ), BenchmarkOptionSpec( diff --git a/benchbox/core/nyctaxi/dataframe_queries/parameters.py b/benchbox/core/nyctaxi/dataframe_queries/parameters.py index 42bac93d17..463948e38d 100644 --- a/benchbox/core/nyctaxi/dataframe_queries/parameters.py +++ b/benchbox/core/nyctaxi/dataframe_queries/parameters.py @@ -76,4 +76,20 @@ def get_parameters(query_id: str) -> NYCTaxiParameters: NYCTaxiParameters with default values for the query. """ params = NYCTAXI_DEFAULT_PARAMS.get(query_id, {}).copy() + if _parameter_overrides is not None and query_id in _parameter_overrides: + params.update(_parameter_overrides[query_id]) return NYCTaxiParameters(query_id=query_id, params=params) + + +_parameter_overrides: dict[str, dict[str, Any]] | None = None + + +def set_parameter_overrides(overrides: dict[str, dict[str, Any]] | None) -> None: + """Inject seed-derived parameters for a gate run. + + Called by the cross-surface builder before query execution so the DataFrame + surface reads the same windows the SQL surface rendered. Pass None to clear + and revert to static defaults. + """ + global _parameter_overrides + _parameter_overrides = overrides diff --git a/benchbox/core/nyctaxi/dataframe_queries/queries.py b/benchbox/core/nyctaxi/dataframe_queries/queries.py index fb0b2bdf61..543182aa44 100644 --- a/benchbox/core/nyctaxi/dataframe_queries/queries.py +++ b/benchbox/core/nyctaxi/dataframe_queries/queries.py @@ -135,7 +135,14 @@ def _expr_derive(ctx: DataFrameContext, frame: Any, names: str) -> Any: elif name == "month": frame = frame.with_columns(col("pickup_datetime").dt.truncate("1mo").alias("month")) elif name == "day_of_week": - frame = frame.with_columns(col("pickup_datetime").dt.weekday().alias("day_of_week")) + # Postgres DOW: Sunday=0..Saturday=6. Unified weekday() is ISO + # 0=Monday..6=Sunday on every backend, so DOW = (iso + 1) mod 7. + frame = frame.with_columns( + ( + (col("pickup_datetime").dt.weekday() + lit(1)) + - ((col("pickup_datetime").dt.weekday() + lit(1)) / lit(7)).floor() * lit(7) + ).alias("day_of_week") + ) elif name == "tip_pct_nonzero": frame = frame.with_columns( ctx.when(col("fare_amount") != lit(0)) @@ -170,12 +177,11 @@ def _expr_derive(ctx: DataFrameContext, frame: Any, names: str) -> Any: ((col("duration_sec") / lit(60) / lit(5)).floor() * lit(5)).alias("duration_bucket_min") ) elif name == "day_type": + dowi = col("pickup_datetime").dt.weekday() + lit(1) + dow = dowi - (dowi / lit(7)).floor() * lit(7) frame = frame.with_columns( col("pickup_datetime").dt.hour().alias("hour"), - ctx.when(col("pickup_datetime").dt.weekday().is_in([5, 6])) - .then(lit("weekend")) - .otherwise(lit("weekday")) - .alias("day_type"), + ctx.when(dow.is_in([0, 6])).then(lit("weekend")).otherwise(lit("weekday")).alias("day_type"), ) elif name == "period": frame = frame.with_columns( @@ -208,7 +214,8 @@ def _pandas_derive(frame: Any, names: str) -> Any: elif name == "month": frame["month"] = frame["pickup_datetime"].dt.to_period("M").dt.to_timestamp() elif name == "day_of_week": - frame["day_of_week"] = frame["pickup_datetime"].dt.dayofweek + # Postgres DOW: Sunday=0..Saturday=6. Pandas dayofweek: Monday=0..Sunday=6. + frame["day_of_week"] = (frame["pickup_datetime"].dt.dayofweek + 1) % 7 elif name == "tip_pct_nonzero": frame["tip_pct"] = np.where( frame["fare_amount"] != 0, frame["tip_amount"] / frame["fare_amount"] * 100, np.nan @@ -232,7 +239,9 @@ def _pandas_derive(frame: Any, names: str) -> Any: frame["duration_bucket_min"] = np.floor(frame["duration_sec"] / 60 / 5) * 5 elif name == "day_type": frame["hour"] = frame["pickup_datetime"].dt.hour - frame["day_type"] = np.where(frame["pickup_datetime"].dt.dayofweek.isin([5, 6]), "weekend", "weekday") + frame["day_type"] = np.where( + ((frame["pickup_datetime"].dt.dayofweek + 1) % 7).isin([0, 6]), "weekend", "weekday" + ) elif name == "period": frame["hour"] = frame["pickup_datetime"].dt.hour frame["duration_min"] = (frame["dropoff_datetime"] - frame["pickup_datetime"]).dt.total_seconds() / 60 diff --git a/benchbox/core/nyctaxi/downloader.py b/benchbox/core/nyctaxi/downloader.py index 831211a19e..eb9cd5cb9e 100644 --- a/benchbox/core/nyctaxi/downloader.py +++ b/benchbox/core/nyctaxi/downloader.py @@ -29,7 +29,12 @@ import yaml from benchbox.core.data_fetch.errors import ChecksumMismatchError -from benchbox.core.nyctaxi.schema import get_green_trips_columns, get_hvfhv_trips_columns, get_trips_columns +from benchbox.core.nyctaxi.schema import ( + get_fhv_trips_columns, + get_green_trips_columns, + get_hvfhv_trips_columns, + get_trips_columns, +) from benchbox.utils.compression_mixin import CompressionMixin from benchbox.utils.verbosity import VerbosityMixin, compute_verbosity @@ -339,20 +344,33 @@ def _clean_csv_value(value: Any, default: Any) -> Any: return int(value) return value + @staticmethod + def _normalize_sr_flag(value: Any) -> str: + """Map TLC's numeric shared-ride flag to the stored Y/N convention. + + Real TLC FHV Parquet encodes SR_Flag as 1 (shared) or null; the + synthetic generator emits Y/N directly. Both normalize to Y/N so the + fhv-shared-ride-rate query counts remote and synthetic data alike. + """ + if value is None: + return "N" + if isinstance(value, float) and value != value: # NaN + return "N" + text = str(value).strip().lower() + return "Y" if text in ("1", "1.0", "y", "yes", "true") else "N" + def _map_row_to_schema(self, row, trip_id: int) -> list: columns = type(self)._COLUMN_PROVIDER() - return [ - trip_id, - *[ - self._clean_csv_value( - self._get_col( - row, self._COLUMN_ALIASES.get(column, (column,)), self._COLUMN_DEFAULTS.get(column, 0) - ), - self._COLUMN_DEFAULTS.get(column, 0), - ) - for column in columns - ], - ] + mapped = [trip_id] + for column in columns: + value = self._clean_csv_value( + self._get_col(row, self._COLUMN_ALIASES.get(column, (column,)), self._COLUMN_DEFAULTS.get(column, 0)), + self._COLUMN_DEFAULTS.get(column, 0), + ) + if column == "sr_flag": + value = self._normalize_sr_flag(value) + mapped.append(value) + return mapped def _generate_synthetic_month(self, writer: csv.writer, start_trip_id: int) -> int: num_trips = max(self._SYNTHETIC_MIN_TRIPS, int(self._SYNTHETIC_MONTHLY_TRIPS * self.sample_rate * 100)) @@ -695,3 +713,78 @@ def _generate_synthetic_month(self, writer: csv.writer, start_trip_id: int) -> i ) return num_trips + + +class FHVDataDownloader(_TripDataDownloader): + """Downloads and processes NYC TLC For-Hire Vehicle (FHV) trip data. + + FHV covers non-high-volume for-hire bases (black cars, liveries, limos). + The simplest TLC schema (~1M trips/month) and no fare columns. + Source: fhv_tripdata_YYYY-MM.parquet + Coverage: 2015-present + """ + + _TABLE_NAME = "fhv_trips" + _OUTPUT_FILENAME = "fhv_trips.csv" + _URL_PREFIX = "fhv_tripdata" + _LOGGER_NAME = "benchbox.core.nyctaxi.downloader.fhv" + _TAXI_LABEL = "FHV" + _DOWNLOAD_LOG_LABEL = "FHV" + _SKIP_EXISTING_MESSAGE = "FHV trips data already exists, skipping" + _COLUMN_PROVIDER = get_fhv_trips_columns + _STATS_TAXI_TYPE = "fhv" + _COLUMN_ALIASES = { + "dispatching_base_num": ("dispatching_base_num", "dispatching_base_number", "Dispatching_base_num"), + "affiliated_base_number": ("Affiliated_base_number", "affiliated_base_num"), + "pickup_datetime": ("pickup_datetime", "Pickup_datetime"), + "dropoff_datetime": ("dropOff_datetime", "dropoff_datetime", "Dropoff_datetime"), + "pickup_location_id": ("PUlocationID", "PULocationID", "pulocationid"), + "dropoff_location_id": ("DOlocationID", "DOLocationID", "dolocationid"), + "sr_flag": ("SR_Flag", "sr_flag"), + } + _COLUMN_DEFAULTS = { + "dispatching_base_num": "", + "affiliated_base_number": "", + "pickup_datetime": "", + "dropoff_datetime": "", + "sr_flag": "N", + } + _SYNTHETIC_MONTHLY_TRIPS = 1000 + _SYNTHETIC_MIN_TRIPS = 25 + _SYNTHETIC_HOURS = (8, 9, 10, 17, 18, 19, 20, 21, 22) + _SYNTHETIC_DURATION_OFFSET = 12 + _SYNTHETIC_DURATION_SCALE = 18.0 + _SYNTHETIC_DISTANCE_SCALE = 4.0 + + def _generate_synthetic_month(self, writer: csv.writer, start_trip_id: int) -> int: + """Generate synthetic FHV trip data (dispatched-car patterns). + + FHV trips are pre-arranged dispatches: longer waits and durations + than street hails, no metered fare columns. Shared rides are rare + (pre-2020 SR flag). + """ + num_trips = max(self._SYNTHETIC_MIN_TRIPS, int(self._SYNTHETIC_MONTHLY_TRIPS * self.sample_rate * 100)) + all_zones = list(range(1, 263)) + for i in range(num_trips): + trip_id = start_trip_id + i + hour = int(self.rng.choice(self._SYNTHETIC_HOURS)) + minute = int(self.rng.integers(0, 60)) + day = int(self.rng.integers(1, 29)) + month = int(self.rng.choice(self.months)) + pickup_time = datetime(self.year, month, day, hour, minute) + duration_min = int(self._SYNTHETIC_DURATION_OFFSET + self.rng.exponential(self._SYNTHETIC_DURATION_SCALE)) + dropoff_time = pickup_time + timedelta(minutes=duration_min) + base_num = f"B{int(self.rng.integers(100000, 999999))}" + writer.writerow( + [ + trip_id, + base_num, + base_num, + pickup_time.strftime("%Y-%m-%d %H:%M:%S"), + dropoff_time.strftime("%Y-%m-%d %H:%M:%S"), + int(self.rng.choice(all_zones)), + int(self.rng.choice(all_zones)), + "Y" if self.rng.random() > 0.95 else "N", + ] + ) + return num_trips diff --git a/benchbox/core/nyctaxi/queries.py b/benchbox/core/nyctaxi/queries.py index ad459ea915..230e814b05 100644 --- a/benchbox/core/nyctaxi/queries.py +++ b/benchbox/core/nyctaxi/queries.py @@ -28,6 +28,7 @@ QUERIES: dict[str, dict[str, Any]] = _CATALOG["QUERIES"] GREEN_QUERIES: dict[str, dict[str, Any]] = _CATALOG["GREEN_QUERIES"] HVFHV_QUERIES: dict[str, dict[str, Any]] = _CATALOG["HVFHV_QUERIES"] +FHV_QUERIES: dict[str, dict[str, Any]] = _CATALOG["FHV_QUERIES"] CROSS_TYPE_QUERIES: dict[str, dict[str, Any]] = _CATALOG["CROSS_TYPE_QUERIES"] @@ -46,6 +47,7 @@ def __init__( seed: Optional[int] = None, include_green_queries: bool = False, include_hvfhv_queries: bool = False, + include_fhv_queries: bool = False, include_cross_type_queries: bool = False, ) -> None: """Initialize query manager. @@ -56,6 +58,7 @@ def __init__( seed: Random seed for parameter generation include_green_queries: Include Green Taxi-specific queries include_hvfhv_queries: Include HVFHV-specific queries + include_fhv_queries: Include FHV-specific queries include_cross_type_queries: Include cross-type comparison queries """ self.start_date = start_date or datetime(2019, 1, 1) @@ -71,6 +74,8 @@ def __init__( self._active_queries.update(GREEN_QUERIES) if include_hvfhv_queries: self._active_queries.update(HVFHV_QUERIES) + if include_fhv_queries: + self._active_queries.update(FHV_QUERIES) if include_cross_type_queries: self._active_queries.update(CROSS_TYPE_QUERIES) diff --git a/benchbox/core/nyctaxi/query_catalog.yaml b/benchbox/core/nyctaxi/query_catalog.yaml index a52a7b41f0..b745ade54d 100644 --- a/benchbox/core/nyctaxi/query_catalog.yaml +++ b/benchbox/core/nyctaxi/query_catalog.yaml @@ -668,6 +668,67 @@ HVFHV_QUERIES: ORDER BY hvfhs_license_num, hour params: duration_days: 30 +FHV_QUERIES: + fhv-base-volume: + id: F1 + name: FHV Trips by Dispatching Base + description: Trip volume by dispatching base for non-high-volume FHV bases + category: fhv-dispatch + sql: | + SELECT + dispatching_base_num, + COUNT(*) as trip_count, + AVG(EXTRACT(EPOCH FROM (dropoff_datetime - pickup_datetime)) / 60) as avg_duration_min + FROM fhv_trips + WHERE pickup_datetime >= '{start_date}' + AND pickup_datetime < '{end_date}' + AND dropoff_datetime > pickup_datetime + GROUP BY dispatching_base_num + ORDER BY trip_count DESC + LIMIT 25 + params: + duration_days: 90 + fhv-shared-ride-rate: + id: F2 + name: FHV Shared Ride Rate + description: Shared-ride flag rate over time (pre-2020 shared FHV rides) + category: fhv-service + sql: | + SELECT + DATE_TRUNC('month', pickup_datetime) as month, + COUNT(*) as trip_count, + SUM(CASE WHEN sr_flag = 'Y' THEN 1 ELSE 0 END) as shared_trips, + ROUND( + SUM(CASE WHEN sr_flag = 'Y' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2 + ) as shared_pct + FROM fhv_trips + WHERE pickup_datetime >= '{start_date}' + AND pickup_datetime < '{end_date}' + GROUP BY DATE_TRUNC('month', pickup_datetime) + ORDER BY month + params: + duration_days: 90 + fhv-top-zones: + id: F3 + name: FHV Top Pickup Zones + description: Most popular FHV pickup zones with dropoff spread + category: fhv-geographic + sql: | + SELECT + f.pickup_location_id, + z.zone, + z.borough, + COUNT(*) as trip_count, + COUNT(DISTINCT f.dropoff_location_id) as distinct_dropoffs + FROM fhv_trips f + LEFT JOIN taxi_zones z ON f.pickup_location_id = z.location_id + WHERE f.pickup_datetime >= '{start_date}' + AND f.pickup_datetime < '{end_date}' + GROUP BY f.pickup_location_id, z.zone, z.borough + ORDER BY trip_count DESC + LIMIT 25 + params: + duration_days: 30 CROSS_TYPE_QUERIES: cross-market-share: id: X1 diff --git a/benchbox/core/nyctaxi/schema.py b/benchbox/core/nyctaxi/schema.py index f26ca72aea..61f7de8f6c 100644 --- a/benchbox/core/nyctaxi/schema.py +++ b/benchbox/core/nyctaxi/schema.py @@ -15,6 +15,7 @@ class TaxiType(Enum): YELLOW = "yellow" GREEN = "green" HVFHV = "hvfhv" + FHV = "fhv" def _load_schema_specs() -> dict[str, Any]: @@ -57,6 +58,8 @@ def get_create_tables_sql( tables_to_create.append("green_trips") elif taxi_type == TaxiType.HVFHV: tables_to_create.append("hvfhv_trips") + elif taxi_type == TaxiType.FHV: + tables_to_create.append("fhv_trips") statements = [] for table_name in TABLE_ORDER: @@ -185,3 +188,10 @@ def get_hvfhv_trips_columns() -> list[str]: columns = list(NYC_TAXI_SCHEMA["hvfhv_trips"]["columns"].keys()) columns.remove("trip_id") return columns + + +def get_fhv_trips_columns() -> list[str]: + """Get column names for the fhv_trips table (excluding auto-generated trip_id).""" + columns = list(NYC_TAXI_SCHEMA["fhv_trips"]["columns"].keys()) + columns.remove("trip_id") + return columns diff --git a/benchbox/core/nyctaxi/schema_specs.yaml b/benchbox/core/nyctaxi/schema_specs.yaml index 0bcbb3a441..6182df1989 100644 --- a/benchbox/core/nyctaxi/schema_specs.yaml +++ b/benchbox/core/nyctaxi/schema_specs.yaml @@ -257,11 +257,50 @@ schema: - pickup_location_id - dropoff_location_id - hvfhs_license_num + fhv_trips: + description: NYC For-Hire Vehicle trips (non-high-volume bases) - simplest + TLC schema, ~1M trips/month + columns: + trip_id: + type: BIGINT + description: Unique trip identifier + dispatching_base_num: + type: VARCHAR(8) + description: TLC base license number that dispatched the trip + affiliated_base_number: + type: VARCHAR(8) + description: Base affiliated with the vehicle + pickup_datetime: + type: TIMESTAMP + description: Pickup timestamp + dropoff_datetime: + type: TIMESTAMP + description: Dropoff timestamp + pickup_location_id: + type: INTEGER + description: TLC Taxi Zone for pickup + dropoff_location_id: + type: INTEGER + description: TLC Taxi Zone for dropoff + sr_flag: + type: VARCHAR(1) + description: Shared-ride flag (Y/N, pre-2020 shared rides) + primary_key: + - trip_id + partition_by: pickup_datetime + order_by: + - pickup_datetime + - pickup_location_id + indexes: + - pickup_datetime + - pickup_location_id + - dropoff_location_id table_order: - taxi_zones - trips - green_trips - hvfhv_trips +- fhv_trips rate_codes: 1: Standard rate 2: JFK diff --git a/benchbox/core/platform_manifest.py b/benchbox/core/platform_manifest.py index 43c65b339b..00be148c38 100644 --- a/benchbox/core/platform_manifest.py +++ b/benchbox/core/platform_manifest.py @@ -1636,6 +1636,116 @@ def _load_manifest(payload: str) -> tuple[PlatformManifestEntry, ...]: }, "support_status": "experimental" }, + { + "key": "paradedb", + "aliases": [], + "adapter": { + "module": "benchbox.platforms.paradedb", + "class_name": "ParadeDBAdapter", + "registration_order": 47, + "stream_connection_capability": "independent_connection" + }, + "display_name": "paradedb", + "description": "Hybrid search + analytics PostgreSQL • BM25 via pg_analytics", + "category": "olap", + "libraries": [ + { + "name": "psycopg", + "required": true + } + ], + "requirements": [ + "psycopg[binary]>=3.1" + ], + "installation_command": "uv add 'psycopg[binary]'", + "adoption": "emerging", + "supports": [ + "olap", + "analytics" + ], + "driver_package": "psycopg", + "notes": "PostgreSQL extension adding Elasticsearch-compatible BM25 search and analytics over heap tables. Requires pg_analytics on server. No known extension conflicts.", + "capabilities": { + "supports_sql": true, + "supports_dataframe": false, + "default_mode": "sql", + "platform_family": "paradedb", + "default_deployment": "self-hosted", + "deployment_modes": { + "self-hosted": { + "mode": "self-hosted", + "display_name": "ParadeDB Self-Hosted", + "description": "Self-hosted PostgreSQL with pg_analytics extension", + "requires_credentials": true, + "requires_cloud_storage": false, + "requires_network": true, + "default_for_platform": true, + "dependencies": [ + "psycopg[binary]" + ], + "auth_methods": [ + "password" + ] + } + } + }, + "support_status": "experimental" + }, + { + "key": "citus", + "aliases": [], + "adapter": { + "module": "benchbox.platforms.citus", + "class_name": "CitusAdapter", + "registration_order": 48, + "stream_connection_capability": "independent_connection" + }, + "display_name": "citus", + "description": "Distributed PostgreSQL • Sharded tables via citus", + "category": "olap", + "libraries": [ + { + "name": "psycopg", + "required": true + } + ], + "requirements": [ + "psycopg[binary]>=3.1" + ], + "installation_command": "uv add 'psycopg[binary]'", + "adoption": "emerging", + "supports": [ + "olap", + "analytics" + ], + "driver_package": "psycopg", + "notes": "PostgreSQL extension distributing tables across coordinator and workers. Requires citus on server. Tables stay coordinator-local unless distribution_column is set. No known extension conflicts.", + "capabilities": { + "supports_sql": true, + "supports_dataframe": false, + "default_mode": "sql", + "platform_family": "citus", + "default_deployment": "self-hosted", + "deployment_modes": { + "self-hosted": { + "mode": "self-hosted", + "display_name": "Citus Self-Hosted", + "description": "Self-hosted Citus coordinator with citus extension", + "requires_credentials": true, + "requires_cloud_storage": false, + "requires_network": true, + "default_for_platform": true, + "dependencies": [ + "psycopg[binary]" + ], + "auth_methods": [ + "password" + ] + } + } + }, + "support_status": "experimental" + }, { "key": "synapse", "aliases": [ diff --git a/benchbox/core/platform_registry.py b/benchbox/core/platform_registry.py index 9d85c9d8b5..9551fb9233 100644 --- a/benchbox/core/platform_registry.py +++ b/benchbox/core/platform_registry.py @@ -11,6 +11,7 @@ import argparse import importlib +import os from collections import Counter from collections.abc import Iterable from copy import deepcopy @@ -328,6 +329,8 @@ def _detect_library(cls, lib_spec: dict[str, Any]) -> LibraryInfo: lib_name = lib_spec["name"] import_name = lib_spec.get("import_name", lib_name) + # Optional native modules can change cwd before failing to load. + original_cwd = os.getcwd() try: module = importlib.import_module(import_name) version = getattr(module, "__version__", None) @@ -347,6 +350,8 @@ def _detect_library(cls, lib_spec: dict[str, Any]) -> LibraryInfo: return LibraryInfo(name=lib_name, version=version, installed=True) except (ImportError, OSError) as e: return LibraryInfo(name=lib_name, version=None, installed=False, import_error=str(e)) + finally: + os.chdir(original_cwd) @staticmethod def _extract_requirement_package(requirement: str) -> Optional[str]: diff --git a/benchbox/core/primitives_benchmark_utils.py b/benchbox/core/primitives_benchmark_utils.py index 7898ab2f12..ac7abf7145 100644 --- a/benchbox/core/primitives_benchmark_utils.py +++ b/benchbox/core/primitives_benchmark_utils.py @@ -40,15 +40,118 @@ def quote_identifier(identifier: str) -> str: return f'"{escaped}"' +#: Dialects whose catalog tables are created in UPPERCASE, so a quoted +#: identifier must be uppercased to resolve: Snowflake folds unquoted names +#: to upper (a quoted lowercase name is a different table), and BigQuery is +#: case-sensitive with adapter-created tables uppercased by convention. +UPPERCASE_IDENTIFIER_DIALECTS = frozenset({"snowflake", "bigquery"}) + + +def quote_identifier_for_dialect(identifier: str, dialect: str | None) -> str: + """Quote an identifier with the case and quoting the dialect's catalog uses. + + BigQuery uses backticks: double quotes denote string literals there, so a + double-quoted table name is a syntax error, not an identifier. Databricks + likewise rejects double-quoted identifiers with PARSE_SYNTAX_ERROR on + warehouses without ANSI mode (verified live), so it uses backticks too. + + Args: + identifier: Table, column, or schema name (source-case, usually lower) + dialect: Platform dialect key (e.g. 'snowflake', 'duckdb'); None or + 'standard' keeps source case + """ + normalized = (dialect or "standard").lower() + if normalized == "bigquery": + # Validate first for the same injection guard as quote_identifier. + quote_identifier(identifier) + return f"`{identifier.upper()}`" + if normalized in ("databricks", "starrocks"): + quote_identifier(identifier) + escaped = identifier.replace("`", "``") + return f"`{escaped}`" + if normalized in UPPERCASE_IDENTIFIER_DIALECTS: + return quote_identifier(identifier.upper()) + return quote_identifier(identifier) + + +#: Dialects whose staging rebuild replaces tables in place instead of dropping +#: them. Unity Catalog counts a dropped table against the metastore table quota +#: for about seven days; ``CREATE OR REPLACE`` on an existing table does not. +_REPLACE_IN_PLACE_DIALECTS = frozenset({"databricks"}) + + +def replaces_tables_in_place(dialect: str | None) -> bool: + """Return True when staging rebuilds should replace tables, not drop them.""" + return (dialect or "").lower() in _REPLACE_IN_PLACE_DIALECTS + + +def replace_table_sql(create_sql: str) -> str: + """Turn a plain ``CREATE TABLE`` statement into ``CREATE OR REPLACE TABLE``.""" + head, sep, rest = create_sql.partition("CREATE TABLE") + if not sep or "IF NOT EXISTS" in rest.split("(", 1)[0].upper(): + raise ValueError("replace_table_sql expects a plain CREATE TABLE statement") + return f"{head}CREATE OR REPLACE TABLE{rest}" + + +def failed_platform_error(cursor: Any) -> str | None: + """Return the adapter-reported error if ``cursor`` wraps a FAILED result. + + Several platform adapters report query failures as a FAILED result + payload rather than raising. Reading such a cursor as success corrupts + coverage: a failed write looks executed, and a failed validation SELECT + materializes placeholder rows that can satisfy vacuous COUNT(*) checks. + Callers must fail loud instead. Plain DB-API cursors (embedded engines) + carry no ``platform_result`` and always return None here. + + Args: + cursor: Cursor (or cursor-like) returned by ``connection.execute``. + + Returns: + The adapter-reported error string, or None when the result is not a + reported failure. + """ + platform_result = getattr(cursor, "platform_result", None) + if isinstance(platform_result, dict) and platform_result.get("status") == "FAILED": + return str(platform_result.get("error", "unknown error")) + if isinstance(cursor, dict) and cursor.get("status") == "FAILED": + return str(cursor.get("error", "unknown error")) + return None + + +def fetch_count_probe(connection: DatabaseConnection, sql: str) -> int: + """Run a SELECT COUNT(*) probe and return the count. + + Platform adapters report query failures as a FAILED result payload + rather than raising, which surfaces here as an empty row set. A failed + probe must raise (fail loud) instead of reading as "0 rows", which would + misreport a broken query as an empty table. + """ + cursor = connection.execute(sql) + if (probe_error := failed_platform_error(cursor)) is not None: + raise RuntimeError(f"Count probe failed: {probe_error}") + row = cursor.fetchone() + return row[0] if row else 0 + + def table_exists( connection: DatabaseConnection, table_name: str, log_verbose: Callable[[str], None], + dialect: str | None = None, ) -> bool: """Check whether a table exists without requiring information schema access.""" try: - quoted_table = quote_identifier(table_name) - connection.execute(f"SELECT 1 FROM {quoted_table} LIMIT 0") + quoted_table = quote_identifier_for_dialect(table_name, dialect) + cursor = connection.execute(f"SELECT 1 FROM {quoted_table} LIMIT 0") + if (error := failed_platform_error(cursor)) is not None: + error_msg = error.lower() + if any( + phrase in error_msg + for phrase in ["does not exist", "doesn't exist", "no such table", "unknown table", "not found"] + ): + return False + log_verbose(f"Unexpected error checking table '{table_name}': {error}") + return False return True except ValueError as exc: log_verbose(f"Invalid table name '{table_name}': {exc}") diff --git a/benchbox/core/query_catalog.py b/benchbox/core/query_catalog.py index 6d64b56569..dc6668fee7 100644 --- a/benchbox/core/query_catalog.py +++ b/benchbox/core/query_catalog.py @@ -244,8 +244,8 @@ def _benchmark_supports_dialect(bm: Any, dialect: str | None) -> bool: except Exception: pass # Otherwise check for translation capability (translate_query_text or catalog variants). - # Benchmarks without this capability (e.g. TSBS DevOps, NYC Taxi, DataVault) silently ignore - # dialect in get_queries / get_query and return native SQL. + # Benchmarks without this capability silently ignore dialect in + # get_queries / get_query and return native SQL. for obj in objs: if hasattr(obj, "translate_query_text") or hasattr(obj, "has_dialect_variant"): return True diff --git a/benchbox/core/query_catalog_base.py b/benchbox/core/query_catalog_base.py index 0e9afb8cf1..1286a71ca0 100644 --- a/benchbox/core/query_catalog_base.py +++ b/benchbox/core/query_catalog_base.py @@ -151,6 +151,12 @@ def has_variant(self, query_id: str, dialect: str) -> bool: # --------------------------------------------------------------------------- +#: Cloud dialects whose engines reject DuckDB-only syntax (``SUBSTRING(x FROM a +#: FOR b)``, ``INTERVAL '90 days'``, ``DATE_TRUNC('minute', ts)`` on BigQuery), +#: so benchmarks written in DuckDB SQL must be translated for them. +CLOUD_TRANSLATED_DIALECTS: tuple[str, ...] = ("bigquery", "snowflake", "databricks", "spark") + + class TranslatableQueryMixin: """Mixin that provides a shared ``translate_query_text`` method for benchmark classes whose queries originate in a common source dialect. @@ -165,6 +171,23 @@ class TranslatableQueryMixin: _source_dialect: str = "netezza" + #: Dialect substrings that :meth:`translate_for_dialect` translates to. + #: ``None`` translates every requested dialect. + _translated_dialects: tuple[str, ...] | None = None + + def translate_for_dialect(self, query_text: str, dialect: str | None) -> str: + """Translate *query_text* when *dialect* is one this benchmark translates to. + + Returns the source text unchanged when *dialect* is empty or is not + listed in ``_translated_dialects``. + """ + if not dialect: + return query_text + d = dialect.lower() + if self._translated_dialects is not None and not any(p in d for p in self._translated_dialects): + return query_text + return self.translate_query_text(query_text, dialect) + def translate_query_text(self, query_text: str, target_dialect: str) -> str: """Translate a query from the benchmark's source dialect to *target_dialect*. @@ -186,6 +209,7 @@ def translate_query_text(self, query_text: str, target_dialect: str) -> str: __all__ = [ + "CLOUD_TRANSLATED_DIALECTS", "BaseQueryCatalogMixin", "CatalogEntry", "QuerySkippedError", diff --git a/benchbox/core/read_primitives/catalog/queries.yaml b/benchbox/core/read_primitives/catalog/queries.yaml index e5b477e2d1..80dcaf8a4d 100644 --- a/benchbox/core/read_primitives/catalog/queries.yaml +++ b/benchbox/core/read_primitives/catalog/queries.yaml @@ -106,21 +106,21 @@ queries: - {"id": "window_lead_lag_same_frame", "category": "window", "result_contract": {"capability": "window_offset_functions", "row_identity": ["o_orderkey"], "columns": ["o_orderkey", "o_orderdate", "o_totalprice", "prev_order_price", "next_order_price"]}, "sql": "\n-- Offset window functions over the same frame with 'natural' ordering\n-- o_orderkey is the deterministic tie-break within the window ORDER BY so LAG/LEAD\n-- are well-defined across engines when a customer has multiple orders on one date.\nSELECT\n o_orderkey,\n o_orderdate,\n o_totalprice,\n LAG(o_totalprice, 1) OVER (\n PARTITION BY o_custkey\n ORDER BY o_orderdate, o_orderkey\n ) as prev_order_price,\n LEAD(o_totalprice, 1) OVER (\n PARTITION BY o_custkey\n ORDER BY o_orderdate, o_orderkey\n ) as next_order_price\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1996-01-01';\n", "variants": {"clickhouse": "\n-- ClickHouse requires lowercase window-function identifiers. Keep the\n-- deterministic composite ordering identical to the canonical query.\nSELECT\n o_orderkey,\n o_orderdate,\n o_totalprice,\n lag(o_totalprice, 1) OVER (\n PARTITION BY o_custkey\n ORDER BY o_orderdate, o_orderkey\n ) as prev_order_price,\n lead(o_totalprice, 1) OVER (\n PARTITION BY o_custkey\n ORDER BY o_orderdate, o_orderkey\n ) as next_order_price\nFROM orders\nWHERE o_orderdate >= toDate('1995-01-01')\n AND o_orderdate < toDate('1996-01-01');\n"}} - {"id": "window_row_number", "category": "window", "sql": "\n-- Row number calculation window function with different ordering\nSELECT\n o_custkey,\n o_orderkey,\n o_totalprice,\n ROW_NUMBER() OVER (\n PARTITION BY o_custkey\n ORDER BY o_totalprice DESC\n ) as order_rank\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1996-01-01';\n"} - {"id": "window_multiple_orderings", "category": "window", "sql": "\n-- Ranking window aggregations with multiple frame orderings\nSELECT\n l_orderkey,\n l_partkey,\n l_quantity,\n l_extendedprice,\n DENSE_RANK() OVER (PARTITION BY l_orderkey ORDER BY l_extendedprice DESC) as price_rank,\n PERCENT_RANK() OVER (PARTITION BY l_orderkey ORDER BY l_quantity) as quantity_percentile,\n CUME_DIST() OVER (PARTITION BY l_orderkey ORDER BY l_extendedprice) as price_distribution\nFROM lineitem\nWHERE l_orderkey <= 10000\nORDER BY l_orderkey, price_rank;\n", "result_contract": {"capability": "window_rank_distribution", "columns": ["l_orderkey", "l_partkey", "l_quantity", "l_extendedprice", "price_rank", "quantity_percentile", "price_distribution"]}, "skip_on": ["clickhouse"], "variants": {"clickhouse": "\n-- ClickHouse 25.8 has no cume_dist implementation; keep this cell explicitly unsupported.\nSELECT *\nFROM (\n SELECT\n l_orderkey,\n l_partkey,\n l_quantity,\n l_extendedprice,\n dense_rank() OVER (PARTITION BY l_orderkey ORDER BY l_extendedprice DESC) as price_rank,\n percent_rank() OVER (PARTITION BY l_orderkey ORDER BY l_quantity) as quantity_percentile,\n cume_dist() OVER (PARTITION BY l_orderkey ORDER BY l_extendedprice) as price_distribution\n FROM lineitem\n WHERE l_orderkey <= 10000\n) t\nORDER BY l_orderkey, price_rank;\n"}} -- {"id": "window_moving_frame", "category": "window", "sql": "\n-- Window aggregations with complex moving frame definitions\n-- The ROWS frame is order-sensitive, so o_orderkey is the deterministic tie-break\n-- in its window ORDER BY (and the outer ORDER BY) when several orders share a\n-- date. The RANGE frame keeps a single ordering column (INTERVAL requires it) and\n-- is order-insensitive by construction.\nSELECT\n o_orderkey,\n o_orderdate,\n o_totalprice,\n -- Moving average with preceding rows\n AVG(o_totalprice) OVER (\n ORDER BY o_orderdate, o_orderkey\n ROWS BETWEEN 5 PRECEDING AND CURRENT ROW\n ) as moving_avg_6_orders,\n -- Running total with range-based frame\n SUM(o_totalprice) OVER (\n ORDER BY o_orderdate\n RANGE BETWEEN INTERVAL '30' DAY PRECEDING AND CURRENT ROW\n ) as monthly_running_total\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1996-01-01'\nORDER BY o_orderdate, o_orderkey;\n", "skip_on": ["clickhouse", "redshift"]} +- {"id": "window_moving_frame", "category": "window", "sql": "\n-- Window aggregations with complex moving frame definitions\n-- The ROWS frame is order-sensitive, so o_orderkey is the deterministic tie-break\n-- in its window ORDER BY (and the outer ORDER BY) when several orders share a\n-- date. The RANGE frame keeps a single ordering column (INTERVAL requires it) and\n-- is order-insensitive by construction.\nSELECT\n o_orderkey,\n o_orderdate,\n o_totalprice,\n -- Moving average with preceding rows\n AVG(o_totalprice) OVER (\n ORDER BY o_orderdate, o_orderkey\n ROWS BETWEEN 5 PRECEDING AND CURRENT ROW\n ) as moving_avg_6_orders,\n -- Running total with range-based frame\n SUM(o_totalprice) OVER (\n ORDER BY o_orderdate\n RANGE BETWEEN INTERVAL '30' DAY PRECEDING AND CURRENT ROW\n ) as monthly_running_total\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1996-01-01'\nORDER BY o_orderdate, o_orderkey;\n", "skip_on": ["clickhouse", "redshift"], "variants": {"bigquery": "SELECT o_orderkey, o_orderdate, o_totalprice,\n AVG(o_totalprice) OVER (\n ORDER BY o_orderdate, o_orderkey\n ROWS BETWEEN 5 PRECEDING AND CURRENT ROW\n ) AS moving_avg_6_orders,\n SUM(o_totalprice) OVER (\n ORDER BY UNIX_DATE(o_orderdate)\n RANGE BETWEEN 30 PRECEDING AND CURRENT ROW\n ) AS monthly_running_total\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1996-01-01'\nORDER BY o_orderdate, o_orderkey;"}, "result_contract": {"capability": "moving_row_and_date_range_windows", "row_identity": ["o_orderkey"], "columns": ["o_orderkey", "o_orderdate", "o_totalprice", "moving_avg_6_orders", "monthly_running_total"]}} - {"id": "window_unbounded_frame", "category": "window", "sql": "\n-- Window aggregations with the same unbounded frame definition\nSELECT\n l_orderkey,\n l_linenumber,\n l_shipdate,\n l_quantity,\n FIRST_VALUE(l_quantity) OVER (\n PARTITION BY l_orderkey\n ORDER BY l_linenumber\n ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING\n ) as first_line_qty,\n LAST_VALUE(l_quantity) OVER (\n PARTITION BY l_orderkey\n ORDER BY l_linenumber\n ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING\n ) as last_line_qty\nFROM lineitem\nWHERE l_orderkey BETWEEN 1 AND 5000;\n"} -- {"id": "json_extract_simple", "category": "json", "sql": "\n-- Extract from JSON with simple path expressions\nSELECT\n o_orderkey,\n JSON_EXTRACT(o_comment, '$.priority') as order_priority,\n JSON_EXTRACT(o_comment, '$.notes') as order_notes\nFROM orders\nWHERE JSON_EXTRACT(o_comment, '$.priority') IS NOT NULL\nLIMIT 1000;\n", "skip_on": ["duckdb", "datafusion", "redshift"], "description": "TPC-H o_comment contains plain text, not valid JSON"} -- {"id": "json_extract_nested", "category": "json", "sql": "\n-- Extract from JSON with complex path expressions\nSELECT\n c_custkey,\n JSON_EXTRACT(c_comment, '$.profile.segment') as customer_segment,\n JSON_EXTRACT(c_comment, '$.profile.preferences[0]') as primary_preference,\n JSON_EXTRACT(c_comment, '$.history.last_order.date') as last_order_date\nFROM customer\nWHERE JSON_VALID(c_comment)\nLIMIT 500;\n", "result_contract": {"capability": "json_nested_path_extraction", "row_identity": ["c_custkey"], "columns": ["c_custkey", {"name": "customer_segment", "type_class": "json"}, {"name": "primary_preference", "type_class": "json"}, {"name": "last_order_date", "type_class": "json"}]}, "variants": {"clickhouse": "\n-- ClickHouse JSON access uses JSONExtract*(json, path...) with positional\n-- path components; isValidJSON replaces JSON_VALID. TPC-H c_comment is\n-- plain text so isValidJSON gates rows out (matches duckdb behavior).\nSELECT\n c_custkey,\n JSONExtractRaw(c_comment, 'profile', 'segment') as customer_segment,\n JSONExtractRaw(c_comment, 'profile', 'preferences', 1) as primary_preference,\n JSONExtractRaw(c_comment, 'history', 'last_order', 'date') as last_order_date\nFROM customer\nWHERE isValidJSON(c_comment)\nLIMIT 500;\n"}, "skip_on": ["datafusion", "redshift"]} -- {"id": "json_aggregates", "category": "json", "sql": "\n-- Create JSON array and object with JSON aggregate functions\nSELECT\n p_brand,\n JSON_ARRAYAGG(p_name) as part_names,\n JSON_OBJECTAGG(p_partkey, p_retailprice) as part_prices,\n COUNT(*) as part_count\nFROM part\nWHERE p_type LIKE '%STEEL%'\nGROUP BY p_brand\nORDER BY p_brand\nLIMIT 100;\n", "result_contract": {"capability": "json_array_object_aggregation", "row_identity": ["p_brand"], "columns": ["p_brand", {"name": "part_names", "type_class": "json"}, {"name": "part_prices", "type_class": "json"}, "part_count"]}, "variants": {"duckdb": "\n-- DuckDB uses JSON_GROUP_ARRAY and JSON_GROUP_OBJECT instead\nSELECT\n p_brand,\n JSON_GROUP_ARRAY(p_name) as part_names,\n JSON_GROUP_OBJECT(p_partkey, p_retailprice) as part_prices,\n COUNT(*) as part_count\nFROM part\nWHERE p_type LIKE '%STEEL%'\nGROUP BY p_brand\nORDER BY p_brand\nLIMIT 100;\n", "clickhouse": "\n-- ClickHouse has no JSON_ARRAYAGG/JSON_OBJECTAGG; build arrays/maps with\n-- groupArray + mapFromArrays then serialize via toJSONString.\nSELECT\n p_brand,\n toJSONString(groupArray(p_name)) as part_names,\n toJSONString(mapFromArrays(groupArray(p_partkey), groupArray(p_retailprice))) as part_prices,\n COUNT(*) as part_count\nFROM part\nWHERE p_type LIKE '%STEEL%'\nGROUP BY p_brand\nORDER BY p_brand\nLIMIT 100;\n"}, "skip_on": ["datafusion", "redshift"]} +- {"id": "json_extract_simple", "category": "json", "sql": "\n-- Extract from JSON with simple path expressions\nSELECT\n o_orderkey,\n JSON_EXTRACT(o_comment, '$.priority') as order_priority,\n JSON_EXTRACT(o_comment, '$.notes') as order_notes\nFROM orders\nWHERE JSON_EXTRACT(o_comment, '$.priority') IS NOT NULL\nLIMIT 1000;\n", "skip_on": ["bigquery", "databricks", "datafusion", "duckdb", "redshift", "snowflake"], "description": "TPC-H o_comment contains plain text, not valid JSON", "variants": {}} +- {"id": "json_extract_nested", "category": "json", "sql": "\n-- Extract from JSON with complex path expressions\nSELECT\n c_custkey,\n JSON_EXTRACT(c_comment, '$.profile.segment') as customer_segment,\n JSON_EXTRACT(c_comment, '$.profile.preferences[0]') as primary_preference,\n JSON_EXTRACT(c_comment, '$.history.last_order.date') as last_order_date\nFROM customer\nWHERE JSON_VALID(c_comment)\nLIMIT 500;\n", "result_contract": {"capability": "json_nested_path_extraction", "row_identity": ["c_custkey"], "columns": ["c_custkey", {"name": "customer_segment", "type_class": "json"}, {"name": "primary_preference", "type_class": "json"}, {"name": "last_order_date", "type_class": "json"}]}, "variants": {"clickhouse": "\n-- ClickHouse JSON access uses JSONExtract*(json, path...) with positional\n-- path components; isValidJSON replaces JSON_VALID. TPC-H c_comment is\n-- plain text so isValidJSON gates rows out (matches duckdb behavior).\nSELECT\n c_custkey,\n JSONExtractRaw(c_comment, 'profile', 'segment') as customer_segment,\n JSONExtractRaw(c_comment, 'profile', 'preferences', 1) as primary_preference,\n JSONExtractRaw(c_comment, 'history', 'last_order', 'date') as last_order_date\nFROM customer\nWHERE isValidJSON(c_comment)\nLIMIT 500;\n", }, "skip_on": ["bigquery", "datafusion", "redshift", "snowflake", "databricks"], "description": "TPC-H c_comment contains plain text, not valid JSON"} +- {"id": "json_aggregates", "category": "json", "sql": "\n-- Create JSON array and object with JSON aggregate functions\nSELECT\n p_brand,\n JSON_ARRAYAGG(p_name) as part_names,\n JSON_OBJECTAGG(p_partkey, p_retailprice) as part_prices,\n COUNT(*) as part_count\nFROM part\nWHERE p_type LIKE '%STEEL%'\nGROUP BY p_brand\nORDER BY p_brand\nLIMIT 100;\n", "result_contract": {"capability": "json_array_object_aggregation", "row_identity": ["p_brand"], "columns": ["p_brand", {"name": "part_names", "type_class": "json"}, {"name": "part_prices", "type_class": "json"}, "part_count"]}, "variants": {"duckdb": "\n-- DuckDB uses JSON_GROUP_ARRAY and JSON_GROUP_OBJECT instead\nSELECT\n p_brand,\n JSON_GROUP_ARRAY(p_name) as part_names,\n JSON_GROUP_OBJECT(p_partkey, p_retailprice) as part_prices,\n COUNT(*) as part_count\nFROM part\nWHERE p_type LIKE '%STEEL%'\nGROUP BY p_brand\nORDER BY p_brand\nLIMIT 100;\n", "clickhouse": "\n-- ClickHouse has no JSON_ARRAYAGG/JSON_OBJECTAGG; build arrays/maps with\n-- groupArray + mapFromArrays then serialize via toJSONString.\nSELECT\n p_brand,\n toJSONString(groupArray(p_name)) as part_names,\n toJSONString(mapFromArrays(groupArray(p_partkey), groupArray(p_retailprice))) as part_prices,\n COUNT(*) as part_count\nFROM part\nWHERE p_type LIKE '%STEEL%'\nGROUP BY p_brand\nORDER BY p_brand\nLIMIT 100;\n", "snowflake": "SELECT p_brand,\n TO_JSON(ARRAY_AGG(p_name)) AS part_names,\n TO_JSON(OBJECT_AGG(TO_VARCHAR(p_partkey), TO_VARIANT(p_retailprice))) AS part_prices,\n COUNT(*) AS part_count\nFROM part\nWHERE p_type LIKE '%STEEL%'\nGROUP BY p_brand\nORDER BY p_brand\nLIMIT 100;", "bigquery": "SELECT p_brand,\n TO_JSON_STRING(ARRAY_AGG(p_name ORDER BY p_partkey)) AS part_names,\n TO_JSON_STRING(JSON_OBJECT(ARRAY_AGG(CAST(p_partkey AS STRING) ORDER BY p_partkey), ARRAY_AGG(p_retailprice ORDER BY p_partkey))) AS part_prices,\n COUNT(*) AS part_count\nFROM part\nWHERE p_type LIKE '%STEEL%'\nGROUP BY p_brand\nORDER BY p_brand\nLIMIT 100;"}, "skip_on": ["datafusion", "redshift", "databricks"]} - {"id": "fulltext_simple_search", "category": "fulltext", "sql": "\n-- Basic full-text search capabilities on text columns\nSELECT\n p_partkey,\n p_name,\n p_mfgr,\n p_comment\nFROM part\nWHERE MATCH(p_name, p_comment) AGAINST ('STEEL COPPER' IN NATURAL LANGUAGE MODE)\nLIMIT 100;\n", "skip_on": ["bigquery", "clickhouse", "datafusion", "databricks", "duckdb", "redshift", "snowflake"]} - {"id": "fulltext_boolean_search", "category": "fulltext", "sql": "\n-- Boolean full-text search with operators\nSELECT\n c_custkey,\n c_name,\n c_comment,\n MATCH(c_comment) AGAINST ('+BUILDING -FURNITURE' IN BOOLEAN MODE) as relevance_score\nFROM customer\nWHERE MATCH(c_comment) AGAINST ('+BUILDING -FURNITURE' IN BOOLEAN MODE)\nORDER BY relevance_score DESC\nLIMIT 50;\n", "skip_on": ["bigquery", "clickhouse", "datafusion", "databricks", "duckdb", "redshift", "snowflake"]} - {"id": "fulltext_phrase_search", "category": "fulltext", "sql": "\n-- Phrase-based full-text search with ranking\nSELECT\n s_suppkey,\n s_name,\n s_comment,\n MATCH(s_comment) AGAINST ('\"Customer Complaints\"' IN BOOLEAN MODE) as phrase_match_score\nFROM supplier\nWHERE MATCH(s_comment) AGAINST ('\"Customer Complaints\"' IN BOOLEAN MODE)\nORDER BY phrase_match_score DESC;\n", "skip_on": ["bigquery", "clickhouse", "datafusion", "databricks", "duckdb", "redshift", "snowflake"]} -- {"id": "statistical_percentiles", "category": "statistical", "sql": "\n-- Percentile calculation functions for distribution analysis\nSELECT\n l_returnflag,\n l_linestatus,\n COUNT(*) as record_count,\n PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY l_quantity) as quantity_q1,\n PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l_quantity) as quantity_median,\n PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY l_quantity) as quantity_q3,\n PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY l_extendedprice) as price_p95\nFROM lineitem\nGROUP BY l_returnflag, l_linestatus;\n", "result_contract": {"capability": "exact_continuous_percentiles", "row_identity": ["l_returnflag", "l_linestatus"], "columns": ["l_returnflag", "l_linestatus", "record_count", "quantity_q1", "quantity_median", "quantity_q3", "price_p95"]}, "variants": {"redshift": "\n-- Redshift supports PERCENTILE_CONT only as a window function, not aggregate\nSELECT DISTINCT\n l_returnflag,\n l_linestatus,\n COUNT(*) OVER (PARTITION BY l_returnflag, l_linestatus) as record_count,\n PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY l_quantity) OVER (PARTITION BY l_returnflag, l_linestatus) as quantity_q1,\n PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l_quantity) OVER (PARTITION BY l_returnflag, l_linestatus) as quantity_median,\n PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY l_quantity) OVER (PARTITION BY l_returnflag, l_linestatus) as quantity_q3,\n PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY l_extendedprice) OVER (PARTITION BY l_returnflag, l_linestatus) as price_p95\nFROM lineitem;\n"}, "skip_on": ["clickhouse"]} +- {"id": "statistical_percentiles", "category": "statistical", "sql": "\n-- Percentile calculation functions for distribution analysis\nSELECT\n l_returnflag,\n l_linestatus,\n COUNT(*) as record_count,\n PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY l_quantity) as quantity_q1,\n PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l_quantity) as quantity_median,\n PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY l_quantity) as quantity_q3,\n PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY l_extendedprice) as price_p95\nFROM lineitem\nGROUP BY l_returnflag, l_linestatus;\n", "result_contract": {"capability": "exact_continuous_percentiles", "row_identity": ["l_returnflag", "l_linestatus"], "columns": ["l_returnflag", "l_linestatus", "record_count", "quantity_q1", "quantity_median", "quantity_q3", "price_p95"]}, "variants": {"redshift": "\n-- Redshift supports PERCENTILE_CONT only as a window function, not aggregate\nSELECT DISTINCT\n l_returnflag,\n l_linestatus,\n COUNT(*) OVER (PARTITION BY l_returnflag, l_linestatus) as record_count,\n PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY l_quantity) OVER (PARTITION BY l_returnflag, l_linestatus) as quantity_q1,\n PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l_quantity) OVER (PARTITION BY l_returnflag, l_linestatus) as quantity_median,\n PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY l_quantity) OVER (PARTITION BY l_returnflag, l_linestatus) as quantity_q3,\n PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY l_extendedprice) OVER (PARTITION BY l_returnflag, l_linestatus) as price_p95\nFROM lineitem;\n", "bigquery": "SELECT DISTINCT l_returnflag, l_linestatus,\n COUNT(*) OVER (PARTITION BY l_returnflag, l_linestatus) AS record_count,\n PERCENTILE_CONT(l_quantity, 0.25) OVER (PARTITION BY l_returnflag, l_linestatus) AS quantity_q1,\n PERCENTILE_CONT(l_quantity, 0.5) OVER (PARTITION BY l_returnflag, l_linestatus) AS quantity_median,\n PERCENTILE_CONT(l_quantity, 0.75) OVER (PARTITION BY l_returnflag, l_linestatus) AS quantity_q3,\n PERCENTILE_CONT(l_extendedprice, 0.95) OVER (PARTITION BY l_returnflag, l_linestatus) AS price_p95\nFROM lineitem;"}, "skip_on": ["clickhouse"]} - {"id": "approx_quantiles_array", "category": "statistical", "sql": "\n-- Approximate quantiles returned as a single array per group\n-- Companion to statistical_percentiles, but exercises the\n-- vector-quantile path (one sketch evaluation per group instead of\n-- four PERCENTILE_CONT calls).\nSELECT\n l_returnflag,\n l_linestatus,\n APPROX_QUANTILE(l_quantity, [0.25, 0.5, 0.75, 0.95]) as quantity_quantiles\nFROM lineitem\nGROUP BY l_returnflag, l_linestatus;\n", "result_contract": {"capability": "approximate_quantile_array", "row_identity": ["l_returnflag", "l_linestatus"], "columns": ["l_returnflag", "l_linestatus", {"name": "quantity_quantiles", "type_class": "array"}]}, "variants": {"bigquery": "\nSELECT\n l_returnflag,\n l_linestatus,\n [\n APPROX_QUANTILES(l_quantity, 100)[OFFSET(25)],\n APPROX_QUANTILES(l_quantity, 100)[OFFSET(50)],\n APPROX_QUANTILES(l_quantity, 100)[OFFSET(75)],\n APPROX_QUANTILES(l_quantity, 100)[OFFSET(95)]\n ] as quantity_quantiles\nFROM lineitem\nGROUP BY l_returnflag, l_linestatus;\n", "clickhouse": "\nSELECT\n l_returnflag,\n l_linestatus,\n quantilesTDigest(0.25, 0.5, 0.75, 0.95)(toFloat64(l_quantity)) as quantity_quantiles\nFROM lineitem\nGROUP BY l_returnflag, l_linestatus;\n", "snowflake": "\nSELECT\n l_returnflag,\n l_linestatus,\n ARRAY_CONSTRUCT(\n APPROX_PERCENTILE(l_quantity, 0.25),\n APPROX_PERCENTILE(l_quantity, 0.5),\n APPROX_PERCENTILE(l_quantity, 0.75),\n APPROX_PERCENTILE(l_quantity, 0.95)\n ) as quantity_quantiles\nFROM lineitem\nGROUP BY l_returnflag, l_linestatus;\n"}, "skip_on": ["redshift", "datafusion", "sqlite"]} - {"id": "statistical_variance_stddev", "category": "statistical", "sql": "\n-- Variance and standard deviation calculations\nSELECT\n o_orderpriority,\n COUNT(*) as order_count,\n AVG(o_totalprice) as avg_price,\n VARIANCE(o_totalprice) as price_variance,\n STDDEV(o_totalprice) as price_stddev,\n STDDEV_POP(o_totalprice) as price_stddev_pop,\n STDDEV_SAMP(o_totalprice) as price_stddev_samp\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\nGROUP BY o_orderpriority;\n"} -- {"id": "statistical_correlation", "category": "statistical", "skip_on": ["redshift"], "sql": "\n-- Correlation analysis between numeric columns\nSELECT\n CORR(l_quantity, l_extendedprice) as qty_price_correlation,\n COVAR_POP(l_quantity, l_discount) as qty_discount_covariance,\n COVAR_SAMP(l_tax, l_extendedprice) as tax_price_covariance,\n REGR_SLOPE(l_extendedprice, l_quantity) as price_qty_slope,\n REGR_INTERCEPT(l_extendedprice, l_quantity) as price_qty_intercept,\n REGR_R2(l_extendedprice, l_quantity) as regression_r_squared\nFROM lineitem\nWHERE l_shipdate >= DATE '1995-01-01'\n AND l_shipdate < DATE '1996-01-01';\n", "result_contract": {"capability": "correlation_covariance_regression", "columns": ["qty_price_correlation", "qty_discount_covariance", "tax_price_covariance", "price_qty_slope", "price_qty_intercept", "regression_r_squared"]}, "variants": {"clickhouse": "\n-- ClickHouse's corr/covarPop/covarSamp reject Decimal arguments\n-- (ILLEGAL_TYPE_OF_ARGUMENT); cast columns to Float64 first.\n-- REGR_SLOPE/INTERCEPT/R2 are not available; compute manually from\n-- corr() and stddevSamp() over the cast columns.\nSELECT\n corr(toFloat64(l_quantity), toFloat64(l_extendedprice)) as qty_price_correlation,\n covarPop(toFloat64(l_quantity), toFloat64(l_discount)) as qty_discount_covariance,\n covarSamp(toFloat64(l_tax), toFloat64(l_extendedprice)) as tax_price_covariance,\n corr(toFloat64(l_extendedprice), toFloat64(l_quantity)) * stddevSamp(toFloat64(l_extendedprice)) / nullif(stddevSamp(toFloat64(l_quantity)), 0) as price_qty_slope,\n avg(toFloat64(l_extendedprice)) - corr(toFloat64(l_extendedprice), toFloat64(l_quantity)) * stddevSamp(toFloat64(l_extendedprice)) / nullif(stddevSamp(toFloat64(l_quantity)), 0) * avg(toFloat64(l_quantity)) as price_qty_intercept,\n pow(corr(toFloat64(l_extendedprice), toFloat64(l_quantity)), 2) as regression_r_squared\nFROM lineitem\nWHERE l_shipdate >= toDate('1995-01-01')\n AND l_shipdate < toDate('1996-01-01');\n"}} +- {"id": "statistical_correlation", "category": "statistical", "skip_on": ["redshift"], "sql": "\n-- Correlation analysis between numeric columns\nSELECT\n CORR(l_quantity, l_extendedprice) as qty_price_correlation,\n COVAR_POP(l_quantity, l_discount) as qty_discount_covariance,\n COVAR_SAMP(l_tax, l_extendedprice) as tax_price_covariance,\n REGR_SLOPE(l_extendedprice, l_quantity) as price_qty_slope,\n REGR_INTERCEPT(l_extendedprice, l_quantity) as price_qty_intercept,\n REGR_R2(l_extendedprice, l_quantity) as regression_r_squared\nFROM lineitem\nWHERE l_shipdate >= DATE '1995-01-01'\n AND l_shipdate < DATE '1996-01-01';\n", "result_contract": {"capability": "correlation_covariance_regression", "columns": ["qty_price_correlation", "qty_discount_covariance", "tax_price_covariance", "price_qty_slope", "price_qty_intercept", "regression_r_squared"]}, "variants": {"clickhouse": "\n-- ClickHouse's corr/covarPop/covarSamp reject Decimal arguments\n-- (ILLEGAL_TYPE_OF_ARGUMENT); cast columns to Float64 first.\n-- REGR_SLOPE/INTERCEPT/R2 are not available; compute manually from\n-- corr() and stddevSamp() over the cast columns.\nSELECT\n corr(toFloat64(l_quantity), toFloat64(l_extendedprice)) as qty_price_correlation,\n covarPop(toFloat64(l_quantity), toFloat64(l_discount)) as qty_discount_covariance,\n covarSamp(toFloat64(l_tax), toFloat64(l_extendedprice)) as tax_price_covariance,\n corr(toFloat64(l_extendedprice), toFloat64(l_quantity)) * stddevSamp(toFloat64(l_extendedprice)) / nullif(stddevSamp(toFloat64(l_quantity)), 0) as price_qty_slope,\n avg(toFloat64(l_extendedprice)) - corr(toFloat64(l_extendedprice), toFloat64(l_quantity)) * stddevSamp(toFloat64(l_extendedprice)) / nullif(stddevSamp(toFloat64(l_quantity)), 0) * avg(toFloat64(l_quantity)) as price_qty_intercept,\n pow(corr(toFloat64(l_extendedprice), toFloat64(l_quantity)), 2) as regression_r_squared\nFROM lineitem\nWHERE l_shipdate >= toDate('1995-01-01')\n AND l_shipdate < toDate('1996-01-01');\n", "bigquery": "SELECT\n CORR(l_quantity, l_extendedprice) AS qty_price_correlation,\n COVAR_POP(l_quantity, l_discount) AS qty_discount_covariance,\n COVAR_SAMP(l_tax, l_extendedprice) AS tax_price_covariance,\n COVAR_POP(l_extendedprice, l_quantity) / NULLIF(VAR_POP(l_quantity), 0) AS price_qty_slope,\n AVG(l_extendedprice) -\n COVAR_POP(l_extendedprice, l_quantity) / NULLIF(VAR_POP(l_quantity), 0) * AVG(l_quantity)\n AS price_qty_intercept,\n CASE\n WHEN VAR_POP(l_quantity) = 0 THEN NULL\n WHEN VAR_POP(l_extendedprice) = 0 THEN 1\n ELSE POW(CORR(l_extendedprice, l_quantity), 2)\n END AS regression_r_squared\nFROM lineitem\nWHERE l_shipdate >= DATE '1995-01-01'\n AND l_shipdate < DATE '1996-01-01';"}} - {"id": "olap_cube_analysis", "category": "olap", "sql": "\n-- CUBE operation for multidimensional analysis\nSELECT\n n.n_name as nation,\n r.r_name as region,\n EXTRACT(YEAR FROM o.o_orderdate) as order_year,\n EXTRACT(QUARTER FROM o.o_orderdate) as order_quarter,\n COUNT(*) as order_count,\n SUM(o.o_totalprice) as total_revenue,\n AVG(o.o_totalprice) as avg_order_value\nFROM orders o\nJOIN customer c ON o.o_custkey = c.c_custkey\nJOIN nation n ON c.c_nationkey = n.n_nationkey\nJOIN region r ON n.n_regionkey = r.r_regionkey\nWHERE o.o_orderdate >= DATE '1995-01-01'\n AND o.o_orderdate < DATE '1997-01-01'\nGROUP BY CUBE(n.n_name, r.r_name,\n EXTRACT(YEAR FROM o.o_orderdate),\n EXTRACT(QUARTER FROM o.o_orderdate));\n"} - {"id": "olap_rollup_analysis", "category": "olap", "sql": "\n-- ROLLUP operation for hierarchical aggregation\nSELECT\n r.r_name as region,\n n.n_name as nation,\n c.c_mktsegment as market_segment,\n COUNT(DISTINCT c.c_custkey) as customer_count,\n COUNT(*) as order_count,\n SUM(o.o_totalprice) as total_revenue,\n AVG(o.o_totalprice) as avg_order_value\nFROM orders o\nJOIN customer c ON o.o_custkey = c.c_custkey\nJOIN nation n ON c.c_nationkey = n.n_nationkey\nJOIN region r ON n.n_regionkey = r.r_regionkey\nWHERE o.o_orderdate >= DATE '1995-01-01'\nGROUP BY ROLLUP(r.r_name, n.n_name, c.c_mktsegment)\nORDER BY r.r_name NULLS LAST, n.n_name NULLS LAST, c.c_mktsegment NULLS LAST;\n"} -- {"id": "timeseries_trend_analysis", "category": "timeseries", "skip_on": ["redshift"], "sql": "\n-- Time series trend analysis with linear regression\nWITH monthly_totals AS (\n SELECT\n DATE_TRUNC('month', o_orderdate) as order_month,\n COUNT(*) as order_count,\n SUM(o_totalprice) as monthly_revenue,\n AVG(o_totalprice) as avg_order_value,\n EXTRACT(EPOCH FROM DATE_TRUNC('month', o_orderdate)) as month_epoch\n FROM orders\n WHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1997-01-01'\n GROUP BY DATE_TRUNC('month', o_orderdate)\n),\nmonthly_with_lag AS (\n SELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) as prev_month_revenue\n FROM monthly_totals\n)\nSELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n REGR_SLOPE(monthly_revenue, month_epoch) OVER () as revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / NULLIF(prev_month_revenue, 0) * 100 as mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;\n", "result_contract": {"capability": "monthly_time_series_regression", "row_identity": ["order_month"], "columns": ["order_month", "order_count", "monthly_revenue", "avg_order_value", "revenue_trend_slope", "prev_month_revenue", "mom_growth_pct"]}, "variants": {"duckdb": "\n-- DuckDB doesn't allow nested aggregates, use CTE to pre-aggregate\nWITH monthly_totals AS (\n SELECT\n DATE_TRUNC('month', o_orderdate) as order_month,\n COUNT(*) as order_count,\n SUM(o_totalprice) as monthly_revenue,\n AVG(o_totalprice) as avg_order_value,\n EXTRACT(EPOCH FROM DATE_TRUNC('month', o_orderdate)) as month_epoch\n FROM orders\n WHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1997-01-01'\n GROUP BY DATE_TRUNC('month', o_orderdate)\n),\nmonthly_with_lag AS (\n SELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) as prev_month_revenue\n FROM monthly_totals\n)\nSELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n REGR_SLOPE(monthly_revenue, month_epoch) OVER () as revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / NULLIF(prev_month_revenue, 0) * 100 as mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;\n", "clickhouse": "\n-- ClickHouse: use toStartOfMonth for DATE_TRUNC, toUnixTimestamp for EPOCH.\n-- REGR_SLOPE not available; compute slope manually from corr/stddev.\n-- Cast revenue/epoch to Float64 in the CTE because corr/stddev reject\n-- Decimal/UInt arguments (ILLEGAL_TYPE_OF_ARGUMENT).\nWITH monthly_totals AS (\n SELECT\n toStartOfMonth(o_orderdate) as order_month,\n COUNT(*) as order_count,\n toFloat64(SUM(o_totalprice)) as monthly_revenue,\n toFloat64(AVG(o_totalprice)) as avg_order_value,\n toFloat64(toUnixTimestamp(toStartOfMonth(o_orderdate))) as month_epoch\n FROM orders\n WHERE o_orderdate >= toDate('1995-01-01')\n AND o_orderdate < toDate('1997-01-01')\n GROUP BY toStartOfMonth(o_orderdate)\n),\nmonthly_with_lag AS (\n SELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) as prev_month_revenue\n FROM monthly_totals\n)\nSELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n corr(monthly_revenue, month_epoch) OVER () * stddevSamp(monthly_revenue) OVER () / nullif(stddevSamp(month_epoch) OVER (), 0) as revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / nullif(prev_month_revenue, 0) * 100 as mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;\n", "datafusion": "\nWITH monthly_totals AS (\n SELECT\n DATE_TRUNC('month', o_orderdate) as order_month,\n COUNT(*) as order_count,\n SUM(o_totalprice) as monthly_revenue,\n AVG(o_totalprice) as avg_order_value,\n EXTRACT(EPOCH FROM DATE_TRUNC('month', o_orderdate)) as month_epoch\n FROM orders\n WHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1997-01-01'\n GROUP BY DATE_TRUNC('month', o_orderdate)\n),\nmonthly_with_lag AS (\n SELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) as prev_month_revenue\n FROM monthly_totals\n)\nSELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n REGR_SLOPE(monthly_revenue, month_epoch) OVER () as revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / NULLIF(prev_month_revenue, 0) * 100 as mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;\n"}} +- {"id": "timeseries_trend_analysis", "category": "timeseries", "skip_on": ["redshift", "databricks"], "sql": "\n-- Time series trend analysis with linear regression\nWITH monthly_totals AS (\n SELECT\n DATE_TRUNC('month', o_orderdate) as order_month,\n COUNT(*) as order_count,\n SUM(o_totalprice) as monthly_revenue,\n AVG(o_totalprice) as avg_order_value,\n EXTRACT(EPOCH FROM DATE_TRUNC('month', o_orderdate)) as month_epoch\n FROM orders\n WHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1997-01-01'\n GROUP BY DATE_TRUNC('month', o_orderdate)\n),\nmonthly_with_lag AS (\n SELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) as prev_month_revenue\n FROM monthly_totals\n)\nSELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n REGR_SLOPE(monthly_revenue, month_epoch) OVER () as revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / NULLIF(prev_month_revenue, 0) * 100 as mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;\n", "result_contract": {"capability": "monthly_time_series_regression", "row_identity": ["order_month"], "columns": ["order_month", "order_count", "monthly_revenue", "avg_order_value", "revenue_trend_slope", "prev_month_revenue", "mom_growth_pct"]}, "variants": {"duckdb": "\n-- DuckDB doesn't allow nested aggregates, use CTE to pre-aggregate\nWITH monthly_totals AS (\n SELECT\n DATE_TRUNC('month', o_orderdate) as order_month,\n COUNT(*) as order_count,\n SUM(o_totalprice) as monthly_revenue,\n AVG(o_totalprice) as avg_order_value,\n EXTRACT(EPOCH FROM DATE_TRUNC('month', o_orderdate)) as month_epoch\n FROM orders\n WHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1997-01-01'\n GROUP BY DATE_TRUNC('month', o_orderdate)\n),\nmonthly_with_lag AS (\n SELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) as prev_month_revenue\n FROM monthly_totals\n)\nSELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n REGR_SLOPE(monthly_revenue, month_epoch) OVER () as revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / NULLIF(prev_month_revenue, 0) * 100 as mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;\n", "clickhouse": "\n-- ClickHouse: use toStartOfMonth for DATE_TRUNC, toUnixTimestamp for EPOCH.\n-- REGR_SLOPE not available; compute slope manually from corr/stddev.\n-- Cast revenue/epoch to Float64 in the CTE because corr/stddev reject\n-- Decimal/UInt arguments (ILLEGAL_TYPE_OF_ARGUMENT).\nWITH monthly_totals AS (\n SELECT\n toStartOfMonth(o_orderdate) as order_month,\n COUNT(*) as order_count,\n toFloat64(SUM(o_totalprice)) as monthly_revenue,\n toFloat64(AVG(o_totalprice)) as avg_order_value,\n toFloat64(toUnixTimestamp(toStartOfMonth(o_orderdate))) as month_epoch\n FROM orders\n WHERE o_orderdate >= toDate('1995-01-01')\n AND o_orderdate < toDate('1997-01-01')\n GROUP BY toStartOfMonth(o_orderdate)\n),\nmonthly_with_lag AS (\n SELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) as prev_month_revenue\n FROM monthly_totals\n)\nSELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n corr(monthly_revenue, month_epoch) OVER () * stddevSamp(monthly_revenue) OVER () / nullif(stddevSamp(month_epoch) OVER (), 0) as revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / nullif(prev_month_revenue, 0) * 100 as mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;\n", "datafusion": "\nWITH monthly_totals AS (\n SELECT\n DATE_TRUNC('month', o_orderdate) as order_month,\n COUNT(*) as order_count,\n SUM(o_totalprice) as monthly_revenue,\n AVG(o_totalprice) as avg_order_value,\n EXTRACT(EPOCH FROM DATE_TRUNC('month', o_orderdate)) as month_epoch\n FROM orders\n WHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1997-01-01'\n GROUP BY DATE_TRUNC('month', o_orderdate)\n),\nmonthly_with_lag AS (\n SELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) as prev_month_revenue\n FROM monthly_totals\n)\nSELECT\n order_month,\n order_count,\n monthly_revenue,\n avg_order_value,\n REGR_SLOPE(monthly_revenue, month_epoch) OVER () as revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / NULLIF(prev_month_revenue, 0) * 100 as mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;\n", "bigquery": "WITH monthly_totals AS (\n SELECT DATE_TRUNC(o_orderdate, MONTH) AS order_month,\n COUNT(*) AS order_count,\n SUM(o_totalprice) AS monthly_revenue,\n AVG(o_totalprice) AS avg_order_value,\n UNIX_SECONDS(TIMESTAMP(DATE_TRUNC(o_orderdate, MONTH))) AS month_epoch\n FROM orders\n WHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1997-01-01'\n GROUP BY order_month\n),\nmonthly_with_lag AS (\n SELECT order_month, order_count, monthly_revenue, avg_order_value, month_epoch,\n LAG(monthly_revenue, 1) OVER (ORDER BY order_month) AS prev_month_revenue\n FROM monthly_totals\n)\nSELECT order_month, order_count, monthly_revenue, avg_order_value,\n COVAR_POP(monthly_revenue, month_epoch) OVER () /\n NULLIF(VAR_POP(month_epoch) OVER (), 0) AS revenue_trend_slope,\n prev_month_revenue,\n (monthly_revenue - prev_month_revenue) / NULLIF(prev_month_revenue, 0) * 100 AS mom_growth_pct\nFROM monthly_with_lag\nORDER BY order_month;"}} - {"id": "max_by_simple", "category": "max", "sql": "\n-- Find the customer with the highest account balance in each nation\nSELECT\n n_name,\n MAX_BY(c_name, c_acctbal) as richest_customer,\n MAX(c_acctbal) as max_balance\nFROM customer c\nJOIN nation n ON c.c_nationkey = n.n_nationkey\nGROUP BY n_name\nORDER BY max_balance DESC, n_name;\n", "result_contract": {"capability": "arg_max_aggregate", "row_identity": ["n_name"], "columns": ["n_name", "richest_customer", "max_balance"]}, "variants": {"datafusion": "\nWITH ranked AS (\n SELECT\n n.n_name,\n c.c_name,\n c.c_acctbal,\n ROW_NUMBER() OVER (PARTITION BY n.n_name ORDER BY c.c_acctbal DESC, c.c_name) as rn\n FROM customer c\n JOIN nation n ON c.c_nationkey = n.n_nationkey\n)\nSELECT\n n_name,\n c_name as richest_customer,\n c_acctbal as max_balance\nFROM ranked\nWHERE rn = 1\nORDER BY max_balance DESC, n_name;\n", "redshift": "\nWITH ranked AS (\n SELECT\n n.n_name,\n c.c_name,\n c.c_acctbal,\n ROW_NUMBER() OVER (PARTITION BY n.n_name ORDER BY c.c_acctbal DESC, c.c_name) as rn\n FROM customer c\n JOIN nation n ON c.c_nationkey = n.n_nationkey\n)\nSELECT\n n_name,\n c_name as richest_customer,\n c_acctbal as max_balance\nFROM ranked\nWHERE rn = 1\nORDER BY max_balance DESC, n_name;\n"}} - {"id": "min_by_simple", "category": "min", "sql": "\n-- Find the customer with the lowest account balance in each nation\nSELECT\n n_name,\n MIN_BY(c_name, c_acctbal) as poorest_customer,\n MIN(c_acctbal) as min_balance\nFROM customer c\nJOIN nation n ON c.c_nationkey = n.n_nationkey\nGROUP BY n_name\nORDER BY min_balance ASC, n_name;\n", "result_contract": {"capability": "arg_min_aggregate", "row_identity": ["n_name"], "columns": ["n_name", "poorest_customer", "min_balance"]}, "variants": {"datafusion": "\nWITH ranked AS (\n SELECT\n n.n_name,\n c.c_name,\n c.c_acctbal,\n ROW_NUMBER() OVER (PARTITION BY n.n_name ORDER BY c.c_acctbal ASC, c.c_name) as rn\n FROM customer c\n JOIN nation n ON c.c_nationkey = n.n_nationkey\n)\nSELECT\n n_name,\n c_name as poorest_customer,\n c_acctbal as min_balance\nFROM ranked\nWHERE rn = 1\nORDER BY min_balance ASC, n_name;\n", "redshift": "\nWITH ranked AS (\n SELECT\n n.n_name,\n c.c_name,\n c.c_acctbal,\n ROW_NUMBER() OVER (PARTITION BY n.n_name ORDER BY c.c_acctbal ASC, c.c_name) as rn\n FROM customer c\n JOIN nation n ON c.c_nationkey = n.n_nationkey\n)\nSELECT\n n_name,\n c_name as poorest_customer,\n c_acctbal as min_balance\nFROM ranked\nWHERE rn = 1\nORDER BY min_balance ASC, n_name;\n"}} - {"id": "max_by_complex", "category": "max", "sql": "\n-- Find the most expensive order for each customer segment\nSELECT\n c_mktsegment,\n MAX_BY(o_orderkey, o_totalprice) as most_expensive_order_id,\n MAX_BY(o_orderdate, o_totalprice) as most_expensive_order_date,\n MAX(o_totalprice) as max_order_value\nFROM orders o\nJOIN customer c ON o.o_custkey = c.c_custkey\nGROUP BY c_mktsegment\nORDER BY max_order_value DESC, c_mktsegment;\n", "result_contract": {"capability": "arg_max_aggregate", "row_identity": ["c_mktsegment"], "columns": ["c_mktsegment", "most_expensive_order_id", "most_expensive_order_date", "max_order_value"]}, "variants": {"datafusion": "\nWITH ranked AS (\n SELECT\n c.c_mktsegment,\n o.o_orderkey,\n o.o_orderdate,\n o.o_totalprice,\n ROW_NUMBER() OVER (\n PARTITION BY c.c_mktsegment\n ORDER BY o.o_totalprice DESC, o.o_orderkey\n ) as rn\n FROM orders o\n JOIN customer c ON o.o_custkey = c.c_custkey\n)\nSELECT\n c_mktsegment,\n o_orderkey as most_expensive_order_id,\n o_orderdate as most_expensive_order_date,\n o_totalprice as max_order_value\nFROM ranked\nWHERE rn = 1\nORDER BY max_order_value DESC, c_mktsegment;\n", "redshift": "\nWITH ranked AS (\n SELECT\n c.c_mktsegment,\n o.o_orderkey,\n o.o_orderdate,\n o.o_totalprice,\n ROW_NUMBER() OVER (\n PARTITION BY c.c_mktsegment\n ORDER BY o.o_totalprice DESC, o.o_orderkey\n ) as rn\n FROM orders o\n JOIN customer c ON o.o_custkey = c.c_custkey\n)\nSELECT\n c_mktsegment,\n o_orderkey as most_expensive_order_id,\n o_orderdate as most_expensive_order_date,\n o_totalprice as max_order_value\nFROM ranked\nWHERE rn = 1\nORDER BY max_order_value DESC, c_mktsegment;\n"}} @@ -140,7 +140,7 @@ queries: - {"id": "optimizer_join_reordering", "category": "optimizer", "sql": "\n-- Test join reordering optimization\n-- Good optimizers should reorder joins based on cardinality (nation smallest, then customer, then orders)\nSELECT\n n.n_name,\n c.c_name,\n COUNT(o.o_orderkey) as order_count,\n SUM(o.o_totalprice) as total_value\nFROM orders o -- Largest table listed first (suboptimal order)\nJOIN customer c ON o.o_custkey = c.c_custkey -- Medium table\nJOIN nation n ON c.c_nationkey = n.n_nationkey -- Smallest table\nWHERE n.n_regionkey = 1 -- High selectivity on smallest table\n AND o.o_orderdate >= DATE '1995-01-01'\n AND o.o_orderdate < DATE '1996-01-01'\nGROUP BY n.n_name, c.c_name\nHAVING COUNT(o.o_orderkey) > 5\nORDER BY total_value DESC\nLIMIT 100;\n"} - {"id": "optimizer_limit_pushdown", "category": "optimizer", "sql": "\n-- Test limit pushdown through operations optimization\n-- Good optimizers should push partial limits down to reduce work in upstream operations\nSELECT\n c.c_name,\n c.c_mktsegment,\n o.o_orderdate,\n o.o_totalprice,\n o.o_orderpriority\nFROM customer c\nJOIN orders o ON c.c_custkey = o.o_custkey\nWHERE c.c_mktsegment = 'BUILDING'\n AND o.o_orderdate >= DATE '1995-01-01'\n AND o.o_orderdate < DATE '1996-01-01'\nORDER BY o.o_totalprice DESC\nLIMIT 100; -- Should push partial limit down through join\n"} - {"id": "optimizer_aggregate_pushdown", "category": "optimizer", "sql": "\n-- Test aggregate pushdown through joins optimization\n-- Good optimizers should partially compute aggregates before joins when possible\nSELECT\n c.c_name,\n c.c_mktsegment,\n c.c_nationkey,\n COUNT(o.o_orderkey) as order_count,\n SUM(o.o_totalprice) as total_spent,\n AVG(o.o_totalprice) as avg_order_value\nFROM customer c\nJOIN orders o ON c.c_custkey = o.o_custkey\nWHERE c.c_nationkey = 15\n AND o.o_orderdate >= DATE '1995-01-01'\n AND o.o_orderdate < DATE '1996-01-01'\nGROUP BY c.c_custkey, c.c_name, c.c_mktsegment, c.c_nationkey\nHAVING SUM(o.o_totalprice) > 500000 -- HAVING can influence pushdown strategy\nORDER BY total_spent DESC\nLIMIT 50;\n"} -- {"id": "optimizer_scalar_subquery_flattening", "category": "optimizer", "sql": "\n-- Test scalar subquery flattening optimization\n-- Good optimizers should convert scalar subqueries to joins for better performance\nSELECT\n c.c_name,\n c.c_mktsegment,\n c.c_acctbal,\n (SELECT AVG(o.o_totalprice) \n FROM orders o \n WHERE o.o_custkey = c.c_custkey \n AND o.o_orderdate >= DATE '1995-01-01') as avg_order_value,\n (SELECT COUNT(o.o_orderkey)\n FROM orders o \n WHERE o.o_custkey = c.c_custkey \n AND o.o_orderdate >= DATE '1995-01-01') as order_count\nFROM customer c\nWHERE c.c_nationkey = 15\n AND c.c_acctbal > 5000\nORDER BY avg_order_value DESC, c.c_name\nLIMIT 100;\n", "skip_on": ["clickhouse", "datafusion"], "description": "ClickHouse and DataFusion do not support correlated subqueries (ClickHouse: NOT_IMPLEMENTED Code 48; DataFusion: planner rejects). The point of this query is to measure the optimizer's ability to auto-flatten correlated scalar subqueries into joins, so a pre-flattened LEFT JOIN rewrite would only measure plain join execution and defeat the test.", "result_contract": {"capability": "scalar_subquery_flattening", "row_identity": ["c_name", "c_mktsegment", "c_acctbal"], "columns": ["c_name", "c_mktsegment", "c_acctbal", "avg_order_value", "order_count"]}} +- {"id": "optimizer_scalar_subquery_flattening", "category": "optimizer", "sql": "\n-- Test scalar subquery flattening optimization\n-- Good optimizers should convert scalar subqueries to joins for better performance\nSELECT\n c.c_name,\n c.c_mktsegment,\n c.c_acctbal,\n (SELECT AVG(o.o_totalprice) \n FROM orders o \n WHERE o.o_custkey = c.c_custkey \n AND o.o_orderdate >= DATE '1995-01-01') as avg_order_value,\n (SELECT COUNT(o.o_orderkey)\n FROM orders o \n WHERE o.o_custkey = c.c_custkey \n AND o.o_orderdate >= DATE '1995-01-01') as order_count\nFROM customer c\nWHERE c.c_nationkey = 15\n AND c.c_acctbal > 5000\nORDER BY avg_order_value DESC, c.c_name\nLIMIT 100;\n", "skip_on": ["clickhouse", "datafusion", "snowflake"], "description": "ClickHouse and DataFusion do not support correlated subqueries (ClickHouse: NOT_IMPLEMENTED Code 48; DataFusion: planner rejects). The point of this query is to measure the optimizer's ability to auto-flatten correlated scalar subqueries into joins, so a pre-flattened LEFT JOIN rewrite would only measure plain join execution and defeat the test. Snowflake rejects this correlated scalar shape with internal error 000603; the query measures optimizer flattening, so a pre-flattened join is not a comparable replacement.", "result_contract": {"capability": "scalar_subquery_flattening", "row_identity": ["c_name", "c_mktsegment", "c_acctbal"], "columns": ["c_name", "c_mktsegment", "c_acctbal", "avg_order_value", "order_count"]}, "variants": {}} - {"id": "optimizer_constant_folding", "category": "optimizer", "sql": "\n-- Test constant folding and expression simplification optimization\n-- Good optimizers should pre-compute constants and simplify expressions at compile time\nSELECT\n l_orderkey,\n l_partkey,\n l_suppkey,\n l_linenumber,\n l_quantity * (1.0 + 0.0) as simplified_qty, -- Should fold to l_quantity\n l_extendedprice * (2 * 3 + 4) as constant_folded, -- Should fold to l_extendedprice * 10\n l_discount + 0.0 - 0.0 as zero_folded, -- Should fold to l_discount\n CASE WHEN 1 = 1 THEN l_tax ELSE 0 END as condition_folded, -- Should fold to l_tax\n l_quantity / 1.0 as division_folded, -- Should fold to l_quantity\n l_extendedprice + (5 - 5) as addition_folded -- Should fold to l_extendedprice\nFROM lineitem\nWHERE l_quantity * 1 > 10 -- Should simplify to l_quantity > 10\n AND l_shipdate >= DATE '1995-01-01'\n AND l_discount + 0 < 0.1 -- Should simplify to l_discount < 0.1\nORDER BY l_quantity * (1 + 0) DESC, l_orderkey, l_linenumber -- Should simplify to l_quantity DESC\nLIMIT 1000;\n"} - {"id": "optimizer_column_pruning", "category": "optimizer", "sql": "\n-- Test column pruning (projection pushdown) optimization\n-- Good optimizers should only read columns needed for the query result\nSELECT\n c.c_name -- Only need c_name from customer table\nFROM customer c\nJOIN orders o ON c.c_custkey = o.o_custkey -- Only need c_custkey for join from customer\nJOIN lineitem l ON o.o_orderkey = l.l_orderkey -- Only need o_orderkey for join from orders\nWHERE o.o_orderdate >= DATE '1995-01-01' -- Only need o_orderdate for filtering\n AND o.o_orderdate < DATE '1996-01-01'\n AND l.l_quantity > 40 -- Only need l_quantity for filtering\n AND c.c_nationkey = 15 -- Only need c_nationkey for filtering\nORDER BY c.c_name\nLIMIT 500;\n"} - {"id": "optimizer_in_to_exists", "category": "optimizer", "sql": "\n-- Test IN-to-EXISTS transformation optimization\n-- Good optimizers should transform IN subqueries to EXISTS when beneficial\nSELECT\n c.c_name,\n c.c_mktsegment,\n c.c_nationkey,\n c.c_acctbal\nFROM customer c\nWHERE c.c_custkey IN (\n SELECT o.o_custkey\n FROM orders o\n WHERE o.o_orderdate >= DATE '1995-01-01'\n AND o.o_orderdate < DATE '1996-01-01'\n AND o.o_totalprice > 100000\n)\n AND c.c_nationkey IN (1, 2, 3, 4, 5) -- Additional IN predicate\nORDER BY c.c_acctbal DESC\nLIMIT 200;\n"} @@ -151,26 +151,26 @@ queries: - {"id": "any_value_with_filter", "category": "aggregation", "sql": "\n-- Any value with additional aggregates\nSELECT\n n_regionkey,\n ANY_VALUE(n_name) as sample_nation,\n ANY_VALUE(n_comment) as sample_comment,\n COUNT(*) as nation_count\nFROM nation\nGROUP BY n_regionkey;\n", "result_contract": {"capability": "arbitrary_value_aggregate", "row_identity": ["n_regionkey"], "columns": ["n_regionkey", "sample_nation", "sample_comment", "nation_count"]}, "variants": {"clickhouse": "\nSELECT\n n_regionkey,\n any(n_name) as sample_nation,\n any(n_comment) as sample_comment,\n COUNT(*) as nation_count\nFROM nation\nGROUP BY n_regionkey;\n"}, "skip_on": ["datafusion"]} - {"id": "groupby_all_simple", "category": "aggregation", "sql": "\n-- Automatic grouping by all non-aggregate columns\nSELECT\n l_returnflag,\n l_linestatus,\n SUM(l_quantity) as total_qty,\n AVG(l_extendedprice) as avg_price\nFROM lineitem\nGROUP BY ALL;\n"} - {"id": "groupby_all_complex", "category": "aggregation", "sql": "\n-- GROUP BY ALL with multiple non-aggregate expressions\nSELECT\n DATE_TRUNC('month', o_orderdate) as order_month,\n o_orderpriority,\n COUNT(*) as order_count,\n SUM(o_totalprice) as monthly_revenue\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\nGROUP BY ALL\nORDER BY order_month, o_orderpriority;\n", "result_contract": {"capability": "group_by_all_expansion", "row_identity": ["order_month", "o_orderpriority"], "columns": ["order_month", "o_orderpriority", "order_count", "monthly_revenue"]}, "variants": {"datafusion": "\nSELECT\n DATE_TRUNC('month', o_orderdate) as order_month,\n o_orderpriority,\n COUNT(o_orderkey) as order_count,\n SUM(o_totalprice) as monthly_revenue\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\nGROUP BY DATE_TRUNC('month', o_orderdate), o_orderpriority\nORDER BY order_month, o_orderpriority;\n"}} -- {"id": "orderby_all_simple", "category": "orderby", "sql": "\n-- Order by all columns in SELECT list\nSELECT\n r_name,\n n_name,\n COUNT(*) as supplier_count\nFROM supplier s\nJOIN nation n ON s.s_nationkey = n.n_nationkey\nJOIN region r ON n.n_regionkey = r.r_regionkey\nGROUP BY r_name, n_name\nORDER BY ALL;\n", "result_contract": {"capability": "order_by_all_expansion", "row_identity": ["r_name", "n_name"], "columns": ["r_name", "n_name", "supplier_count"]}, "variants": {"datafusion": "\nSELECT\n r_name,\n n_name,\n COUNT(*) as supplier_count\nFROM supplier s\nJOIN nation n ON s.s_nationkey = n.n_nationkey\nJOIN region r ON n.n_regionkey = r.r_regionkey\nGROUP BY r_name, n_name\nORDER BY r_name, n_name, supplier_count;\n", "redshift": "\nSELECT\n r_name,\n n_name,\n COUNT(*) as supplier_count\nFROM supplier s\nJOIN nation n ON s.s_nationkey = n.n_nationkey\nJOIN region r ON n.n_regionkey = r.r_regionkey\nGROUP BY r_name, n_name\nORDER BY r_name, n_name, supplier_count;\n"}} -- {"id": "orderby_all_desc", "category": "orderby", "sql": "\n-- ORDER BY ALL with direction\nSELECT\n p_brand,\n p_type,\n AVG(p_retailprice) as avg_price\nFROM part\nGROUP BY p_brand, p_type\nORDER BY ALL DESC\nLIMIT 100;\n", "result_contract": {"capability": "order_by_all_desc_expansion", "row_identity": ["p_brand", "p_type"], "columns": ["p_brand", "p_type", "avg_price"]}, "variants": {"datafusion": "\nSELECT\n p_brand,\n p_type,\n AVG(p_retailprice) as avg_price\nFROM part\nGROUP BY p_brand, p_type\nORDER BY p_brand DESC, p_type DESC, avg_price DESC\nLIMIT 100;\n", "redshift": "\nSELECT\n p_brand,\n p_type,\n AVG(p_retailprice) as avg_price\nFROM part\nGROUP BY p_brand, p_type\nORDER BY p_brand DESC, p_type DESC, avg_price DESC\nLIMIT 100;\n"}} +- {"id": "orderby_all_simple", "category": "orderby", "sql": "\n-- Order by all columns in SELECT list\nSELECT\n r_name,\n n_name,\n COUNT(*) as supplier_count\nFROM supplier s\nJOIN nation n ON s.s_nationkey = n.n_nationkey\nJOIN region r ON n.n_regionkey = r.r_regionkey\nGROUP BY r_name, n_name\nORDER BY ALL;\n", "result_contract": {"capability": "order_by_all_expansion", "row_identity": ["r_name", "n_name"], "columns": ["r_name", "n_name", "supplier_count"]}, "variants": {"datafusion": "\nSELECT\n r_name,\n n_name,\n COUNT(*) as supplier_count\nFROM supplier s\nJOIN nation n ON s.s_nationkey = n.n_nationkey\nJOIN region r ON n.n_regionkey = r.r_regionkey\nGROUP BY r_name, n_name\nORDER BY r_name, n_name, supplier_count;\n", "redshift": "\nSELECT\n r_name,\n n_name,\n COUNT(*) as supplier_count\nFROM supplier s\nJOIN nation n ON s.s_nationkey = n.n_nationkey\nJOIN region r ON n.n_regionkey = r.r_regionkey\nGROUP BY r_name, n_name\nORDER BY r_name, n_name, supplier_count;\n", "bigquery": "SELECT r_name, n_name, COUNT(*) AS supplier_count FROM supplier s JOIN nation n ON s.s_nationkey = n.n_nationkey JOIN region r ON n.n_regionkey = r.r_regionkey GROUP BY r_name, n_name ORDER BY r_name, n_name, supplier_count;"}} +- {"id": "orderby_all_desc", "category": "orderby", "sql": "\n-- ORDER BY ALL with direction\nSELECT\n p_brand,\n p_type,\n AVG(p_retailprice) as avg_price\nFROM part\nGROUP BY p_brand, p_type\nORDER BY ALL DESC\nLIMIT 100;\n", "result_contract": {"capability": "order_by_all_desc_expansion", "row_identity": ["p_brand", "p_type"], "columns": ["p_brand", "p_type", "avg_price"]}, "variants": {"datafusion": "\nSELECT\n p_brand,\n p_type,\n AVG(p_retailprice) as avg_price\nFROM part\nGROUP BY p_brand, p_type\nORDER BY p_brand DESC, p_type DESC, avg_price DESC\nLIMIT 100;\n", "redshift": "\nSELECT\n p_brand,\n p_type,\n AVG(p_retailprice) as avg_price\nFROM part\nGROUP BY p_brand, p_type\nORDER BY p_brand DESC, p_type DESC, avg_price DESC\nLIMIT 100;\n", "bigquery": "SELECT p_brand, p_type, AVG(p_retailprice) AS avg_price FROM part GROUP BY p_brand, p_type ORDER BY p_brand DESC, p_type DESC, avg_price DESC LIMIT 100;"}} - {"id": "array_agg_simple", "category": "array", "sql": "\n-- Aggregate part keys into arrays per supplier\nSELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey ORDER BY ps_partkey) as supplied_parts,\n COUNT(*) as part_count\nFROM partsupp\nGROUP BY ps_suppkey\nHAVING COUNT(*) <= 100\nORDER BY ps_suppkey\nLIMIT 100;\n", "result_contract": {"capability": "ordered_array_aggregation", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", {"name": "supplied_parts", "type_class": "array", "order_sensitive": true}, "part_count"]}, "variants": {"clickhouse": "\nSELECT\n ps_suppkey,\n arraySort(groupArray(ps_partkey)) as supplied_parts,\n COUNT(*) as part_count\nFROM partsupp\nGROUP BY ps_suppkey\nHAVING COUNT(*) <= 100\nORDER BY ps_suppkey\nLIMIT 100;\n"}, "skip_on": ["redshift"]} - {"id": "array_agg_distinct", "category": "array", "sql": "\n-- Distinct array aggregation\nSELECT\n c_mktsegment,\n ARRAY_AGG(DISTINCT c_nationkey ORDER BY c_nationkey) as nation_keys\nFROM customer\nGROUP BY c_mktsegment\nORDER BY c_mktsegment;\n", "result_contract": {"capability": "ordered_distinct_array_aggregation", "row_identity": ["c_mktsegment"], "columns": ["c_mktsegment", {"name": "nation_keys", "type_class": "array", "order_sensitive": true}]}, "variants": {"clickhouse": "\nSELECT\n c_mktsegment,\n arraySort(groupUniqArray(c_nationkey)) as nation_keys\nFROM customer\nGROUP BY c_mktsegment\nORDER BY c_mktsegment;\n"}, "skip_on": ["redshift"]} - {"id": "array_unnest", "category": "array", "sql": "\n-- Unnest/explode array back to rows\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n UNNEST(parts) as part_key\nFROM supplier_parts;\n", "result_contract": {"capability": "array_unnest", "row_identity": ["ps_suppkey", "part_key"], "columns": ["ps_suppkey", "part_key"]}, "variants": {"bigquery": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n part_key\nFROM supplier_parts, UNNEST(parts) as part_key;\n", "clickhouse": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n groupArray(ps_partkey) as parts\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n arrayJoin(parts) as part_key\nFROM supplier_parts;\n"}, "skip_on": ["redshift"]} - {"id": "array_contains", "category": "array", "sql": "\n-- Check if array contains a value\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n ARRAY_CONTAINS(parts, 100) as has_part_100\nFROM supplier_parts\nORDER BY ps_suppkey\nLIMIT 100;\n", "result_contract": {"capability": "array_membership_predicate", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", "has_part_100"]}, "variants": {"duckdb": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n list_contains(parts, 100) as has_part_100\nFROM supplier_parts\nORDER BY ps_suppkey\nLIMIT 100;\n", "clickhouse": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n groupArray(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n has(parts, 100) as has_part_100\nFROM supplier_parts\nORDER BY ps_suppkey\nLIMIT 100;\n", "bigquery": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n 100 IN UNNEST(parts) as has_part_100\nFROM supplier_parts\nORDER BY ps_suppkey\nLIMIT 100;\n"}, "skip_on": ["datafusion", "redshift"]} -- {"id": "array_length", "category": "array", "sql": "\n-- Get array length/cardinality\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n CARDINALITY(parts) as num_parts\nFROM supplier_parts\nORDER BY num_parts DESC, ps_suppkey\nLIMIT 100;\n", "result_contract": {"capability": "array_cardinality", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", "num_parts"]}, "variants": {"duckdb": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n len(parts) as num_parts\nFROM supplier_parts\nORDER BY num_parts DESC, ps_suppkey\nLIMIT 100;\n", "clickhouse": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n groupArray(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n length(parts) as num_parts\nFROM supplier_parts\nORDER BY num_parts DESC, ps_suppkey\nLIMIT 100;\n"}, "skip_on": ["datafusion", "redshift"]} +- {"id": "array_length", "category": "array", "sql": "\n-- Get array length/cardinality\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n CARDINALITY(parts) as num_parts\nFROM supplier_parts\nORDER BY num_parts DESC, ps_suppkey\nLIMIT 100;\n", "result_contract": {"capability": "array_cardinality", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", "num_parts"]}, "variants": {"duckdb": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n ARRAY_AGG(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n len(parts) as num_parts\nFROM supplier_parts\nORDER BY num_parts DESC, ps_suppkey\nLIMIT 100;\n", "clickhouse": "\nWITH supplier_parts AS (\n SELECT\n ps_suppkey,\n groupArray(ps_partkey) as parts\n FROM partsupp\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n length(parts) as num_parts\nFROM supplier_parts\nORDER BY num_parts DESC, ps_suppkey\nLIMIT 100;\n", "snowflake": "WITH supplier_parts AS (\n SELECT ps_suppkey, ARRAY_AGG(ps_partkey) AS parts\n FROM partsupp GROUP BY ps_suppkey\n)\nSELECT ps_suppkey, ARRAY_SIZE(parts) AS num_parts\nFROM supplier_parts\nORDER BY num_parts DESC, ps_suppkey\nLIMIT 100;", "bigquery": "WITH supplier_parts AS (\n SELECT ps_suppkey, ARRAY_AGG(ps_partkey) AS parts\n FROM partsupp GROUP BY ps_suppkey\n)\nSELECT ps_suppkey, ARRAY_LENGTH(parts) AS num_parts\nFROM supplier_parts\nORDER BY num_parts DESC, ps_suppkey\nLIMIT 100;"}, "skip_on": ["datafusion", "redshift"]} - {"id": "array_slice", "category": "array", "sql": "\n-- Get array slice/subset\nWITH order_prices AS (\n SELECT\n o_custkey,\n ARRAY_AGG(o_totalprice ORDER BY o_totalprice DESC) as prices\n FROM orders\n GROUP BY o_custkey\n HAVING COUNT(*) >= 5\n)\nSELECT\n o_custkey,\n ARRAY_SLICE(prices, 1, 3) as top_3_orders\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n", "result_contract": {"capability": "ordered_array_slice", "row_identity": ["o_custkey"], "columns": ["o_custkey", {"name": "top_3_orders", "type_class": "array", "order_sensitive": true}]}, "variants": {"duckdb": "\nWITH order_prices AS (\n SELECT\n o_custkey,\n ARRAY_AGG(o_totalprice ORDER BY o_totalprice DESC) as prices\n FROM orders\n GROUP BY o_custkey\n HAVING COUNT(*) >= 5\n)\nSELECT\n o_custkey,\n prices[1:3] as top_3_orders\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n", "clickhouse": "\nWITH order_prices AS (\n SELECT\n o_custkey,\n arrayReverseSort(groupArray(o_totalprice)) as prices\n FROM orders\n GROUP BY o_custkey\n HAVING COUNT(*) >= 5\n)\nSELECT\n o_custkey,\n arraySlice(prices, 1, 3) as top_3_orders\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n"}, "skip_on": ["redshift"]} - {"id": "array_min_max", "category": "array", "sql": "\n-- Get min/max from array\nWITH order_prices AS (\n SELECT\n o_custkey,\n ARRAY_AGG(o_totalprice) as prices\n FROM orders\n GROUP BY o_custkey\n)\nSELECT\n o_custkey,\n ARRAY_MIN(prices) as min_order,\n ARRAY_MAX(prices) as max_order\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n", "result_contract": {"capability": "array_min_max", "row_identity": ["o_custkey"], "columns": ["o_custkey", "min_order", "max_order"]}, "variants": {"duckdb": "\nWITH order_prices AS (\n SELECT\n o_custkey,\n ARRAY_AGG(o_totalprice) as prices\n FROM orders\n GROUP BY o_custkey\n)\nSELECT\n o_custkey,\n list_min(prices) as min_order,\n list_max(prices) as max_order\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n", "clickhouse": "\nWITH order_prices AS (\n SELECT\n o_custkey,\n groupArray(o_totalprice) as prices\n FROM orders\n GROUP BY o_custkey\n)\nSELECT\n o_custkey,\n arrayMin(prices) as min_order,\n arrayMax(prices) as max_order\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n"}, "skip_on": ["bigquery", "datafusion", "redshift"]} - {"id": "array_sort", "category": "array", "sql": "\n-- Sort array elements\nWITH part_sizes AS (\n SELECT\n p_brand,\n ARRAY_AGG(p_size) as sizes\n FROM part\n GROUP BY p_brand\n)\nSELECT\n p_brand,\n ARRAY_SORT(sizes) as sorted_sizes\nFROM part_sizes\nORDER BY p_brand\nLIMIT 50;\n", "result_contract": {"capability": "array_sort", "row_identity": ["p_brand"], "columns": ["p_brand", {"name": "sorted_sizes", "type_class": "array", "order_sensitive": true}]}, "variants": {"duckdb": "\nWITH part_sizes AS (\n SELECT\n p_brand,\n ARRAY_AGG(p_size) as sizes\n FROM part\n GROUP BY p_brand\n)\nSELECT\n p_brand,\n list_sort(sizes) as sorted_sizes\nFROM part_sizes\nORDER BY p_brand\nLIMIT 50;\n", "clickhouse": "\nWITH part_sizes AS (\n SELECT\n p_brand,\n groupArray(p_size) as sizes\n FROM part\n GROUP BY p_brand\n)\nSELECT\n p_brand,\n arraySort(sizes) as sorted_sizes\nFROM part_sizes\nORDER BY p_brand\nLIMIT 50;\n"}, "skip_on": ["bigquery", "redshift"]} - {"id": "array_distinct", "category": "array", "sql": "\n-- Get distinct array elements\nWITH ship_modes AS (\n SELECT\n l_orderkey,\n ARRAY_AGG(l_shipmode) as modes\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n ARRAY_SORT(ARRAY_DISTINCT(modes)) as unique_modes\nFROM ship_modes\nORDER BY l_orderkey\nLIMIT 100;\n", "result_contract": {"capability": "distinct_array_values", "row_identity": ["l_orderkey"], "columns": ["l_orderkey", {"name": "unique_modes", "type_class": "array"}]}, "variants": {"duckdb": "\nWITH ship_modes AS (\n SELECT\n l_orderkey,\n ARRAY_AGG(l_shipmode) as modes\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n list_sort(list_distinct(modes)) as unique_modes\nFROM ship_modes\nORDER BY l_orderkey\nLIMIT 100;\n", "clickhouse": "\nWITH ship_modes AS (\n SELECT\n l_orderkey,\n groupArray(l_shipmode) as modes\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n arraySort(arrayDistinct(modes)) as unique_modes\nFROM ship_modes\nORDER BY l_orderkey\nLIMIT 100;\n"}, "skip_on": ["bigquery", "datafusion", "redshift"]} - {"id": "struct_construction", "category": "struct", "sql": "\n-- Construct struct/row from columns\nSELECT\n c_custkey,\n STRUCT(c_name, c_address, c_phone) as contact_info,\n c_acctbal\nFROM customer\nWHERE c_nationkey = 1\nORDER BY c_custkey\nLIMIT 100;\n", "result_contract": {"capability": "positional_struct_construction", "row_identity": ["c_custkey"], "columns": ["c_custkey", {"name": "contact_info", "type_class": "struct"}, "c_acctbal"]}, "variants": {"clickhouse": "\nSELECT\n c_custkey,\n tuple(c_name, c_address, c_phone) as contact_info,\n c_acctbal\nFROM customer\nWHERE c_nationkey = 1\nORDER BY c_custkey\nLIMIT 100;\n", "duckdb": "\nSELECT\n c_custkey,\n ROW(c_name, c_address, c_phone) as contact_info,\n c_acctbal\nFROM customer\nWHERE c_nationkey = 1\nORDER BY c_custkey\nLIMIT 100;\n"}, "skip_on": ["redshift"]} -- {"id": "struct_access", "category": "struct", "sql": "\n-- Access struct fields\nWITH customer_info AS (\n SELECT\n c_custkey,\n STRUCT(c_name AS name, c_phone AS phone, c_acctbal AS balance) as info\n FROM customer\n WHERE c_nationkey = 1\n)\nSELECT\n c_custkey,\n info.name,\n info.balance\nFROM customer_info\nWHERE info.balance > 5000\nORDER BY c_custkey\nLIMIT 100;\n", "result_contract": {"capability": "struct_field_access", "row_identity": ["c_custkey"], "columns": ["c_custkey", "name", "balance"]}, "variants": {"clickhouse": "\nWITH customer_info AS (\n SELECT\n c_custkey,\n tuple(c_name, c_phone, c_acctbal) as info\n FROM customer\n WHERE c_nationkey = 1\n)\nSELECT\n c_custkey,\n info.1 as name,\n info.3 as balance\nFROM customer_info\nWHERE info.3 > 5000\nORDER BY c_custkey\nLIMIT 100;\n"}, "skip_on": ["redshift"]} +- {"id": "struct_access", "category": "struct", "sql": "\n-- Access struct fields\nWITH customer_info AS (\n SELECT\n c_custkey,\n STRUCT(c_name AS name, c_phone AS phone, c_acctbal AS balance) as info\n FROM customer\n WHERE c_nationkey = 1\n)\nSELECT\n c_custkey,\n info.name,\n info.balance\nFROM customer_info\nWHERE info.balance > 5000\nORDER BY c_custkey\nLIMIT 100;\n", "result_contract": {"capability": "struct_field_access", "row_identity": ["c_custkey"], "columns": ["c_custkey", "name", "balance"]}, "variants": {"clickhouse": "\nWITH customer_info AS (\n SELECT\n c_custkey,\n tuple(c_name, c_phone, c_acctbal) as info\n FROM customer\n WHERE c_nationkey = 1\n)\nSELECT\n c_custkey,\n info.1 as name,\n info.3 as balance\nFROM customer_info\nWHERE info.3 > 5000\nORDER BY c_custkey\nLIMIT 100;\n", "snowflake": "WITH customer_info AS (\n SELECT c_custkey,\n OBJECT_CONSTRUCT('name', c_name, 'phone', c_phone, 'balance', c_acctbal) AS info\n FROM customer\n WHERE c_nationkey = 1\n)\nSELECT c_custkey,\n info:name::STRING AS name,\n info:balance::DECIMAL(15, 2) AS balance\nFROM customer_info\nWHERE info:balance::DECIMAL(15, 2) > 5000\nORDER BY c_custkey\nLIMIT 100;"}, "skip_on": ["redshift"]} - {"id": "array_of_struct", "category": "struct", "sql": "\n-- Array of structs - orders with line items summary\nSELECT\n o_orderkey,\n ARRAY_AGG(\n STRUCT(l_linenumber, l_partkey, l_quantity, l_extendedprice)\n ORDER BY l_linenumber\n ) as line_items\nFROM orders o\nJOIN lineitem l ON o.o_orderkey = l.l_orderkey\nWHERE o_orderdate = DATE '1995-03-15'\nGROUP BY o_orderkey\nORDER BY o_orderkey\nLIMIT 50;\n", "result_contract": {"capability": "ordered_array_of_structs", "row_identity": ["o_orderkey"], "columns": ["o_orderkey", {"name": "line_items", "type_class": "array", "order_sensitive": true}]}, "variants": {"duckdb": "\nSELECT\n o_orderkey,\n ARRAY_AGG(\n struct_pack(\n l_linenumber := l_linenumber,\n l_partkey := l_partkey,\n l_quantity := l_quantity,\n l_extendedprice := l_extendedprice\n )\n ORDER BY l_linenumber\n ) as line_items\nFROM orders o\nJOIN lineitem l ON o.o_orderkey = l.l_orderkey\nWHERE o_orderdate = DATE '1995-03-15'\nGROUP BY o_orderkey\nORDER BY o_orderkey\nLIMIT 50;\n", "clickhouse": "\nSELECT\n o_orderkey,\n arraySort(\n x -> x.1,\n groupArray(\n CAST(\n (l_linenumber, l_partkey, l_quantity, l_extendedprice),\n 'Tuple(l_linenumber Int32, l_partkey Int32, l_quantity Decimal(15,2), l_extendedprice Decimal(15,2))'\n )\n )\n ) as line_items\nFROM orders o\nJOIN lineitem l ON o.o_orderkey = l.l_orderkey\nWHERE o_orderdate = '1995-03-15'\nGROUP BY o_orderkey\nORDER BY o_orderkey\nLIMIT 50;\n"}, "skip_on": ["redshift"]} -- {"id": "map_construction", "category": "map", "sql": "\n-- Construct map from key-value pairs\nSELECT\n ps_suppkey,\n MAP_FROM_ENTRIES(\n ARRAY_AGG(STRUCT(CAST(ps_partkey AS VARCHAR), ps_supplycost))\n ) as part_costs\nFROM partsupp\nWHERE ps_suppkey <= 10\nGROUP BY ps_suppkey\nORDER BY ps_suppkey;\n", "result_contract": {"capability": "map_construction", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", {"name": "part_costs", "type_class": "map"}]}, "variants": {"duckdb": "\nSELECT\n ps_suppkey,\n map_from_entries(\n list(struct_pack(k := CAST(ps_partkey AS VARCHAR), v := ps_supplycost))\n ) as part_costs\nFROM partsupp\nWHERE ps_suppkey <= 10\nGROUP BY ps_suppkey\nORDER BY ps_suppkey;\n", "datafusion": "\nSELECT\n ps_suppkey,\n MAP(\n ARRAY_AGG(CAST(ps_partkey AS VARCHAR)),\n ARRAY_AGG(ps_supplycost)\n ) as part_costs\nFROM partsupp\nWHERE ps_suppkey <= 10\nGROUP BY ps_suppkey\nORDER BY ps_suppkey;\n", "clickhouse": "\nSELECT\n ps_suppkey,\n CAST(\n (groupArray(CAST(ps_partkey AS String)), groupArray(ps_supplycost)),\n 'Map(String, Decimal(15,2))'\n ) as part_costs\nFROM partsupp\nWHERE ps_suppkey <= 10\nGROUP BY ps_suppkey\nORDER BY ps_suppkey;\n"}, "skip_on": ["bigquery", "redshift"]} -- {"id": "map_access", "category": "map", "sql": "\n-- Access map values by key\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n MAP_FROM_ENTRIES(\n ARRAY_AGG(STRUCT(CAST(ps_partkey AS VARCHAR), ps_supplycost))\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n part_costs['1'] as cost_for_part_1,\n part_costs['5'] as cost_for_part_5\nFROM supplier_costs\nORDER BY ps_suppkey\nLIMIT 10;\n", "result_contract": {"capability": "map_key_access", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", "cost_for_part_1", "cost_for_part_5"]}, "variants": {"datafusion": "\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n MAP(\n ARRAY_AGG(CAST(ps_partkey AS VARCHAR)),\n ARRAY_AGG(ps_supplycost)\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n MAP_EXTRACT(part_costs, '1')[1] as cost_for_part_1,\n MAP_EXTRACT(part_costs, '5')[1] as cost_for_part_5\nFROM supplier_costs\nORDER BY ps_suppkey\nLIMIT 10;\n", "clickhouse": "\n-- ClickHouse builds Map via CAST((Array(K), Array(V)) AS Map(K, V));\n-- match the map_construction / map_keys_values convention.\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n CAST(\n (groupArray(CAST(ps_partkey AS String)), groupArray(ps_supplycost)),\n 'Map(String, Decimal(15,2))'\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n part_costs['1'] as cost_for_part_1,\n part_costs['5'] as cost_for_part_5\nFROM supplier_costs\nLIMIT 10;\n"}, "skip_on": ["bigquery", "redshift"]} -- {"id": "map_keys_values", "category": "map", "sql": "\n-- Extract map keys and values\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n MAP_FROM_ENTRIES(\n ARRAY_AGG(STRUCT(CAST(ps_partkey AS VARCHAR), ps_supplycost))\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 5\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n MAP_KEYS(part_costs) as part_ids,\n MAP_VALUES(part_costs) as costs\nFROM supplier_costs\nORDER BY ps_suppkey;\n", "result_contract": {"capability": "map_keys_values", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", {"name": "part_ids", "type_class": "array"}, {"name": "costs", "type_class": "array"}]}, "variants": {"datafusion": "\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n MAP(\n ARRAY_AGG(CAST(ps_partkey AS VARCHAR)),\n ARRAY_AGG(ps_supplycost)\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 5\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n MAP_KEYS(part_costs) as part_ids,\n MAP_VALUES(part_costs) as costs\nFROM supplier_costs\nORDER BY ps_suppkey;\n", "clickhouse": "\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n CAST(\n (groupArray(CAST(ps_partkey AS String)), groupArray(ps_supplycost)),\n 'Map(String, Decimal(15,2))'\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 5\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n mapKeys(part_costs) as part_ids,\n mapValues(part_costs) as costs\nFROM supplier_costs\nORDER BY ps_suppkey;\n"}, "skip_on": ["bigquery", "redshift"]} +- {"id": "map_construction", "category": "map", "sql": "\n-- Construct map from key-value pairs\nSELECT\n ps_suppkey,\n MAP_FROM_ENTRIES(\n ARRAY_AGG(STRUCT(CAST(ps_partkey AS VARCHAR), ps_supplycost))\n ) as part_costs\nFROM partsupp\nWHERE ps_suppkey <= 10\nGROUP BY ps_suppkey\nORDER BY ps_suppkey;\n", "result_contract": {"capability": "map_construction", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", {"name": "part_costs", "type_class": "map"}]}, "variants": {"duckdb": "\nSELECT\n ps_suppkey,\n map_from_entries(\n list(struct_pack(k := CAST(ps_partkey AS VARCHAR), v := ps_supplycost))\n ) as part_costs\nFROM partsupp\nWHERE ps_suppkey <= 10\nGROUP BY ps_suppkey\nORDER BY ps_suppkey;\n", "datafusion": "\nSELECT\n ps_suppkey,\n MAP(\n ARRAY_AGG(CAST(ps_partkey AS VARCHAR)),\n ARRAY_AGG(ps_supplycost)\n ) as part_costs\nFROM partsupp\nWHERE ps_suppkey <= 10\nGROUP BY ps_suppkey\nORDER BY ps_suppkey;\n", "clickhouse": "\nSELECT\n ps_suppkey,\n CAST(\n (groupArray(CAST(ps_partkey AS String)), groupArray(ps_supplycost)),\n 'Map(String, Decimal(15,2))'\n ) as part_costs\nFROM partsupp\nWHERE ps_suppkey <= 10\nGROUP BY ps_suppkey\nORDER BY ps_suppkey;\n"}, "skip_on": ["bigquery", "redshift", "snowflake"], "description": "Snowflake OBJECT_AGG produces an OBJECT rather than the native map required by this result contract."} +- {"id": "map_access", "category": "map", "sql": "\n-- Access map values by key\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n MAP_FROM_ENTRIES(\n ARRAY_AGG(STRUCT(CAST(ps_partkey AS VARCHAR), ps_supplycost))\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n part_costs['1'] as cost_for_part_1,\n part_costs['5'] as cost_for_part_5\nFROM supplier_costs\nORDER BY ps_suppkey\nLIMIT 10;\n", "result_contract": {"capability": "map_key_access", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", "cost_for_part_1", "cost_for_part_5"]}, "variants": {"datafusion": "\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n MAP(\n ARRAY_AGG(CAST(ps_partkey AS VARCHAR)),\n ARRAY_AGG(ps_supplycost)\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n MAP_EXTRACT(part_costs, '1')[1] as cost_for_part_1,\n MAP_EXTRACT(part_costs, '5')[1] as cost_for_part_5\nFROM supplier_costs\nORDER BY ps_suppkey\nLIMIT 10;\n", "clickhouse": "\n-- ClickHouse builds Map via CAST((Array(K), Array(V)) AS Map(K, V));\n-- match the map_construction / map_keys_values convention.\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n CAST(\n (groupArray(CAST(ps_partkey AS String)), groupArray(ps_supplycost)),\n 'Map(String, Decimal(15,2))'\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 10\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n part_costs['1'] as cost_for_part_1,\n part_costs['5'] as cost_for_part_5\nFROM supplier_costs\nLIMIT 10;\n"}, "skip_on": ["bigquery", "redshift", "snowflake"], "description": "Snowflake OBJECT key access would replace native map lookup with a different measured capability."} +- {"id": "map_keys_values", "category": "map", "sql": "\n-- Extract map keys and values\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n MAP_FROM_ENTRIES(\n ARRAY_AGG(STRUCT(CAST(ps_partkey AS VARCHAR), ps_supplycost))\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 5\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n MAP_KEYS(part_costs) as part_ids,\n MAP_VALUES(part_costs) as costs\nFROM supplier_costs\nORDER BY ps_suppkey;\n", "result_contract": {"capability": "map_keys_values", "row_identity": ["ps_suppkey"], "columns": ["ps_suppkey", {"name": "part_ids", "type_class": "array"}, {"name": "costs", "type_class": "array"}]}, "variants": {"datafusion": "\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n MAP(\n ARRAY_AGG(CAST(ps_partkey AS VARCHAR)),\n ARRAY_AGG(ps_supplycost)\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 5\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n MAP_KEYS(part_costs) as part_ids,\n MAP_VALUES(part_costs) as costs\nFROM supplier_costs\nORDER BY ps_suppkey;\n", "clickhouse": "\nWITH supplier_costs AS (\n SELECT\n ps_suppkey,\n CAST(\n (groupArray(CAST(ps_partkey AS String)), groupArray(ps_supplycost)),\n 'Map(String, Decimal(15,2))'\n ) as part_costs\n FROM partsupp\n WHERE ps_suppkey <= 5\n GROUP BY ps_suppkey\n)\nSELECT\n ps_suppkey,\n mapKeys(part_costs) as part_ids,\n mapValues(part_costs) as costs\nFROM supplier_costs\nORDER BY ps_suppkey;\n"}, "skip_on": ["bigquery", "redshift", "snowflake"], "description": "Snowflake OBJECT keys and values do not satisfy the native map capability in this result contract."} - {"id": "list_transform", "category": "lambda", "sql": "\n-- Transform each array element\nWITH part_prices AS (\n SELECT\n p_brand,\n ARRAY_AGG(p_retailprice) as prices\n FROM part\n GROUP BY p_brand\n)\nSELECT\n p_brand,\n TRANSFORM(prices, x -> x * 1.1) as prices_with_tax\nFROM part_prices\nORDER BY p_brand\nLIMIT 50;\n", "result_contract": {"capability": "array_lambda_transform", "row_identity": ["p_brand"], "columns": ["p_brand", {"name": "prices_with_tax", "type_class": "array"}]}, "variants": {"duckdb": "\nWITH part_prices AS (\n SELECT\n p_brand,\n ARRAY_AGG(p_retailprice) as prices\n FROM part\n GROUP BY p_brand\n)\nSELECT\n p_brand,\n list_transform(prices, x -> x * 1.1) as prices_with_tax\nFROM part_prices\nORDER BY p_brand\nLIMIT 50;\n", "clickhouse": "\nWITH part_prices AS (\n SELECT\n p_brand,\n groupArray(p_retailprice) as prices\n FROM part\n GROUP BY p_brand\n)\nSELECT\n p_brand,\n arrayMap(x -> x * 1.1, prices) as prices_with_tax\nFROM part_prices\nORDER BY p_brand\nLIMIT 50;\n"}, "skip_on": ["bigquery", "datafusion", "redshift"]} - {"id": "list_filter", "category": "lambda", "sql": "\n-- Filter array elements by condition\nWITH order_prices AS (\n SELECT\n o_custkey,\n ARRAY_AGG(o_totalprice) as prices\n FROM orders\n GROUP BY o_custkey\n)\nSELECT\n o_custkey,\n FILTER(prices, x -> x > 100000) as large_orders\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n", "result_contract": {"capability": "array_lambda_filter", "row_identity": ["o_custkey"], "columns": ["o_custkey", {"name": "large_orders", "type_class": "array"}]}, "variants": {"duckdb": "\nWITH order_prices AS (\n SELECT\n o_custkey,\n ARRAY_AGG(o_totalprice) as prices\n FROM orders\n GROUP BY o_custkey\n)\nSELECT\n o_custkey,\n list_filter(prices, x -> x > 100000) as large_orders\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n", "clickhouse": "\nWITH order_prices AS (\n SELECT\n o_custkey,\n groupArray(o_totalprice) as prices\n FROM orders\n GROUP BY o_custkey\n)\nSELECT\n o_custkey,\n arrayFilter(x -> x > 100000, prices) as large_orders\nFROM order_prices\nORDER BY o_custkey\nLIMIT 100;\n"}, "skip_on": ["bigquery", "datafusion", "redshift"]} -- {"id": "list_reduce", "category": "lambda", "sql": "\n-- Reduce array to single value\nWITH quantities AS (\n SELECT\n l_orderkey,\n ARRAY_AGG(l_quantity) as qtys\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n REDUCE(qtys, 0, (acc, x) -> acc + x) as total_qty\nFROM quantities\nORDER BY l_orderkey\nLIMIT 100;\n", "result_contract": {"capability": "array_lambda_reduce", "row_identity": ["l_orderkey"], "columns": ["l_orderkey", "total_qty"]}, "variants": {"duckdb": "\nWITH quantities AS (\n SELECT\n l_orderkey,\n ARRAY_AGG(l_quantity) as qtys\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n list_reduce(qtys, (acc, x) -> acc + x) as total_qty\nFROM quantities\nORDER BY l_orderkey\nLIMIT 100;\n", "clickhouse": "\nWITH quantities AS (\n SELECT\n l_orderkey,\n groupArray(l_quantity) as qtys\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n arrayReduce('sum', qtys) as total_qty\nFROM quantities\nORDER BY l_orderkey\nLIMIT 100;\n"}, "skip_on": ["bigquery", "datafusion", "redshift"]} +- {"id": "list_reduce", "category": "lambda", "sql": "\n-- Reduce array to single value\nWITH quantities AS (\n SELECT\n l_orderkey,\n ARRAY_AGG(l_quantity) as qtys\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n REDUCE(qtys, 0, (acc, x) -> acc + x) as total_qty\nFROM quantities\nORDER BY l_orderkey\nLIMIT 100;\n", "result_contract": {"capability": "array_lambda_reduce", "row_identity": ["l_orderkey"], "columns": ["l_orderkey", "total_qty"]}, "variants": {"duckdb": "\nWITH quantities AS (\n SELECT\n l_orderkey,\n ARRAY_AGG(l_quantity) as qtys\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n list_reduce(qtys, (acc, x) -> acc + x) as total_qty\nFROM quantities\nORDER BY l_orderkey\nLIMIT 100;\n", "clickhouse": "\nWITH quantities AS (\n SELECT\n l_orderkey,\n groupArray(l_quantity) as qtys\n FROM lineitem\n GROUP BY l_orderkey\n)\nSELECT\n l_orderkey,\n arrayReduce('sum', qtys) as total_qty\nFROM quantities\nORDER BY l_orderkey\nLIMIT 100;\n"}, "skip_on": ["bigquery", "datafusion", "redshift", "databricks"]} - {"id": "asof_join_basic", "category": "timeseries", "sql": "\n-- ASOF join: find closest prior order for each lineitem shipment\nSELECT\n l.l_orderkey,\n l.l_shipdate,\n o.o_orderdate,\n o.o_totalprice,\n l.l_shipdate - o.o_orderdate as days_to_ship\nFROM lineitem l\nASOF JOIN orders o\n ON l.l_orderkey = o.o_orderkey\n AND l.l_shipdate >= o.o_orderdate\nWHERE l.l_shipdate >= DATE '1995-01-01'\n AND l.l_shipdate < DATE '1995-02-01'\nORDER BY l.l_orderkey, l.l_shipdate\nLIMIT 100;\n", "result_contract": {"capability": "asof_temporal_join", "columns": ["l_orderkey", "l_shipdate", "o_orderdate", "o_totalprice", "days_to_ship"]}, "variants": {"snowflake": "\nSELECT\n l.l_orderkey,\n l.l_shipdate,\n o.o_orderdate,\n o.o_totalprice,\n l.l_shipdate - o.o_orderdate as days_to_ship\nFROM lineitem l\nASOF JOIN orders o\n MATCH_CONDITION (l.l_shipdate >= o.o_orderdate)\n ON l.l_orderkey = o.o_orderkey\nWHERE l.l_shipdate >= DATE '1995-01-01'\n AND l.l_shipdate < DATE '1995-02-01'\nORDER BY l.l_orderkey, l.l_shipdate\nLIMIT 100;\n"}, "skip_on": ["bigquery", "datafusion", "databricks", "redshift"]} - {"id": "pivot_basic", "category": "pivot", "sql": "\n-- Pivot ship modes into columns\nSELECT *\nFROM (\n SELECT\n l_returnflag,\n l_shipmode,\n l_quantity\n FROM lineitem\n WHERE l_shipdate >= DATE '1995-01-01'\n AND l_shipdate < DATE '1995-04-01'\n)\nPIVOT (\n SUM(l_quantity)\n FOR l_shipmode IN ('AIR', 'RAIL', 'SHIP', 'TRUCK')\n);\n", "skip_on": ["clickhouse"]} - {"id": "unpivot_basic", "category": "pivot", "sql": "\n-- Unpivot part dimensions into rows\nSELECT\n p_partkey,\n dimension_name,\n dimension_value\nFROM (\n SELECT\n p_partkey,\n CAST(p_size AS DECIMAL(15, 2)) as p_size,\n p_retailprice\n FROM part\n WHERE p_partkey <= 100\n)\nUNPIVOT (\n dimension_value\n FOR dimension_name IN (p_size, p_retailprice)\n)\nORDER BY p_partkey, dimension_name;\n", "result_contract": {"capability": "unpivot_rows", "row_identity": ["p_partkey", "dimension_name"], "columns": ["p_partkey", "dimension_name", "dimension_value"]}, "variants": {"datafusion": "\nSELECT\n p_partkey,\n 'p_size' as dimension_name,\n CAST(p_size AS DECIMAL(15, 2)) as dimension_value\nFROM part\nWHERE p_partkey <= 100\nUNION ALL\nSELECT\n p_partkey,\n 'p_retailprice' as dimension_name,\n p_retailprice as dimension_value\nFROM part\nWHERE p_partkey <= 100\nORDER BY p_partkey, dimension_name;\n"}, "skip_on": ["clickhouse"]} diff --git a/benchbox/core/read_primitives/generator.py b/benchbox/core/read_primitives/generator.py index 460505e64e..a3e46afc35 100644 --- a/benchbox/core/read_primitives/generator.py +++ b/benchbox/core/read_primitives/generator.py @@ -55,14 +55,14 @@ def __init__( scale_factor=scale_factor, output_dir=output_dir, verbose=verbose, **kwargs ) - def generate_data(self, tables: Optional[list[str]] = None) -> dict[str, str]: + def generate_data(self, tables: Optional[list[str]] = None) -> dict[str, str | list[str]]: """Generate Read Primitives data files using TPC-H data generator. Args: tables: Optional list of table names to generate. If None, generates all tables. Returns: - Dictionary mapping table names to file paths + Dictionary mapping table names to a file path or an ordered list of shard paths. """ # Use centralized cloud/local generation handler @@ -80,8 +80,9 @@ def local_generate_func(output_dir: Path) -> dict[str, TablePaths]: # shares TPC-H data. Validation engines should accept TPC-H manifests # for Read Primitives benchmarks since they use the same data. - # Convert Path objects to strings for compatibility - return {k: str(v) for k, v in result.items()} + # Preserve shard boundaries while converting paths for platform consumers. + # Stringifying a whole list produces a repr rather than usable file paths. + return {k: [str(path) for path in v] if isinstance(v, list) else str(v) for k, v in result.items()} def _generate_data_local(self, output_dir: Path, tables: Optional[list[str]] = None) -> dict[str, TablePaths]: """Generate Read Primitives data files locally using TPC-H data generator.""" diff --git a/benchbox/core/results/anonymization.py b/benchbox/core/results/anonymization.py index 63079907a2..eb7efe260e 100644 --- a/benchbox/core/results/anonymization.py +++ b/benchbox/core/results/anonymization.py @@ -55,6 +55,24 @@ def _load_anonymization_specs() -> dict[str, Any]: # Unread identifier fields: omit at the public boundary rather than publish a # confirmable pseudonym. Compact forms; see anonymization_specs.yaml. _PUBLIC_DROP_KEYS = frozenset(_ANONYMIZATION_SPECS["public_drop_keys"]) +# ``config.platform_option_sources`` maps each option to where its value came +# from. These labels are the closed MetadataSource vocabulary in +# benchbox/core/results/environment.py, not identifiers, so they stay readable +# even when the option key itself (database, default_output_location, ...) +# would be pseudonymised. A test keeps this set equal to that Literal. +_OPTION_SOURCE_LABELS = frozenset( + { + "registered_default", + "saved_config", + "environment_variable", + "cli_option", + "runtime_override", + "requested", + "observed", + "inferred", + "unavailable", + } +) # Optional nested maps that collapse to `{}` after drop keys are removed. # Omit the empty block rather than publishing a hollow object (e.g. client_host # that only held machine_id). Compact forms match ``_compact_key``. @@ -713,6 +731,14 @@ def _anonymize_public_scalar(self, value: Any, key_path: tuple[str, ...]) -> Any if value in (None, ""): return value + if ( + isinstance(value, str) + and value in _OPTION_SOURCE_LABELS + and len(key_path) >= 2 + and _compact_key(key_path[-2]) == "platformoptionsources" + ): + return value + if isinstance(value, str) and self._looks_like_connection_string(value): return PUBLIC_REDACTED_VALUE diff --git a/benchbox/core/results/anonymization_specs.yaml b/benchbox/core/results/anonymization_specs.yaml index bf9d8e5598..aa84c11367 100644 --- a/benchbox/core/results/anonymization_specs.yaml +++ b/benchbox/core/results/anonymization_specs.yaml @@ -64,6 +64,8 @@ identifier_keys: workspacename: workspace warehouse: warehouse warehousename: warehouse + # Databricks SQL warehouse id (platform config, compute and raw metadata). + warehouseid: warehouse workgroup: workgroup workgroupname: workgroup cluster: cluster @@ -71,6 +73,13 @@ identifier_keys: clustername: cluster database: database databasename: database + # BigQuery datasets and Databricks Unity Catalog namespaces. Plain `catalog` + # is deliberately absent: local bundles use it for an engine catalog type. + dataset: dataset + datasetid: dataset + uccatalog: catalog + ucschema: schema + ucvolume: volume db: database dbname: database schema: schema @@ -122,6 +131,8 @@ path_keys: - httppath - outputdirectory - outputlocation +# Saved cloud output location (gs://, s3://, @stage); names a private bucket or stage. +- defaultoutputlocation - outputpath - path - s3stagingurl diff --git a/benchbox/core/results/benchmark_specs.yaml b/benchbox/core/results/benchmark_specs.yaml index 283e50db7f..624f232b9e 100644 --- a/benchbox/core/results/benchmark_specs.yaml +++ b/benchbox/core/results/benchmark_specs.yaml @@ -835,3 +835,20 @@ benchmark_specs: requires_tables_object: false sf1_row_counts: null sf1_power_at_size_range: null + vector_search: + benchmark_id: vector_search + unique_query_ids: + - '1' + - '2' + - '3' + - '4' + - '5' + - '6' + min_unique_queries: 0 + min_success_rate: 1.0 + high_failure_expected: false + requires_tables_object: true + sf1_row_counts: + vectors: 1000000 + vector_queries: 100 + sf1_power_at_size_range: null diff --git a/benchbox/core/results/builder.py b/benchbox/core/results/builder.py index d0ba7fc6d1..85eadf1177 100644 --- a/benchbox/core/results/builder.py +++ b/benchbox/core/results/builder.py @@ -64,6 +64,7 @@ QueryResultInput, format_query_id, ) +from benchbox.core.results.query_status import has_failed_query_validation if TYPE_CHECKING: pass @@ -182,6 +183,7 @@ class RunConfigInput: table_format: str | None = None table_format_compression: str | None = None table_format_partition_cols: list[str] | None = None + query_parameters: str | None = None def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = {} @@ -213,6 +215,8 @@ def to_dict(self) -> dict[str, Any]: data["table_format_compression"] = self.table_format_compression if self.table_format_partition_cols: data["table_format_partition_cols"] = self.table_format_partition_cols + if self.query_parameters: + data["query_parameters"] = self.query_parameters return data @@ -645,7 +649,9 @@ def build(self) -> BenchmarkResults: # Determine validation status based on failures validation_status = self._validation_status - if failed_queries and validation_status == "PASSED": + if has_failed_query_validation(query_results_list): + validation_status = "FAILED" + elif failed_queries and validation_status == "PASSED": validation_status = "PARTIAL" return BenchmarkResults( diff --git a/benchbox/core/results/integrity_validator.py b/benchbox/core/results/integrity_validator.py index d147b66bab..f10e4bbc97 100644 --- a/benchbox/core/results/integrity_validator.py +++ b/benchbox/core/results/integrity_validator.py @@ -264,16 +264,22 @@ def _check_query_count_math(self, raw: dict[str, Any], checks: list[CheckResult] total = sq.get("total", 0) passed = sq.get("passed", 0) failed = sq.get("failed", 0) - if passed + failed != total: + skipped = sq.get("skipped", 0) + if passed + failed + skipped != total: _fail( checks, _STRUCTURAL, "query_count_math", - f"{passed} passed + {failed} failed != {total} total", - {"passed": passed, "failed": failed, "total": total}, + f"{passed} passed + {failed} failed + {skipped} skipped != {total} total", + {"passed": passed, "failed": failed, "skipped": skipped, "total": total}, ) else: - _pass(checks, _STRUCTURAL, "query_count_math", f"{total} = {passed} passed + {failed} failed") + _pass( + checks, + _STRUCTURAL, + "query_count_math", + f"{total} = {passed} passed + {failed} failed + {skipped} skipped", + ) def _check_timing_non_negative(self, raw: dict[str, Any], checks: list[CheckResult]) -> None: timing = raw.get("summary", {}).get("timing", {}) @@ -500,11 +506,24 @@ def _check_success_rate(self, raw: dict[str, Any], spec: Any | None, checks: lis sq = raw.get("summary", {}).get("queries", {}) total = sq.get("total", 0) passed = sq.get("passed", 0) + skipped = sq.get("skipped", 0) if total == 0: _fail(checks, _BELIEVABILITY, "success_rate", "No queries recorded") return - rate = passed / total + # Compatibility-skipped queries (version-gated rules such as the + # StarRocks Q2 l2_distance skip) are not failures: discount them + # from both sides before applying the spec floor. + billable = total - skipped + if billable == 0: + _fail( + checks, + _BELIEVABILITY, + "success_rate", + "No queries executed; all queries skipped by compatibility rules", + ) + return + rate = passed / billable if spec is not None and spec.high_failure_expected: _pass( checks, @@ -521,10 +540,15 @@ def _check_success_rate(self, raw: dict[str, Any], spec: Any | None, checks: lis _BELIEVABILITY, "success_rate", f"{rate:.1%} success rate below {floor:.0%} floor", - {"rate": rate, "floor": floor, "passed": passed, "total": total}, + {"rate": rate, "floor": floor, "passed": passed, "total": billable, "skipped": skipped}, ) else: - _pass(checks, _BELIEVABILITY, "success_rate", f"{rate:.1%} >= {floor:.0%} floor") + _pass( + checks, + _BELIEVABILITY, + "success_rate", + f"{rate:.1%} >= {floor:.0%} floor ({skipped} skipped)", + ) def _check_sf1_row_counts( self, diff --git a/benchbox/core/results/loader.py b/benchbox/core/results/loader.py index 27d334ce12..624f2dd756 100644 --- a/benchbox/core/results/loader.py +++ b/benchbox/core/results/loader.py @@ -335,6 +335,7 @@ def reconstruct_benchmark_results( dataset_version=benchmark_section.get("dataset_version"), data_generation_version=_coerce_datagen_version(benchmark_section.get("data_generation_version")), data_generation_hash=_coerce_datagen_hash(benchmark_section.get("data_generation_hash")), + flightdata_source_provenance=benchmark_section.get("source_provenance"), manifest_hash=benchmark_section.get("manifest_hash"), data_archive_hash=benchmark_section.get("data_archive_hash"), funding=provenance_section.get("funding"), diff --git a/benchbox/core/results/models.py b/benchbox/core/results/models.py index c0c1f3f91d..50ce99c15f 100644 --- a/benchbox/core/results/models.py +++ b/benchbox/core/results/models.py @@ -464,6 +464,7 @@ class BenchmarkResults: # behind the data this result ran against. Distinguishes datasets whose specs changed without a # version bump. None for results predating the stamp. data_generation_hash: str | None = None + flightdata_source_provenance: dict[str, Any] | None = None # Optional detailed per-query timing info (for CSV export and analysis) per_query_timings: list[dict[str, Any]] | None = field(default_factory=list) # Optional detailed structures diff --git a/benchbox/core/results/query_status.py b/benchbox/core/results/query_status.py index b89cb5b3cb..adf76a316b 100644 --- a/benchbox/core/results/query_status.py +++ b/benchbox/core/results/query_status.py @@ -43,6 +43,17 @@ def int_or_none(value: Any) -> int | None: return None +def has_failed_query_validation(query_rows: Any) -> bool: + if not isinstance(query_rows, list): + return False + return any( + isinstance(query, dict) + and isinstance(evidence := query.get("row_count_validation"), dict) + and str(evidence.get("status") or "").strip().upper() == "FAILED" + for query in query_rows + ) + + def _query_row_failed(query: Any) -> bool: if not isinstance(query, dict): return False diff --git a/benchbox/core/results/result_factory.py b/benchbox/core/results/result_factory.py index d20f8d3a3a..8be503a0c8 100644 --- a/benchbox/core/results/result_factory.py +++ b/benchbox/core/results/result_factory.py @@ -54,6 +54,7 @@ def _apply_run_config(builder: ResultBuilder, execution_metadata: dict[str, Any] table_format=run_cfg.get("table_format"), table_format_compression=run_cfg.get("table_format_compression"), table_format_partition_cols=run_cfg.get("table_format_partition_cols"), + query_parameters=run_cfg.get("query_parameters"), ) ) diff --git a/benchbox/core/results/schema.py b/benchbox/core/results/schema.py index 40b154961e..305e310eda 100644 --- a/benchbox/core/results/schema.py +++ b/benchbox/core/results/schema.py @@ -483,6 +483,9 @@ def _build_benchmark_section(result: BenchmarkResults) -> dict[str, Any]: section["data_generation_version"] = result.data_generation_version if getattr(result, "data_generation_hash", None) is not None: section["data_generation_hash"] = result.data_generation_hash + source_provenance = getattr(result, "flightdata_source_provenance", None) + if source_provenance is not None: + section["source_provenance"] = source_provenance section.update(_dataset_identity_fields(result)) return section @@ -893,6 +896,7 @@ def _shorten_benchmark_name(name: str) -> str: "table_format", "table_format_compression", "table_format_partition_cols", + "query_parameters", ] diff --git a/benchbox/core/results/schema_specs.yaml b/benchbox/core/results/schema_specs.yaml index 0cb72a38b1..f7ce346622 100644 --- a/benchbox/core/results/schema_specs.yaml +++ b/benchbox/core/results/schema_specs.yaml @@ -46,6 +46,7 @@ config_key_order: - table_format - table_format_compression - table_format_partition_cols +- query_parameters - mode - test_type phase_key_order: diff --git a/benchbox/core/results/status.py b/benchbox/core/results/status.py index 80ccabe18d..237d91ab4a 100644 --- a/benchbox/core/results/status.py +++ b/benchbox/core/results/status.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from typing import Any -from benchbox.core.results.query_status import bundle_failed_query_count, int_or_none +from benchbox.core.results.query_status import bundle_failed_query_count, has_failed_query_validation, int_or_none NON_CLEAN_VALIDATION_STATUSES: frozenset[str] = frozenset( {"failed", "interrupted", "partial", "error", "not_run", "not_validated", "uncertain", "unknown"} @@ -70,15 +70,17 @@ def result_failed_query_count(result: Any) -> int: return 0 -def result_non_clean_reason(result: Any) -> str | None: - """Return a user-facing reason when a result is not a clean pass.""" +def _result_reason(result: Any, failing_validation_statuses: frozenset[str]) -> str | None: failed = result_failed_query_count(result) if failed: noun = "query" if failed == 1 else "queries" return f"{failed} failed {noun}" + if has_failed_query_validation(getattr(result, "query_results", None)): + return "query validation failed" + status = normalize_validation_status(getattr(result, "validation_status", None)) - if status in NON_CLEAN_VALIDATION_STATUSES: + if status in failing_validation_statuses: return f"validation_status={status}" translation_status = _result_translation_status(result) @@ -87,6 +89,11 @@ def result_non_clean_reason(result: Any) -> str | None: return None +def result_non_clean_reason(result: Any) -> str | None: + """Return a user-facing reason when a result is not a clean pass.""" + return _result_reason(result, NON_CLEAN_VALIDATION_STATUSES) + + def result_is_clean_pass(result: Any) -> bool: """Return True when a result has no query failures, non-clean validation, or translation fallback.""" return result_non_clean_reason(result) is None @@ -99,19 +106,7 @@ def result_cli_failure_reason(result: Any) -> str | None: executed (``not_run``/``not_validated``/...) keeps the bundle non-clean for publication, but does not fail the run itself. """ - failed = result_failed_query_count(result) - if failed: - noun = "query" if failed == 1 else "queries" - return f"{failed} failed {noun}" - - status = normalize_validation_status(getattr(result, "validation_status", None)) - if status in CLI_FAILURE_VALIDATION_STATUSES: - return f"validation_status={status}" - - translation_status = _result_translation_status(result) - if translation_status in NON_CLEAN_TRANSLATION_STATUSES: - return f"translation_status={translation_status}" - return None + return _result_reason(result, CLI_FAILURE_VALIDATION_STATUSES) def result_unvalidated_reason(result: Any) -> str | None: @@ -125,7 +120,7 @@ def result_unvalidated_reason(result: Any) -> str | None: ``None`` when there are failed queries (those take precedence) or when the validation status is not in the unvalidated partition. """ - if result_failed_query_count(result): + if result_failed_query_count(result) or has_failed_query_validation(getattr(result, "query_results", None)): return None status = normalize_validation_status(getattr(result, "validation_status", None)) @@ -141,6 +136,9 @@ def bundle_non_clean_reason(data: dict[str, Any]) -> str | None: noun = "query" if failed == 1 else "queries" return f"{failed} failed {noun}" + if has_failed_query_validation(data.get("queries")): + return "query validation failed" + status = _bundle_validation_status(data) if status in NON_CLEAN_VALIDATION_STATUSES: return f"validation_status={status}" diff --git a/benchbox/core/run_service.py b/benchbox/core/run_service.py index f4d81bfeba..60631f83ea 100644 --- a/benchbox/core/run_service.py +++ b/benchbox/core/run_service.py @@ -68,6 +68,19 @@ def validate_stream_count(streams: int | None, phases: str | None = None) -> Non raise ValueError("--streams must be >= 2 (TPC throughput minimum); got: 1") +_DATAFRAME_THROUGHPUT_UNSUPPORTED = ( + "The throughput phase is not supported in DataFrame mode. DataFrame platforms run no " + "concurrent query streams, so a throughput request would only repeat the power-test " + "iterations under a throughput label and ignore --streams. Use --phases power for " + "DataFrame platforms, or run the throughput phase on a SQL platform." +) + + +def reject_unsupported_dataframe_phases(execution_mode: str | None, phases_to_run: list[str] | None) -> None: + if execution_mode == "dataframe" and phases_to_run and "throughput" in phases_to_run: + raise ValueError(_DATAFRAME_THROUGHPUT_UNSUPPORTED) + + from benchbox.core.platform_registry import PlatformRegistry from benchbox.core.results.driver_metadata import apply_driver_metadata from benchbox.core.runner.runner import ( @@ -333,6 +346,7 @@ def execute_run( phases = resolve_lifecycle_phases(phases_to_run) validation = resolve_validation_options(getattr(config, "options", None)) execution_mode = resolve_execution_mode(database_config) + reject_unsupported_dataframe_phases(execution_mode, phases_to_run) adapter = adapter_factory(execution_mode=execution_mode, output_root=output_root, phases=phases) @@ -476,6 +490,7 @@ def map_phases_to_execution_type(phases: list[str]) -> str: "get_execution_mode", "is_dataframe_execution", "map_phases_to_execution_type", + "reject_unsupported_dataframe_phases", "resolve_execution_mode", "resolve_lifecycle_phases", "resolve_mode_with_registry", diff --git a/benchbox/core/runner/runner.py b/benchbox/core/runner/runner.py index 5771747656..2117b3746e 100644 --- a/benchbox/core/runner/runner.py +++ b/benchbox/core/runner/runner.py @@ -35,6 +35,7 @@ as_sql_benchmark_executor, as_statistics_phase_runner, ) +from benchbox.core.loaded_tables import require_loaded_tables from benchbox.core.results.driver_metadata import apply_driver_metadata from benchbox.core.results.models import ( BenchmarkResults, @@ -99,6 +100,12 @@ def _resolve_manifest_allowed_names(benchmark: Any, config: BenchmarkConfig) -> allowed = {config.name.lower()} + # A benchmark that builds its own tables from the source data (tpcds_obt) + # must not accept the source manifest: reusing it loads the source tables + # instead of the benchmark's own. + if getattr(benchmark, "GENERATES_OWN_OUTPUT", False) is True: + return allowed + getter = getattr(benchmark, "get_data_source_benchmark", None) if callable(getter): try: @@ -411,8 +418,13 @@ def _attach_datagen_version( return result if not _manifest_matches_result(manifest, result, benchmark): return result + identity_matches = getattr(benchmark, "manifest_matches_datagen_identity", None) + if callable(identity_matches) and not identity_matches(manifest): + return result result.data_generation_version = manifest.get("data_generation_version") result.data_generation_hash = manifest.get("data_generation_identity_hash", manifest.get("base_constants_hash")) + if str(manifest.get("benchmark", "")).lower() == "flightdata": + result.flightdata_source_provenance = manifest.get("source_provenance") except Exception as exc: logger.debug("leaving data-generation provenance unset: %s", exc) return result @@ -539,6 +551,11 @@ def _execute_load_only_mode( postload_result: ValidationResult | None = None connection_lifecycle = as_connection_lifecycle(adapter) + # New run: allow the existing-database decision to be made once below. + # Without this reset, a reused adapter instance would skip first-connection + # reuse/recreate handling for the load-only run. + # adapter is typed as object, so direct assignment would fail type checking. + setattr(adapter, "_existing_db_decided", False) # noqa: B010 try: connection = connection_lifecycle.create_connection(**(platform_config or {})) @@ -572,6 +589,7 @@ def _execute_load_only_mode( native_loader = as_native_table_loader(adapter) schema_time = native_loader.create_schema(benchmark, connection) table_stats, load_time, per_table_timings = native_loader.load_data(benchmark, connection, data_dir) + require_loaded_tables(benchmark, table_stats) schema_phase = { "status": "COMPLETED", "duration_ms": int(schema_time * 1000), @@ -865,6 +883,7 @@ def _build_run_config_from_options( requested_phases = options.get("requested_phases") if requested_phases: run_options["requested_phases"] = list(requested_phases) + database_options = _database_platform_options(database_config) return RunConfig( benchmark=benchmark_config.name, query_subset=benchmark_config.queries, @@ -888,6 +907,17 @@ def _build_run_config_from_options( warm_up_iterations=max(0, warmups), power_fail_fast=bool(options.get("power_fail_fast", False)), capture_plans=benchmark_config.capture_plans, + # --show-plans travels in DatabaseConfig.options via the CLI runtime + # overrides (direct path) or as a DatabaseConfig extra (interactive + # path); both are collected by _database_platform_options. Read from + # the merged database options, not BenchmarkConfig, which has no + # display-only field by design. Tri-state (mirroring analyze_plans): + # absent means the adapter keeps its own value (e.g. from + # platform_config or a preconfigured adapter); only an explicit + # True/False overrides it. + show_query_plans=( + bool(database_options.get("show_query_plans")) if "show_query_plans" in database_options else None + ), analyze_plans=getattr(benchmark_config, "analyze_plans", None), strict_plan_capture=benchmark_config.strict_plan_capture, normalize_plan_literals=bool(options.get("normalize_plan_literals", False)), diff --git a/benchbox/core/schemas.py b/benchbox/core/schemas.py index e129ef1c68..fccded41cd 100644 --- a/benchbox/core/schemas.py +++ b/benchbox/core/schemas.py @@ -106,6 +106,16 @@ class RunConfig(BaseModel): options: dict[str, Any] = Field(default_factory=dict) enable_postload_validation: bool = False capture_plans: bool = False + # Display the EXPLAIN plan in the console after each query (CLI --show-plans). + # Runtime display-only flag: it is recorded in run-input provenance + # (config.platform_options, like other CLI inputs) but never enters + # plan-capture metadata, phases, or query results. Suppressed while + # capture_plans is active (see display_query_plan_if_enabled) to avoid + # issuing EXPLAIN twice. + # Tri-state, mirroring analyze_plans: None = not specified (the adapter's + # own show_query_plans value, e.g. from platform_config or a + # preconfigured adapter, applies); True/False = explicitly override it. + show_query_plans: bool | None = None # analyze_plans is the single capture-detail knob (ANALYZE vs estimated EXPLAIN). # Tri-state: None = not specified (the adapter default, True, applies); the # first-class --analyze-plans/--no-analyze-plans flag sets it True/False. diff --git a/benchbox/core/tpcdi/config.py b/benchbox/core/tpcdi/config.py index 5ae5afb611..bf0381c9e5 100644 --- a/benchbox/core/tpcdi/config.py +++ b/benchbox/core/tpcdi/config.py @@ -58,7 +58,12 @@ def __post_init__(self) -> None: self.output_dir = get_benchmark_runs_datagen_path("tpcdi", self.scale_factor) else: - self.output_dir = Path(self.output_dir) + # normalize_output_dir keeps a CloudStagingPath/DatabricksPath + # handler intact; Path(...) would stringify it to the local cache + # and drop the cloud upload target resolved at construction time. + from benchbox.utils.cloud_storage import normalize_output_dir + + self.output_dir = normalize_output_dir(self.output_dir) # Set sensible worker count if self.max_workers is None: diff --git a/benchbox/core/tpcds/benchmark/runner.py b/benchbox/core/tpcds/benchmark/runner.py index 95dc05a063..1f04881f42 100644 --- a/benchbox/core/tpcds/benchmark/runner.py +++ b/benchbox/core/tpcds/benchmark/runner.py @@ -221,6 +221,10 @@ def generate_data(self) -> list[Union[str, Path]]: def get_queries(self, dialect: Optional[str] = None, base_dialect: Optional[str] = None) -> dict[str, str]: """Get all TPC-DS benchmark queries. + Parameters are rendered at the data's scale factor, as get_query() does: dsqgen derives some + values from the scale (for example row-count thresholds), so SQL rendered at a different scale + would not match the generated data. + Args: dialect: Target SQL dialect for translation (e.g., 'duckdb', 'postgres') @@ -231,8 +235,8 @@ def get_queries(self, dialect: Optional[str] = None, base_dialect: Optional[str] src = (base_dialect or "netezza").lower() tgt = (dialect or src).lower() - # Generate queries using dsqgen in the base dialect - int_queries = self.query_manager.get_all_queries(dialect=src) + # Generate queries using dsqgen in the base dialect, at the data's scale factor + int_queries = self.query_manager.get_all_queries(dialect=src, scale_factor=self.data_generator.scale_factor) base_queries = {str(k): v for k, v in int_queries.items()} # Always pass through SQLGlot from base to target for consistency @@ -311,6 +315,9 @@ def _apply_target_dialect_overrides( if query_id == 90 and target in {"spark", "lakesail"}: return self._rewrite_spark_q90_zero_denominator(query) + if query_id == 90: + return self._rewrite_default_q90_zero_denominator(query) + if "clickhouse" not in target: return query @@ -344,6 +351,24 @@ def _rewrite_postgres_q90_zero_denominator(query: str) -> str: flags=re.IGNORECASE, ) + @staticmethod + def _rewrite_default_q90_zero_denominator(query: str) -> str: + """Guard Q90's PM count denominator on all other dialects. + + The canonical TPC-DS template divides by ``pmc`` with no zero guard, so + an empty PM bucket is engine-defined (DuckDB yields NaN, Postgres-style + engines error). Default every remaining dialect to NULLIF(pmc, 0) so the + zero-denominator result is consistently SQL NULL. + """ + return re.sub( + r'/\s*CAST\(\s*(?Ppmc|`pmc`|"pmc"|\[pmc\])\s+AS\s+' + r"(?P(?:DECIMAL|NUMERIC)\s*\(\s*15\s*,\s*4\s*\))\s*\)", + lambda match: f"/CAST(NULLIF({match.group('denominator')}, 0) AS {match.group('type')})", + query, + count=1, + flags=re.IGNORECASE, + ) + @staticmethod def _rewrite_postgres_rollup_order_aliases(query_id: int, query: str) -> str: """Replace TPC-DS rollup ORDER BY alias references inside expressions. diff --git a/benchbox/core/tpcds/c_tools.py b/benchbox/core/tpcds/c_tools.py index c2e2d09257..ae405dbe49 100644 --- a/benchbox/core/tpcds/c_tools.py +++ b/benchbox/core/tpcds/c_tools.py @@ -15,8 +15,9 @@ import sys import warnings from pathlib import Path -from typing import Any, Optional, Union +from typing import Any, Callable, Optional, Union +from benchbox.core.tpcds.parameter_log import TemplateParameters, parse_dsqgen_parameter_log from benchbox.utils.tpc_compilation import ( CompilationStatus, ensure_tpc_binaries, @@ -24,6 +25,22 @@ ) +def _option_prefix() -> str: + return "/" if sys.platform == "win32" else "-" + + +def tpcds_option(name: str) -> str: + """Return a dsdgen/dsqgen command-line option for the host platform. + + The Windows TPC-DS tools parse only options that start with ``/``; a ``-`` + prefixed option is ignored there, so ``-scale 0.01`` silently generated the + default scale factor 1 and ``-terminate n`` kept trailing separators. Other + platforms use ``-``. Option names are matched case-insensitively, so they are + passed in upper case everywhere. + """ + return f"{_option_prefix()}{name.upper()}" + + def _resolve_tpcds_tool_and_template_paths() -> tuple[Path, Path]: """Determine the preferred tool and template directories. @@ -71,6 +88,26 @@ def _resolve_tpcds_tool_and_template_paths() -> tuple[Path, Path]: return tools_path, templates_path +_PARAMETER_NAME_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\.(\d+)$") + + +def _substitute_parameters(template: str, parameters: dict[str, Any], query: str) -> str: + """Replace dsqgen ``[NAME.k]`` (and ``[NAME]`` for k == 1) tokens with the given values.""" + for key, value in parameters.items(): + name, index = _PARAMETER_NAME_RE.match(key).groups() + position = int(index) + text = str(value) + patterns = [rf"\[{re.escape(name)}\.{position}\]"] + if position == 1: + patterns.append(rf"\[{re.escape(name)}\]") + template, count = re.subn("|".join(patterns), lambda _match, text=text: text, template) + # dsqgen -LOG also reports values a template defines but does not use (Q10 draws ten counties and + # uses five), so a defined name is accepted; only a name the template does not define is a typo. + if count == 0 and not re.search(rf"\bdefine\s+{re.escape(name)}\s*=", template): + raise ValueError(f"Query {query} has no substitution for parameter {key!r}") + return template + + class TPCDSError(Exception): """Exception for TPC-DS operations.""" @@ -219,6 +256,69 @@ def generate( self._query_cache[cache_key] = result return result + def generate_parameter_log( + self, + query_id: Union[int, str], + *, + seed: Optional[int] = None, + scale_factor: float = 1.0, + stream_id: int = 0, + dialect: str = "netezza", + ) -> TemplateParameters: + """Return the parameters dsqgen substitutes for one template, via ``-LOG``. + + The values are those of the requested stream for the given seed and + scale. A stream's values do not depend on how many streams are + generated, so stream ``n`` is produced by asking dsqgen for ``n + 1``. + + ``stream_id`` is dsqgen's own ``-STREAMS`` stream. ``generate(..., stream_id=n)`` does not + select a stream (it renders stream 0), so for ``n > 0`` the values here do not match the + SQL ``generate`` returns; render the SQL for stream ``n`` from these values (or with + ``generate_dsqgen_streams``) when the two must agree. + + Raises: + TPCDSError: If dsqgen fails or its log lacks the requested stream. + ValueError: If query_id or stream_id is invalid. + """ + try: + base_query_id, variant = self._parse_query_id(query_id) + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid query_id: {e}") from e + if not (1 <= base_query_id <= 99): + raise ValueError(f"Query ID must be 1-99, got {base_query_id}") + if stream_id < 0: + raise ValueError(f"Stream ID must be >= 0, got {stream_id}") + + dialect = self._validate_dialect(dialect) + is_multi_part = base_query_id in (14, 23, 24, 39) and variant in ("a", "b") + cmd, opt = self._build_dsqgen_cmd( + base_query_id, + variant, + seed, + scale_factor, + dialect, + is_multi_part, + streams=stream_id + 1 if stream_id else None, + ) + try: + _, log_text = self._run_dsqgen_with_log(cmd, opt, base_query_id, variant, capture_log=True) + except subprocess.TimeoutExpired: + raise TPCDSError(f"dsqgen timed out for query {base_query_id}{variant or ''}") from None + except FileNotFoundError: + raise TPCDSError(f"dsqgen binary not found at {self.dsqgen_path}") from None + + try: + streams = parse_dsqgen_parameter_log(log_text or "") + except ValueError as e: + raise TPCDSError(f"Unreadable dsqgen parameter log for query {base_query_id}{variant or ''}: {e}") from e + templates = streams.get(stream_id, []) + if len(templates) != 1: + raise TPCDSError( + f"dsqgen parameter log for query {base_query_id}{variant or ''} has {len(templates)} templates " + f"in stream {stream_id}; expected 1" + ) + return templates[0] + def _resolve_template_arg(self, query_id: int, variant: Optional[str], is_multi_part: bool) -> str: """Pick the template path argument dsqgen should use.""" template_name = f"query{query_id}.tpl" if is_multi_part else f"query{query_id}{variant or ''}.tpl" @@ -244,9 +344,10 @@ def _build_dsqgen_cmd( scale_factor: float, dialect: str, is_multi_part: bool, + streams: Optional[int] = None, ) -> tuple[list[str], str]: """Build the dsqgen command and return (cmd, opt_prefix).""" - _opt = "/" if sys.platform == "win32" else "-" + _opt = _option_prefix() template_arg = self._resolve_template_arg(query_id, variant, is_multi_part) cmd = [str(self.dsqgen_path)] @@ -257,6 +358,8 @@ def _build_dsqgen_cmd( cmd.extend([f"{_opt}RNGSEED", str(seed)]) cmd.extend([f"{_opt}FILTER", "Y"]) cmd.extend([f"{_opt}VERBOSE", "N"]) + if streams is not None: + cmd.extend([f"{_opt}STREAMS", str(streams)]) return cmd, _opt def _stage_dsqgen_workdir(self, temp_path: Path) -> dict[str, str]: @@ -286,14 +389,37 @@ def _run_dsqgen( self, cmd: list[str], opt: str, query_id: int, variant: Optional[str] ) -> subprocess.CompletedProcess: """Run dsqgen in a staged temporary workdir and return the completed process.""" + return self._run_dsqgen_with_log(cmd, opt, query_id, variant, capture_log=False)[0] + + def _run_dsqgen_with_log( + self, + cmd: list[str], + opt: str, + query_id: int, + variant: Optional[str], + *, + capture_log: bool, + edit_workdir: Optional[Callable[[Path], None]] = None, + ) -> tuple[subprocess.CompletedProcess, Optional[str]]: + """Run dsqgen in a staged workdir; with ``capture_log`` also return its ``-LOG`` text. + + ``edit_workdir`` is called with the staged workdir before dsqgen runs, so a caller can change + the staged template without touching the installed copy. + """ import tempfile + log_text: Optional[str] = None with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) env = self._stage_dsqgen_workdir(temp_path) + if edit_workdir is not None: + edit_workdir(temp_path) cmd.extend([f"{opt}INPUT", "q/templates.lst"]) cmd.extend([f"{opt}DIRECTORY", "q"]) + log_path = temp_path / "parameters.log" + if capture_log: + cmd.extend([f"{opt}LOG", str(log_path)]) result = subprocess.run( cmd, @@ -303,6 +429,10 @@ def _run_dsqgen( timeout=30, env=env, ) + if capture_log and result.returncode == 0: + if not log_path.exists(): + raise TPCDSError(f"dsqgen wrote no parameter log for query {query_id}{variant or ''}") + log_text = log_path.read_text(encoding="utf-8") if result.returncode != 0: error_output = result.stderr.strip() if result.stderr else "Unknown error" @@ -314,7 +444,7 @@ def _run_dsqgen( f"Stderr: {error_output}\n" f"Stdout: {stdout_output}" ) - return result + return result, log_text def _extract_sql_from_output(self, stdout: str, query_id: int, variant: Optional[str]) -> str: sql_output = stdout.strip() @@ -889,38 +1019,58 @@ def validate_query_id(self, query_id: Union[int, str]) -> bool: def generate_with_parameters( self, - query_id: int, + query_id: Union[int, str], parameters: dict[str, Any], *, scale_factor: float = 1.0, dialect: str = "netezza", + seed: Optional[int] = 1, ) -> str: - """Generate query with specific parameter values. + """Render a query with explicit substitution values. - Args: - query_id: Query number (1-99) - parameters: Dictionary of parameter names to values - scale_factor: Scale factor for calculations - dialect: SQL dialect + ``parameters`` maps dsqgen's own names, as ``-LOG`` writes them (``YEAR.01``, ``ZIP.17``), to + values. dsqgen substitutes each ``[NAME.k]`` token in the template, and ``[NAME]`` for the + first value, so those tokens are replaced with the given value before dsqgen runs. Anything + not given is drawn as usual from ``seed``, so passing every value a seed produces gives that + seed's SQL, and the values win over the seed for the names given. - Returns: - SQL query string with parameters substituted + Raises: + ValueError: If a name is not of the form ``NAME.NN`` or the template has no such + substitution (so a mistyped name cannot silently do nothing). + TPCDSError: If dsqgen fails. """ - # For TPC-DS, we'll need to use template processing with custom parameters - # This is a simplified implementation that uses dsqgen with a fixed seed - # and then post-processes the result with custom parameters + try: + base_query_id, variant = self._parse_query_id(query_id) + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid query_id: {e}") from e + if not (1 <= base_query_id <= 99): + raise ValueError(f"Query ID must be 1-99, got {base_query_id}") + for name in parameters: + if not _PARAMETER_NAME_RE.match(name): + raise ValueError(f"Parameter name must look like 'YEAR.01' (as dsqgen -LOG writes it), got {name!r}") - # Generate base query first - base_sql = self.generate(query_id, seed=1, scale_factor=scale_factor, dialect=dialect) + dialect = self._validate_dialect(dialect) + is_multi_part = base_query_id in (14, 23, 24, 39) and variant in ("a", "b") + cmd, opt = self._build_dsqgen_cmd(base_query_id, variant, seed, scale_factor, dialect, is_multi_part) + q = f"{base_query_id}{variant or ''}" + + def substitute(temp_path: Path) -> None: + template = (temp_path / "q" / self._resolve_template_arg(base_query_id, variant, is_multi_part)).resolve() + template.write_text( + _substitute_parameters(template.read_text(encoding="utf-8"), parameters, q), encoding="utf-8" + ) - # Apply custom parameter substitutions - for param_name, param_value in parameters.items(): - # Convert parameter name to template format - template_param = f"[{param_name.upper()}]" - if template_param in base_sql: - base_sql = base_sql.replace(template_param, str(param_value)) + try: + result, _ = self._run_dsqgen_with_log( + cmd, opt, base_query_id, variant, capture_log=False, edit_workdir=substitute + ) + except subprocess.TimeoutExpired: + raise TPCDSError(f"dsqgen timed out for query {q}") from None + except FileNotFoundError: + raise TPCDSError(f"dsqgen binary not found at {self.dsqgen_path}") from None - return self._clean_sql(base_sql) + sql_query = self._extract_sql_from_output(result.stdout, base_query_id, variant) + return self._select_multi_part(sql_query, base_query_id, variant) if is_multi_part else sql_query class TPCDSQueries: diff --git a/benchbox/core/tpcds/dataframe_queries/default_parameters.yaml b/benchbox/core/tpcds/dataframe_queries/default_parameters.yaml index a98f6c9de4..5595cf33cd 100644 --- a/benchbox/core/tpcds/dataframe_queries/default_parameters.yaml +++ b/benchbox/core/tpcds/dataframe_queries/default_parameters.yaml @@ -1,27 +1,28 @@ 1: - agg_field: sr_return_amt + agg_field: sr_fee state: TN year: 2000 2: year: 2001 3: agg_column: ss_ext_sales_price - manufact_id: 128 - month: 11 + manufact_id: 436 + month: 12 4: + select_one: t_s_secyear.customer_preferred_cust_flag year: 2001 5: - year: 2000 + sales_date: '1998-08-04' 6: - month: 1 - year: 2001 -7: - channel_promo: channel_demo - education: College - gender: M - marital_status: S + month: 2 year: 2000 +7: + education: Primary + gender: F + marital_status: W + year: 1998 8: + qoy: 2 year: 1998 zip_codes: - '24128' @@ -30,6 +31,8 @@ - '87816' - '83926' 9: + agg_else: ss_net_profit + agg_then: ss_ext_discount_amt quantity_ranges: - - 1 - 20 @@ -41,404 +44,553 @@ - 80 - - 81 - 100 + thresholds: + - 25437 + - 22746 + - 9387 + - 10098 + - 18213 10: - gender: F + counties: + - Walker County + - Richland County + - Gaines County + - Douglas County + - Dona Ana County month: 2 year: 2002 11: + select_one: t_s_secyear.customer_preferred_cust_flag year: 2001 12: item_categories: + - Jewelry - Sports - Books - - Home - month: 2 - year: 1999 + sales_date: '2001-01-12' 13: - education: Advanced Degree - marital_status: M + demo1_education: 2 yr Degree + demo1_marital: D + demo2_education: Secondary + demo2_marital: S + demo3_education: Advanced Degree + demo3_marital: W + states1: + - CO + - IL + - MN + states2: + - OH + - MT + - NM + states3: + - TX + - MO + - MI year: 2001 14: - day: 11 - year: 1999 + day: 16 + year: 1998 15: quarter: 2 - year: 2001 - zip_prefix: '85' + year: 2000 16: - call_center: Bill Bowden - county: Williamson County - months: - - 3 - state: TN - year: 2002 + counties: + - Williamson County + - Williamson County + - Williamson County + - Williamson County + - Williamson County + month: 2 + state: IL + year: 1999 17: quarter: 1 - state: OK - year: 2001 + year: 1998 18: + birth_months: + - 9 + - 5 + - 12 + - 4 + - 1 + - 10 cd_education_status: College cd_gender: M - cd_marital_status: S states: - - TX - - OH - - TX - - OR - - NM - - KY - - VA + - ND + - WI + - AL + - NC + - OK + - MS + - TN year: 2001 19: - manager_id: 8 + manager_id: 7 month: 11 year: 1999 20: item_categories: + - Jewelry - Sports - Books - - Home - month: 2 - year: 1999 + sales_date: '2001-01-12' 21: - month: 1 - warehouse_id: 2 - year: 2000 + sales_date: '1998-04-08' 22: - months: - - 1 - - 2 - - 3 - - 4 - - 5 - - 6 - year: 2001 + dms: 1212 23: - year: 2000 + month: 1 + top_percent: 95 + year: 1999 24: - color: chiffon - market_id: 8 + amount_column: ss_sales_price + color: orchid + market_id: 7 + second_color: chiffon 25: - quarters: - - 1 - - 2 - - 3 - year: 2001 -26: - education: College - gender: M - marital_status: S - promo_channel: channel_demo + agg: sum year: 2000 +26: + education: Primary + gender: F + marital_status: W + year: 1998 27: - education: College - gender: M - marital_status: S - state: TN - year: 2002 + education: Primary + gender: F + marital_status: W + states: + - TN + - TN + - TN + - TN + - TN + - TN + year: 1998 28: - quantity_ranges: - - - 0 - - 5 - - - 6 - - 10 - - - 11 - - 15 - - - 16 - - 20 - - - 21 - - 25 - - - 26 - - 30 + coupon_amts: + - 460 + - 1430 + - 920 + - 3054 + - 14180 + - 2513 + list_prices: + - 11 + - 91 + - 66 + - 142 + - 135 + - 28 + wholesale_costs: + - 14 + - 32 + - 4 + - 80 + - 38 + - 42 29: - months: - - 1 - - 2 - - 3 + agg: sum + month: 4 year: 1999 30: - state: GA + state: IL year: 2002 31: - gmt_offset: -5.0 + order_by: ss1.ca_county year: 2000 32: - days: 90 - year: 2000 + manufact_id: 269 + sales_date: '1998-03-18' 33: - categories: - - Books + category: Books gmt_offset: -5.0 - manufacturer_id: 88 - year: 1998 + month: 3 + year: 1999 34: - county: Williamson County - state: TN + buy_potential_1: '>10000' + buy_potential_2: Unknown + counties: + - Williamson County + - Williamson County + - Williamson County + - Williamson County + - Williamson County + - Williamson County + - Williamson County + - Williamson County year: 1998 35: - year: 2002 - years: - - 1999 - - 2000 - - 2001 - - 2002 + aggone: avg + aggthree: sum + aggtwo: max + year: 1999 36: - state: TN - year: 2001 -37: - current_price_max: 98 - current_price_min: 68 - month_end: 6 - month_start: 1 + states: + - TN + - TN + - TN + - TN + - TN + - TN + - TN + - TN year: 2000 +37: + current_price_max: 52 + current_price_min: 22 + manufact_ids: + - 678 + - 964 + - 918 + - 849 + sales_date: '2001-06-02' 38: - year: 2002 + dms: 1212 39: months: - 1 - 2 year: 2001 40: - year: 2000 + sales_date: '1998-04-08' 41: - manufact_ids: - - 738 - - 129 - - 830 - - 540 + colors: + - orchid + - papaya + - burlywood + - navy + - bisque + - azure + - chocolate + - cornflower + - salmon + - midnight + - snow + - steel + - purple + - gainsboro + - metallic + - forest + manufact: 742 + sizes: + - petite + - medium + - N/A + - extra large + - small + - large + units: + - Pound + - Lb + - Bundle + - Each + - N/A + - Tsp + - Bunch + - Gross + - Oz + - Box + - Carton + - Tbl + - Dram + - Unknown + - Gram + - Ounce 42: - month: 11 - year: 2000 + month: 12 + year: 1998 43: gmt_offset: -5.0 - year: 2000 + year: 1998 44: null_col: ss_addr_sk store_sk: 4 - year: 2001 45: - quarters: - - 1 - - 2 - - 3 - year: 2001 - zip_codes: - - '85669' - - '86197' - - '88274' - - '83405' - - '86475' + gbobc: ca_county + qoy: 2 + year: 2000 46: cities: - - Fairview - Midway - Fairview - Fairview - Fairview - year: 2001 + - Fairview + dep_count: 5 + dow: + - 6 + - 0 + vehicle_count: 3 + year: 1999 + year_offsets: + - 0 + - 1 + - 2 47: + order_by: nsum + select_one: v1.i_category, v1.i_brand + select_two: ',v1.d_year, v1.d_moy' year: 2000 48: - year: 2000 + demo1_education: 4 yr Degree + demo1_marital: M + demo2_education: Primary + demo2_marital: D + demo3_education: Advanced Degree + demo3_marital: U + states1: + - KY + - GA + - NM + states2: + - MT + - OR + - IN + states3: + - WI + - MO + - WV + year: 1998 49: + month: 12 year: 2001 50: - channel: store channel - days: 30 - year: 2001 + month: 9 + year: 2000 51: - months: - - 1 - - 2 - - 3 - - 4 - year: 2001 + dms: 1212 52: - month: 11 - year: 2000 + month: 12 + year: 1998 53: - manufacturer_ids: - - 88 - - 33 - - 160 - - 129 - month: 11 + dms: 1212 54: + category: Women + class: maternity month: 4 year: 2000 55: - manager_id: 28 - month: 11 - year: 1999 + manager_id: 36 + month: 12 + year: 2001 56: colors: - - slate - - blanched - - burnished - gmt_offset: -5.0 - year: 2001 + - orchid + - chiffon + - lace + gmt_offset: -8.0 + month: 1 + year: 2000 57: - year: 1999 -58: - day: 1 + order_by: nsum + select_one: v1.cc_name + select_two: ',v1.d_year, v1.d_moy' year: 2000 +58: + sales_date: '1998-02-19' 59: - d_month_seq: 1212 - year: 1999 + d_month_seq: 1185 60: - categories: - - Music - gmt_offset: -5.0 - year: 2001 + category: Children + gmt_offset: -6.0 + month: 9 + year: 1999 61: - category: Jewelry - gmt_offset: -5.0 + category: Books + gmt_offset: -7.0 month: 11 - year: 1998 -62: - months: - - 11 - - 12 year: 1999 +62: + dms: 1212 63: - manufacturer_ids: - - 88 - - 33 - - 160 - - 129 - month: 11 + dms: 1212 64: - income_band: 38128 - year: 1999 -65: + colors: + - maroon + - burnished + - dim + - steel + - navajo + - chocolate + price_min: 35 year: 2000 +65: + dms: 1212 66: - month_end: 4 - month_start: 1 + catalog_net_col: cs_net_paid_inc_ship_tax + catalog_sales_col: cs_sales_price ship_carriers: - DIAMOND - AIRBORNE - web_names: - - pri - year: 2001 + time_start: 49530 + web_net_col: ws_net_paid_inc_tax + web_sales_col: ws_sales_price + year: 2002 67: - year: 2001 + dms: 1212 68: cities: - Midway - Fairview - year: 2001 + dep_count: 5 + vehicle_count: 3 + year: 1999 + year_offsets: + - 0 + - 1 + - 2 69: - month: 4 + month: 1 states: - - KY - - GA - - NM - year: 2001 + - CO + - IL + - MN + year: 1999 70: - year: 2001 + dms: 1212 71: - brand: 'amalgexporti #1' - manager_id: 1 - month: 11 - year: 1999 + month: 12 + year: 2000 72: - buy_potential: '>10000' - marital_status: D - year: 1999 + buy_potential: 1001-5000 + marital_status: M + year: 2001 73: - county: Williamson County - state: TN - year: 1999 + buy_potential_1: '>10000' + buy_potential_2: Unknown + counties: + - Williamson County + - Williamson County + - Williamson County + - Williamson County + year: 1998 74: + aggone: max + order_by: + - 2 + - 1 + - 3 year: 2001 75: - category: Books - year: 1999 + category: Sports + year: 2002 76: - channel: store - year: 2001 + null_col_cs: cs_warehouse_sk + null_col_ss: ss_addr_sk + null_col_ws: ws_web_page_sk 77: - channel_type: store channel - year: 2001 + sales_date: '1998-08-04' 78: + select_columns: ss_sold_year, ss_item_sk, ss_customer_sk year: 2000 79: - household_dep_count: 6 - year: 2000 + dep_count: 8 + vehicle_count: 0 + year: 1998 80: - year: 2000 + sales_date: '1998-08-04' 81: - state: GA - year: 2000 + state: IL + year: 1998 82: - month_end: 6 - month_start: 1 - price_max: 92 - price_min: 62 - year: 2000 + manufact_ids: + - 437 + - 129 + - 727 + - 663 + price_max: 60 + price_min: 30 + sales_date: '2002-05-30' 83: dates: - - '2000-06-30' - - '2000-09-27' - - '2000-11-17' - year: 2000 + - '1998-01-02' + - '1998-10-15' + - '1998-11-10' 84: - city: Edgewood - income_band: 38128 + city: Hopewell + income_band: 32287 85: + education_statuses: + - 4 yr Degree + - Primary + - Advanced Degree + marital_statuses: + - M + - D + - U + states: + - KY + - GA + - NM + - MT + - OR + - IN + - WI + - MO + - WV year: 1998 86: - year: 2001 + dms: 1212 87: - month: 4 - year: 2001 + dms: 1212 88: - year: 1998 + dep_counts: + - 3 + - 0 + - 1 89: - category_id: 1 - class_id: 1 - year: 1999 + categories: + - Home + - Books + - Electronics + - Shoes + - Jewelry + - Men + classes: + - wallpaper + - parenting + - musical + - womens + - birdal + - pants + year: 2000 90: - hours: - - - 8 - - 9 - - - 19 - - 20 - year: 2001 + dep_count: 8 + hour_am: 6 + hour_pm: 14 91: + buy_potential: 0-500 + gmt_offset: -7 month: 11 - year: 1998 + year: 1999 92: - days: 90 - year: 2000 + manufact_id: 269 + sales_date: '1998-03-18' 93: reason: reason 28 94: + month: 5 states: - TX - - OR - - AZ year: 1999 95: + month: 5 states: - TX - - OR - - AZ year: 1999 96: - hours: - - - 8 - - 9 + dep_count: 5 + hour: 8 97: - month: 4 - year: 2002 + dms: 1212 98: categories: + - Jewelry - Sports - Books - - Home - year: 1999 + sales_date: '2001-01-12' 99: - month: 2 - year: 1999 + dms: 1212 diff --git a/benchbox/core/tpcds/dataframe_queries/helper_query_specs.yaml b/benchbox/core/tpcds/dataframe_queries/helper_query_specs.yaml index 95021e4557..1ec38501c6 100644 --- a/benchbox/core/tpcds/dataframe_queries/helper_query_specs.yaml +++ b/benchbox/core/tpcds/dataframe_queries/helper_query_specs.yaml @@ -20,13 +20,13 @@ helper_queries: args: - - - i_manufact_id - manufact_id - - 128 + - 436 - - d_moy - month - - 11 + - 12 - - d_year - - i_brand - i_brand_id + - i_brand - ss_ext_sales_price - sum_agg - - d_year @@ -35,6 +35,7 @@ helper_queries: - - false - true - false + - agg_column - query_id: 42 title: Date/Item Category Sales expression_helper: _date_item_sales_expression @@ -45,10 +46,10 @@ helper_queries: - 1 - - d_moy - month - - 11 + - 12 - - d_year - year - - 2000 + - 1998 - - d_year - i_category_id - i_category @@ -72,13 +73,13 @@ helper_queries: - 1 - - d_moy - month - - 11 + - 12 - - d_year - year - - 2000 + - 1998 - - d_year - - i_brand - i_brand_id + - i_brand - ss_ext_sales_price - ext_price - - d_year @@ -94,13 +95,13 @@ helper_queries: args: - - - i_manager_id - manager_id - - 28 + - 36 - - d_moy - month - - 11 + - 12 - - d_year - year - - 1999 + - 2001 - - i_brand_id - i_brand - ss_ext_sales_price @@ -116,13 +117,71 @@ helper_queries: args: - ss_sales_price - sum_sales + - i_manufact_id + - d_qoy + - avg_quarterly_sales + - - Books + - Children + - Electronics + - - personal + - portable + - reference + - self-help + - - 'scholaramalgamalg #14' + - 'scholaramalgamalg #7' + - 'exportiunivamalg #9' + - 'scholaramalgamalg #9' + - - Women + - Music + - Men + - - accessories + - classical + - fragrances + - pants + - - 'amalgimporto #1' + - 'edu packscholar #1' + - 'exportiimporto #1' + - 'importoamalg #1' + - - avg_quarterly_sales + - sum_sales + - i_manufact_id + - 1212 - query_id: 63 title: Store Manufacturer Profit expression_helper: _manufacturer_month_expression pandas_helper: _manufacturer_month_pandas args: - - ss_net_profit - - sum_profit + - ss_sales_price + - sum_sales + - i_manager_id + - d_moy + - avg_monthly_sales + - - Books + - Children + - Electronics + - - personal + - portable + - reference + - self-help + - - 'scholaramalgamalg #14' + - 'scholaramalgamalg #7' + - 'exportiunivamalg #9' + - 'scholaramalgamalg #9' + - - Women + - Music + - Men + - - accessories + - classical + - fragrances + - pants + - - 'amalgimporto #1' + - 'edu packscholar #1' + - 'exportiimporto #1' + - 'importoamalg #1' + - - i_manager_id + - avg_monthly_sales + - sum_sales + - 1212 - query_id: 98 title: Store Sales Item Band expression_helper: _item_category_sales_expression @@ -133,15 +192,18 @@ helper_queries: - ss_sold_date_sk - ss_ext_sales_price - categories - - 1 - - - i_item_desc + - '2001-01-12' + - - i_item_id + - i_item_desc - i_category - i_class - i_current_price - - i_category - i_class - - itemrevenue + - i_item_id - i_item_desc + - revenueratio + - null - query_id: 12 title: Web Sales Item Analysis expression_helper: _item_category_sales_expression @@ -152,7 +214,7 @@ helper_queries: - ws_sold_date_sk - ws_ext_sales_price - item_categories - - 2 + - '2001-01-12' - - i_item_id - i_item_desc - i_category @@ -160,8 +222,9 @@ helper_queries: - i_current_price - - i_category - i_class - - itemrevenue - i_item_id + - i_item_desc + - revenueratio - query_id: 20 title: Catalog Sales Item Analysis expression_helper: _item_category_sales_expression @@ -172,7 +235,7 @@ helper_queries: - cs_sold_date_sk - cs_ext_sales_price - item_categories - - 2 + - '2001-01-12' - - i_item_id - i_item_desc - i_category @@ -180,8 +243,9 @@ helper_queries: - i_current_price - - i_category - i_class - - itemrevenue - i_item_id + - i_item_desc + - revenueratio - query_id: 26 title: Catalog Sales Promo Analysis expression_helper: _promotion_sales_expression @@ -205,7 +269,7 @@ helper_queries: - cs_item_sk - cs_sold_date_sk - cs_ext_discount_amt - - 977 + - 269 - query_id: 37 title: Item Inventory Analysis expression_helper: _inventory_item_expression @@ -215,9 +279,15 @@ helper_queries: - cs_item_sk - - current_price_min - current_price_max - - - 68 - - 98 + - - 22 + - 52 - true + - manufact_ids + - - 678 + - 964 + - 918 + - 849 + - '2001-06-02' - query_id: 82 title: Store Sales Inventory expression_helper: _inventory_item_expression @@ -227,9 +297,15 @@ helper_queries: - ss_item_sk - - price_min - price_max - - - 62 - - 92 + - - 30 + - 60 - false + - manufact_ids + - - 437 + - 129 + - 727 + - 663 + - '2002-05-30' - query_id: 92 title: Web Sales Excess Discount expression_helper: _excess_discount_expression @@ -239,7 +315,7 @@ helper_queries: - ws_item_sk - ws_sold_date_sk - ws_ext_discount_amt - - 350 + - 269 - query_id: 94 title: Web Orders from Multiple Warehouses Not Returned expression_helper: _web_multi_warehouse_expression @@ -261,9 +337,9 @@ helper_queries: args: - i_color - colors - - - pale + - - orchid - chiffon - - thistle + - lace - 1 - - total_sales - i_item_id @@ -275,6 +351,6 @@ helper_queries: - i_category - category - Children - - 8 + - 9 - - i_item_id - total_sales diff --git a/benchbox/core/tpcds/dataframe_queries/parameter_adapters.py b/benchbox/core/tpcds/dataframe_queries/parameter_adapters.py new file mode 100644 index 0000000000..fa60a534bf --- /dev/null +++ b/benchbox/core/tpcds/dataframe_queries/parameter_adapters.py @@ -0,0 +1,780 @@ +"""Bind TPC-DS DataFrame query parameters to the values dsqgen substitutes into the SQL. + +The SQL surface takes its substitution values from dsqgen. The DataFrame surface reads its own +parameter keys (``year``, ``months``, ``store_sk``) from ``default_parameters.yaml``, which holds +representative values valid for scale factor 1 and does not follow dsqgen's seed or scale. An adapter +maps what ``dsqgen -LOG`` reports for a query to the DataFrame keys for that query, so both surfaces +can be run on the same parameters. + +A flat name map is not enough. Q39's SQL uses ``[MONTH]`` and ``[MONTH]+1``, ``-LOG`` records only +``MONTH.01``, and the DataFrame implementation takes ``months: [m, m + 1]``, so an adapter reproduces +the template's arithmetic. One adapter is written per query, and only for queries whose implementation +reads every value the SQL varies: Q41 has none, because its implementation hard-codes colors and +reads ``manufact_start`` with a literal fallback, so binding it would hide that gap. + +Each binding records where its values came from (the dsqgen binary, seed, scale factor and stream), so +a result can be tied to the parameters that produced it. ``stream_id`` is dsqgen's ``-STREAMS`` +stream; ``DSQGenBinary.generate`` does not select a stream, so SQL for a stream above 0 has to be +rendered from the same values (``DSQGenBinary.generate_with_parameters``). + +Copyright 2026 Joe Harris / BenchBox Project + +TPC Benchmark(TM) DS (TPC-DS) - Copyright (c) Transaction Processing Performance Council + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +Adapter = Callable[[Mapping[str, str]], dict[str, Any]] + + +@dataclass(frozen=True) +class ParameterBinding: + """DataFrame parameters for one query, with the dsqgen run they were derived from.""" + + query_id: int + scale_factor: float + seed: int | None + stream_id: int + dsqgen_sha256: str + logged: Mapping[str, str] + parameters: Mapping[str, Any] + + +def _year_and_month(values: Mapping[str, str]) -> tuple[int, int]: + return int(values["YEAR.01"]), int(values["MONTH.01"]) + + +def _listed(values: Mapping[str, str], name: str, first: int = 1, last: int | None = None) -> list[str]: + """The values logged as ``NAME.nn`` for ``first`` through ``last`` (every one when ``last`` is omitted).""" + prefix = f"{name}." + numbered = sorted((int(key[len(prefix) :]), value) for key, value in values.items() if key.startswith(prefix)) + return [value for number, value in numbered if number >= first and (last is None or number <= last)] + + +def _demographics(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "gender": values["GEN.01"], + "marital_status": values["MS.01"], + "education": values["ES.01"], + } + + +def _dms(values: Mapping[str, str]) -> dict[str, Any]: + # Q65, Q67 and Q70 all test d_month_seq between DMS and DMS+11; the implementations add the 11. + return {"dms": int(values["DMS.01"])} + + +def _dms_value(values: Mapping[str, str]) -> int: + # The SQL spans [DMS] through [DMS]+11 (to +23 for Q59); the implementations add the offsets. + return int(values["DMS.01"]) + + +def _dms_window(values: Mapping[str, str]) -> dict[str, Any]: + # Q51, Q53, Q62 and Q63 take one month sequence and read it as ``dms``. + return {"dms": _dms_value(values)} + + +def _month_seq(values: Mapping[str, str]) -> dict[str, Any]: + # Q86, Q87, Q97 and Q99 filter d_month_seq to DMS through DMS + 11; the implementations add the 11. + return {"dms": int(values["DMS.01"])} + + +def _q1(values: Mapping[str, str]) -> dict[str, Any]: + # STATE is derived from COUNTY inside the template; only the state reaches the SQL. + return { + "year": int(values["YEAR.01"]), + "state": values["STATE.01"], + "agg_field": values["AGG_FIELD.01"].lower(), + } + + +def _q2(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"])} + + +def _q3(values: Mapping[str, str]) -> dict[str, Any]: + return { + "manufact_id": int(values["MANUFACT.01"]), + "month": int(values["MONTH.01"]), + "agg_column": values["AGGC.01"].lower(), + } + + +def _q5(values: Mapping[str, str]) -> dict[str, Any]: + return {"sales_date": values["SALES_DATE.01"]} + + +def _q6(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month} + + +def _q7(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "education": values["ES.01"], + "gender": values["GEN.01"], + "marital_status": values["MS.01"], + } + + +def _q8(values: Mapping[str, str]) -> dict[str, Any]: + # The template draws 400 zip codes; every one reaches the SQL. + return {"year": int(values["YEAR.01"]), "qoy": int(values["QOY.01"]), "zip_codes": _listed(values, "ZIP")} + + +def _q9(values: Mapping[str, str]) -> dict[str, Any]: + return { + "quantity_ranges": [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100)], + "thresholds": [int(value) for value in _listed(values, "RC")], + "agg_then": values["AGGCTHEN.01"].lower(), + "agg_else": values["AGGCELSE.01"].lower(), + } + + +def _q10(values: Mapping[str, str]) -> dict[str, Any]: + # The template draws ten counties and its SQL uses the first five. + return { + "year": int(values["YEAR.01"]), + "month": int(values["MONTH.01"]), + "counties": _listed(values, "COUNTY", last=5), + } + + +def _q12(values: Mapping[str, str]) -> dict[str, Any]: + # YEAR only picks the date the template draws SDATE from; the SQL filters on the dates and categories. + return {"item_categories": _listed(values, "CATEGORY"), "sales_date": values["SDATE.01"]} + + +def _q13(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL has three demographic groups, each a marital status, an education status and three states. + marital, education, states = _listed(values, "MS"), _listed(values, "ES"), _listed(values, "STATE") + # The template fixes the year at 2001 rather than drawing it. + parameters: dict[str, Any] = {"year": 2001} + for group in range(3): + parameters[f"demo{group + 1}_marital"] = marital[group] + parameters[f"demo{group + 1}_education"] = education[group] + parameters[f"states{group + 1}"] = states[group * 3 : group * 3 + 3] + return parameters + + +def _q14(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "day": int(values["DAY.01"])} + + +def _q15(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "quarter": int(values["QOY.01"])} + + +def _q16(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + counties = [values[f"COUNTY_{letter}.01"] for letter in "ABCDE"] + return {"year": year, "month": month, "state": values["STATE.01"], "counties": counties} + + +def _q17(values: Mapping[str, str]) -> dict[str, Any]: + # The quarters are fixed in the template (Q1 to Q3); only the year is drawn. + return {"year": int(values["YEAR.01"]), "quarter": 1} + + +def _q18(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "cd_gender": values["GEN.01"], + "cd_education_status": values["ES.01"], + "birth_months": [int(month) for month in _listed(values, "MONTH")], + "states": _listed(values, "STATE"), + } + + +def _q20(values: Mapping[str, str]) -> dict[str, Any]: + # SDATE is already the first day of the 30-day window; the implementation adds the 30 days. + return {"item_categories": _listed(values, "CATEGORY"), "sales_date": values["SDATE.01"]} + + +def _q21(values: Mapping[str, str]) -> dict[str, Any]: + # SALES_DATE is the pivot; the implementation reads the 30 days on either side from it. + return {"sales_date": values["SALES_DATE.01"]} + + +def _q22(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL covers month sequences DMS through DMS+11; the implementation adds the 11. + return {"dms": int(values["DMS.01"])} + + +def _q23(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL looks at years YEAR through YEAR+3 for frequent items and YEAR itself for the sales. + year, month = _year_and_month(values) + return {"year": year, "month": month, "top_percent": int(values["TOPPERCENT.01"])} + + +def _q24(values: Mapping[str, str]) -> dict[str, Any]: + return { + "market_id": int(values["MARKET.01"]), + "amount_column": values["AMOUNTONE.01"].lower(), + "color": values["COLOR.01"], + "second_color": values["COLOR.02"], + } + + +def _q25(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "agg": values["AGG.01"]} + + +def _q26(values: Mapping[str, str]) -> dict[str, Any]: + return _demographics(values) + + +def _q27(values: Mapping[str, str]) -> dict[str, Any]: + # The six states are logged under their own names (STATE_A to STATE_F), one value each. + states = [values[f"STATE_{letter}.01"] for letter in "ABCDEF"] + return {**_demographics(values), "states": states} + + +def _q28(values: Mapping[str, str]) -> dict[str, Any]: + return { + "list_prices": [int(value) for value in _listed(values, "LISTPRICE")], + "coupon_amts": [int(value) for value in _listed(values, "COUPONAMT")], + "wholesale_costs": [int(value) for value in _listed(values, "WHOLESALECOST")], + } + + +def _q29(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month, "agg": values["AGG.01"]} + + +def _q30(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "state": values["STATE.01"]} + + +def _q31(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "order_by": values["AGG.01"]} + + +def _q32(values: Mapping[str, str]) -> dict[str, Any]: + # The template's YEAR only feeds CSDATE, which is the first day of a 90-day window in the SQL. + return {"manufact_id": int(values["IMID.01"]), "sales_date": values["CSDATE.01"]} + + +def _q33(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month, "gmt_offset": float(values["GMT.01"]), "category": values["CATEGORY.01"]} + + +def _q34(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL tests d_year in (YEAR, YEAR+1, YEAR+2); the implementation adds the offsets itself. + return { + "year": int(values["YEAR.01"]), + "counties": [values[f"COUNTY_{letter}.01"] for letter in "ABCDEFGH"], + "buy_potential_1": values["BPONE.01"], + "buy_potential_2": values["BPTWO.01"], + } + + +def _q35(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "aggone": values["AGGONE.01"], + "aggtwo": values["AGGTWO.01"], + "aggthree": values["AGGTHREE.01"], + } + + +def _year_select_one(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "select_one": values["SELECTONE.01"]} + + +def _q19(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month, "manager_id": int(values["MANAGER.01"])} + + +def _q36(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "states": [values[f"STATE_{letter}.01"] for letter in "ABCDEFGH"]} + + +def _q37(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL tests i_current_price between PRICE and PRICE+30. + price = int(values["PRICE.01"]) + return { + "current_price_min": price, + "current_price_max": price + 30, + "manufact_ids": [int(value) for value in _listed(values, "MANUFACT_ID")], + "sales_date": values["INVDATE.01"], + } + + +def _q38(values: Mapping[str, str]) -> dict[str, Any]: + return {"dms": int(values["DMS.01"])} + + +def _q39(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL compares month MONTH with month MONTH+1; the log has only MONTH. + year, month = _year_and_month(values) + return {"year": year, "months": [month, month + 1]} + + +def _q40(values: Mapping[str, str]) -> dict[str, Any]: + return {"sales_date": values["SALES_DATE.01"]} + + +def _q41(values: Mapping[str, str]) -> dict[str, Any]: + return { + "manufact": int(values["MANUFACT.01"]), + "colors": _listed(values, "COLOR"), + "units": _listed(values, "UNIT"), + "sizes": _listed(values, "SIZE"), + } + + +def _q42(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month} + + +def _q43(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "gmt_offset": float(values["GMT.01"])} + + +def _q44(values: Mapping[str, str]) -> dict[str, Any]: + return {"store_sk": int(values["STORE.01"]), "null_col": values["NULLCOLSS.01"]} + + +def _q45(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "qoy": int(values["QOY.01"]), "gbobc": values["GBOBC.01"]} + + +def _q46(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "year_offsets": [0, 1, 2], + "dow": [6, 0], + "cities": [values[f"CITY_{letter}.01"] for letter in "ABCDE"], + "dep_count": int(values["DEPCNT.01"]), + "vehicle_count": int(values["VEHCNT.01"]), + } + + +def _q49(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month} + + +def _q50(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month} + + +def _q54(values: Mapping[str, str]) -> dict[str, Any]: + # CINDX only picks CATEGORY and CLASS from the categories distribution; the SQL receives the names. + year, month = _year_and_month(values) + return {"year": year, "month": month, "category": values["CATEGORY.01"], "class": values["CLASS.01"]} + + +def _q58(values: Mapping[str, str]) -> dict[str, Any]: + # YEAR only bounds the draw of SALES_DATE; the SQL receives the date. + return {"sales_date": values["SALES_DATE.01"]} + + +def _q59(values: Mapping[str, str]) -> dict[str, Any]: + return {"d_month_seq": _dms_value(values)} + + +def _q60(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + # GMT is a whole number of hours in the fips_county distribution; the SQL compares it with a decimal column. + return {"year": year, "month": month, "category": values["CATEGORY.01"], "gmt_offset": int(values["GMT.01"])} + + +def _q65(values: Mapping[str, str]) -> dict[str, Any]: + return _dms(values) + + +def _q66(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL window is TIMEONE through TIMEONE+28800; the implementations add the 28800. The carrier + # order matters because the query reports it as ship_carriers. + return { + "year": int(values["YEAR.01"]), + "time_start": int(values["TIMEONE.01"]), + "ship_carriers": [values["SMC.01"], values["SMC.02"]], + "web_sales_col": values["SALESONE.01"], + "web_net_col": values["NETONE.01"], + "catalog_sales_col": values["SALESTWO.01"], + "catalog_net_col": values["NETTWO.01"], + } + + +def _q67(values: Mapping[str, str]) -> dict[str, Any]: + return _dms(values) + + +def _q68(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "year_offsets": [0, 1, 2], + "cities": [values["CITY_A.01"], values["CITY_B.01"]], + "dep_count": int(values["DEPCNT.01"]), + "vehicle_count": int(values["VEHCNT.01"]), + } + + +def _q70(values: Mapping[str, str]) -> dict[str, Any]: + return _dms(values) + + +def _q71(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month} + + +def _q72(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "buy_potential": values["BP.01"], "marital_status": values["MS.01"]} + + +def _q73(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "counties": [values[f"COUNTY_{letter}.01"] for letter in "ABCD"], + "buy_potential_1": values["BPONE.01"], + "buy_potential_2": values["BPTWO.01"], + } + + +def _q74(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "aggone": values["AGGONE.01"], + "order_by": [int(position) for position in _listed(values, "ORDERC")], + } + + +def _q75(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "category": values["CATEGORY.01"]} + + +def _thirty_day_window(values: Mapping[str, str]) -> dict[str, Any]: + return {"sales_date": values["SALES_DATE.01"]} + + +def _q76(values: Mapping[str, str]) -> dict[str, Any]: + return { + "null_col_ss": values["NULLCOLSS.01"], + "null_col_ws": values["NULLCOLWS.01"], + "null_col_cs": values["NULLCOLCS.01"], + } + + +def _q78(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "select_columns": values["SELECTONE.01"]} + + +def _q79(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL tests d_year in (YEAR, YEAR+1, YEAR+2); the implementations add the offsets. + return { + "year": int(values["YEAR.01"]), + "dep_count": int(values["DEPCNT.01"]), + "vehicle_count": int(values["VEHCNT.01"]), + } + + +def _q81(values: Mapping[str, str]) -> dict[str, Any]: + return {"year": int(values["YEAR.01"]), "state": values["STATE.01"]} + + +def _q82(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL tests i_current_price between PRICE and PRICE+30 and a 60-day window from INVDATE. YEAR only + # constrains how INVDATE was drawn, so it does not reach the SQL. + price = int(values["PRICE.01"]) + return { + "price_min": price, + "price_max": price + 30, + "sales_date": values["INVDATE.01"], + "manufact_ids": [int(value) for value in _listed(values, "MANUFACT_ID")], + } + + +def _q83(values: Mapping[str, str]) -> dict[str, Any]: + # Three dates, in the order the template lists them. + return {"dates": [values["RETURNED_DATE_ONE.01"], values["RETURNED_DATE_TWO.01"], values["RETURNED_DATE_THREE.01"]]} + + +def _q84(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL bounds the income band at INCOME and INCOME + 50000; the implementation adds the 50000. + return {"city": values["CITY.01"], "income_band": int(values["INCOME.01"])} + + +def _q85(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "marital_statuses": _listed(values, "MS"), + "education_statuses": _listed(values, "ES"), + "states": _listed(values, "STATE"), + } + + +def _q88(values: Mapping[str, str]) -> dict[str, Any]: + return {"dep_counts": [int(value) for value in _listed(values, "HOUR")]} + + +def _q89(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "categories": [values[f"CAT_{letter}.01"] for letter in "ABCDEF"], + "classes": [values[f"CLASS_{letter}.01"] for letter in "ABCDEF"], + } + + +def _q90(values: Mapping[str, str]) -> dict[str, Any]: + # The SQL takes the two hours HOUR_AM and HOUR_AM + 1, and HOUR_PM and HOUR_PM + 1; the implementation adds the 1. + return { + "hour_am": int(values["HOUR_AM.01"]), + "hour_pm": int(values["HOUR_PM.01"]), + "dep_count": int(values["DEPCNT.01"]), + } + + +def _q91(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return { + "year": year, + "month": month, + "buy_potential": values["BUY_POTENTIAL.01"], + "gmt_offset": int(values["GMT.01"]), + } + + +def _q92(values: Mapping[str, str]) -> dict[str, Any]: + # YEAR only bounds the draw of WSDATE; the SQL uses the manufacturer id and the date. + return {"manufact_id": int(values["IMID.01"]), "sales_date": values["WSDATE.01"]} + + +def _q93(values: Mapping[str, str]) -> dict[str, Any]: + return {"reason": values["REASON.01"]} + + +def _q94(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month, "states": [values["STATE.01"]]} + + +def _q96(values: Mapping[str, str]) -> dict[str, Any]: + return {"hour": int(values["HOUR.01"]), "dep_count": int(values["DEPCNT.01"])} + + +def _q98(values: Mapping[str, str]) -> dict[str, Any]: + # YEAR only bounds the draw of SDATE; the SQL uses the three categories and the date. + return {"categories": _listed(values, "CATEGORY"), "sales_date": values["SDATE.01"]} + + +def _q47_q57(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "select_one": values["SELECTONE.01"], + "select_two": values["SELECTTWO.01"], + "order_by": values["ORDERBY.01"], + } + + +def _q48(values: Mapping[str, str]) -> dict[str, Any]: + marital, education, states = _listed(values, "MS"), _listed(values, "ES"), _listed(values, "STATE") + parameters: dict[str, Any] = {"year": int(values["YEAR.01"])} + for group in range(3): + parameters[f"demo{group + 1}_marital"] = marital[group] + parameters[f"demo{group + 1}_education"] = education[group] + parameters[f"states{group + 1}"] = states[group * 3 : group * 3 + 3] + return parameters + + +def _q52(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month} + + +def _q55(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month, "manager_id": int(values["MANAGER.01"])} + + +def _q56(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month, "colors": _listed(values, "COLOR"), "gmt_offset": int(values["GMT.01"])} + + +def _q61(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month, "gmt_offset": float(values["GMT.01"]), "category": values["CATEGORY.01"]} + + +def _q64(values: Mapping[str, str]) -> dict[str, Any]: + return { + "year": int(values["YEAR.01"]), + "price_min": int(values["PRICE.01"]), + "colors": _listed(values, "COLOR"), + } + + +def _q69(values: Mapping[str, str]) -> dict[str, Any]: + year, month = _year_and_month(values) + return {"year": year, "month": month, "states": _listed(values, "STATE")} + + +ADAPTERS: dict[int, Adapter] = { + 1: _q1, + 2: _q2, + 3: _q3, + 4: _year_select_one, + 5: _q5, + 6: _q6, + 7: _q7, + 8: _q8, + 9: _q9, + 10: _q10, + 11: _year_select_one, + 12: _q12, + 13: _q13, + 14: _q14, + 15: _q15, + 16: _q16, + 17: _q17, + 18: _q18, + 19: _q19, + 20: _q20, + 21: _q21, + 22: _q22, + 23: _q23, + 24: _q24, + 25: _q25, + 26: _q26, + 27: _q27, + 28: _q28, + 29: _q29, + 30: _q30, + 31: _q31, + 32: _q32, + 33: _q33, + 34: _q34, + 35: _q35, + 36: _q36, + 37: _q37, + 38: _q38, + 39: _q39, + 40: _q40, + 41: _q41, + 42: _q42, + 43: _q43, + 44: _q44, + 45: _q45, + 46: _q46, + 47: _q47_q57, + 48: _q48, + 49: _q49, + 50: _q50, + 51: _dms_window, + 52: _q52, + 53: _dms_window, + 54: _q54, + 55: _q55, + 56: _q56, + 57: _q47_q57, + 58: _q58, + 59: _q59, + 60: _q60, + 61: _q61, + 62: _dms_window, + 63: _dms_window, + 64: _q64, + 65: _q65, + 66: _q66, + 67: _q67, + 68: _q68, + 69: _q69, + 70: _q70, + 71: _q71, + 72: _q72, + 73: _q73, + 74: _q74, + 75: _q75, + 76: _q76, + 77: _thirty_day_window, + 78: _q78, + 79: _q79, + 80: _thirty_day_window, + 81: _q81, + 82: _q82, + 83: _q83, + 84: _q84, + 85: _q85, + 86: _month_seq, + 87: _month_seq, + 88: _q88, + 89: _q89, + 90: _q90, + 91: _q91, + 92: _q92, + 93: _q93, + 94: _q94, + 95: _q94, + 96: _q96, + 97: _month_seq, + 98: _q98, + 99: _month_seq, +} + + +def adapter_query_ids() -> tuple[int, ...]: + """Queries that have an adapter, in ascending order.""" + return tuple(sorted(ADAPTERS)) + + +@lru_cache(maxsize=8) +def _file_sha256(path: str) -> str: + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def bind_parameters( + query_id: int, + *, + scale_factor: float, + seed: int | None = None, + stream_id: int = 0, + dsqgen: Any | None = None, +) -> ParameterBinding: + """The DataFrame parameters for ``query_id`` that match dsqgen's SQL for the same seed, scale and stream. + + Raises: + KeyError: If the query has no adapter (it must not silently fall back to the defaults). + ValueError: If dsqgen did not log a value the adapter needs. + """ + if query_id not in ADAPTERS: + raise KeyError(f"Q{query_id} has no parameter adapter; adapters exist for {list(adapter_query_ids())}") + if dsqgen is None: + from benchbox.core.tpcds.c_tools import DSQGenBinary + + dsqgen = DSQGenBinary() + logged = dict( + dsqgen.generate_parameter_log(query_id, seed=seed, scale_factor=scale_factor, stream_id=stream_id).substitutions + ) + try: + parameters = ADAPTERS[query_id](logged) + except KeyError as exc: + raise ValueError(f"dsqgen did not log {exc.args[0]!r} for Q{query_id} (logged: {sorted(logged)})") from exc + return ParameterBinding( + query_id=query_id, + scale_factor=scale_factor, + seed=seed, + stream_id=stream_id, + dsqgen_sha256=_file_sha256(str(dsqgen.dsqgen_path)), + logged=logged, + parameters=parameters, + ) diff --git a/benchbox/core/tpcds/dataframe_queries/parameters.py b/benchbox/core/tpcds/dataframe_queries/parameters.py index a487590116..fb967be34b 100644 --- a/benchbox/core/tpcds/dataframe_queries/parameters.py +++ b/benchbox/core/tpcds/dataframe_queries/parameters.py @@ -7,6 +7,9 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -48,23 +51,30 @@ def _load_default_params() -> dict[int, dict[str, Any]]: TPCDS_DEFAULT_PARAMS: dict[int, dict[str, Any]] = _load_default_params() -# Module-level parameter overrides. When set by the DataFrame run path before -# query execution, get_parameters() merges these into the defaults. This avoids -# changing the call signature that all 99 query functions depend on. -_parameter_overrides: dict[int, dict[str, Any]] | None = None +_parameter_overrides: ContextVar[dict[int, dict[str, Any]] | None] = ContextVar( + "tpcds_parameter_overrides", default=None +) def set_parameter_overrides(overrides: dict[int, dict[str, Any]] | None) -> None: - """Set parameter overrides for the current benchmark run.""" - global _parameter_overrides - _parameter_overrides = overrides + _parameter_overrides.set(overrides) + + +@contextmanager +def parameter_overrides(overrides: dict[int, dict[str, Any]]) -> Iterator[None]: + token = _parameter_overrides.set({**(_parameter_overrides.get() or {}), **overrides}) + try: + yield + finally: + _parameter_overrides.reset(token) def get_parameters(query_id: int) -> TPCDSParameters: """Get parameters for a TPC-DS query.""" params = dict(TPCDS_DEFAULT_PARAMS.get(query_id, {})) - if _parameter_overrides is not None and query_id in _parameter_overrides: - params.update(_parameter_overrides[query_id]) + overrides = _parameter_overrides.get() + if overrides is not None and query_id in overrides: + params.update(overrides[query_id]) return TPCDSParameters(query_id=query_id, params=params) diff --git a/benchbox/core/tpcds/dataframe_queries/production_binding.py b/benchbox/core/tpcds/dataframe_queries/production_binding.py new file mode 100644 index 0000000000..6d95c3ade7 --- /dev/null +++ b/benchbox/core/tpcds/dataframe_queries/production_binding.py @@ -0,0 +1,174 @@ +# Copyright 2026 Joe Harris / BenchBox Project + +# TPC Benchmark(TM) DS (TPC-DS) - Copyright (c) Transaction Processing Performance Council + +# Licensed under the MIT License. See LICENSE file in the project root for details. + +from __future__ import annotations + +import dataclasses +import functools +import os +import re +import threading +from collections.abc import Callable, Iterable, Mapping +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +from benchbox.core.tpcds.dataframe_queries.parameter_adapters import ADAPTERS, ParameterBinding, bind_parameters + +SQL_POWER_DSQGEN_STREAM = 0 + +_QUERY_NUMBER = re.compile(r"^Q?(\d+)[A-Za-z]?$", re.IGNORECASE) + +_cache: dict[tuple[int, float, int, int], ParameterBinding] = {} +_cache_lock = threading.Lock() + + +def power_parameter_seed(seed: int | None, stream_id: int) -> int: + from benchbox.core.tpcds.power_test import power_parameter_seed as sql_power_parameter_seed + + return sql_power_parameter_seed(seed, stream_id) + + +def query_number(query_id: Any) -> int | None: + match = _QUERY_NUMBER.match(str(query_id).strip()) + return int(match.group(1)) if match else None + + +def clear_binding_cache() -> None: + with _cache_lock: + _cache.clear() + + +def bind_power_stream( + query_numbers: Iterable[int], + *, + scale_factor: float, + seed: int | None, + stream_id: int, + dsqgen_stream: int = SQL_POWER_DSQGEN_STREAM, + dsqgen: Any | None = None, +) -> dict[int, ParameterBinding]: + rngseed = power_parameter_seed(seed, stream_id) + scale = float(scale_factor) + wanted = sorted({number for number in query_numbers if is_bound(number)}) + + def key(number: int) -> tuple[int, float, int, int]: + return (number, scale, rngseed, dsqgen_stream) + + with _cache_lock: + missing = [number for number in wanted if key(number) not in _cache] + if missing: + if dsqgen is None: + from benchbox.core.tpcds.c_tools import DSQGenBinary + + dsqgen = DSQGenBinary() + + def bind(number: int) -> ParameterBinding: + return bind_parameters(number, scale_factor=scale, seed=rngseed, stream_id=dsqgen_stream, dsqgen=dsqgen) + + workers = max(1, min(8, os.cpu_count() or 1, len(missing))) + with ThreadPoolExecutor(max_workers=workers) as pool: + fresh = dict(zip(missing, pool.map(bind, missing))) + with _cache_lock: + _cache.update({key(number): binding for number, binding in fresh.items()}) + + with _cache_lock: + return {number: _cache[key(number)] for number in wanted} + + +def _bound(implementation: Callable[[Any], Any] | None, binding: ParameterBinding) -> Callable[[Any], Any] | None: + if implementation is None: + return None + from benchbox.core.tpcds.dataframe_queries.parameters import parameter_overrides + + number = binding.query_id + parameters = dict(binding.parameters) + + @functools.wraps(implementation) + def run(ctx: Any) -> Any: + with parameter_overrides({number: dict(parameters)}): + return implementation(ctx) + + run.parameter_binding = binding + return run + + +def bind_queries(queries: Iterable[Any], bindings: Mapping[int, ParameterBinding]) -> list[Any]: + bound: list[Any] = [] + for query in queries: + number = query_number(query.query_id) + binding = bindings.get(number) if number is not None else None + if binding is None: + bound.append(query) + continue + bound.append( + dataclasses.replace( + query, + pandas_impl=_bound(query.pandas_impl, binding), + expression_impl=_bound(query.expression_impl, binding), + ) + ) + return bound + + +def query_binding(query: Any) -> ParameterBinding | None: + for implementation in (query.expression_impl, query.pandas_impl): + binding = getattr(implementation, "parameter_binding", None) + if binding is not None: + return binding + return None + + +def run_seed(benchmark_config: Any) -> int | None: + seed = (getattr(benchmark_config, "options", {}) or {}).get("seed") + return None if seed is None else int(seed) + + +def bind_power_stream_queries(queries: list[Any], benchmark_config: Any, stream_id: int) -> list[Any]: + from benchbox.core.dataframe.query_resolution import build_dataframe_query_filter_from_config + + query_filter = build_dataframe_query_filter_from_config(benchmark_config) + numbers = set() + for query in queries: + number = query_number(query.query_id) + if number is None: + continue + if query_filter is None or str(query.query_id).upper() in query_filter or f"Q{number}" in query_filter: + numbers.add(number) + bindings = bind_power_stream( + numbers, + scale_factor=getattr(benchmark_config, "scale_factor", 1.0), + seed=run_seed(benchmark_config), + stream_id=stream_id, + ) + return bind_queries(queries, bindings) + + +def is_bound(number: int | None) -> bool: + return number in ADAPTERS + + +def describe_query_parameters(seed: int | None, query_numbers: Iterable[int] | None = None) -> str: + numbers = sorted(set(range(1, 100) if query_numbers is None else query_numbers)) + bound = [n for n in numbers if is_bound(n)] + defaults = [n for n in numbers if not is_bound(n)] + first = power_parameter_seed(seed, 0) + + def listed(values: list[int]) -> str: + return " ".join(f"Q{n}" for n in values) or "none" + + return ( + f"dsqgen -LOG values (-RNGSEED {first} + stream_id, dsqgen stream {SQL_POWER_DSQGEN_STREAM}) " + f"for {len(bound)} of {len(numbers)} queries: {listed(bound)}; " + f"default_parameters.yaml for {len(defaults)}: {listed(defaults)}" + ) + + +def selected_query_numbers(benchmark_config: Any) -> list[int] | None: + queries = getattr(benchmark_config, "queries", None) + if not queries: + return None + numbers = {query_number(query_id) for query_id in queries} + return sorted(n for n in numbers if n is not None) diff --git a/benchbox/core/tpcds/dataframe_queries/qualification_values.json b/benchbox/core/tpcds/dataframe_queries/qualification_values.json new file mode 100644 index 0000000000..7baa9287fb --- /dev/null +++ b/benchbox/core/tpcds/dataframe_queries/qualification_values.json @@ -0,0 +1,1038 @@ +{ + "source": "TPC Benchmark DS Standard Specification 4.0.0, Appendix B, 'Qualification Substitution Parameters'", + "format": "values keyed by the names dsqgen -LOG writes (NAME.NN); a spec name without a suffix is NAME.01", + "not_in_template": { + "71": [ + "MANAGER.01" + ] + }, + "values": { + "1": { + "AGG_FIELD.01": "SR_RETURN_AMT", + "STATE.01": "TN", + "YEAR.01": "2000" + }, + "2": { + "YEAR.01": "2001" + }, + "3": { + "AGGC.01": "ss_ext_sales_price", + "MANUFACT.01": "128", + "MONTH.01": "11" + }, + "4": { + "SELECTONE.01": "t_s_secyear.customer_preferred_cust_flag", + "YEAR.01": "2001" + }, + "5": { + "SALES_DATE.01": "2000-08-23", + "YEAR.01": "2000" + }, + "6": { + "MONTH.01": "1", + "YEAR.01": "2001" + }, + "7": { + "ES.01": "College", + "GEN.01": "M", + "MS.01": "S", + "YEAR.01": "2000" + }, + "8": { + "QOY.01": "2", + "YEAR.01": "1998", + "ZIP.01": "24128", + "ZIP.02": "76232", + "ZIP.03": "65084", + "ZIP.04": "87816", + "ZIP.05": "83926", + "ZIP.06": "77556", + "ZIP.07": "20548", + "ZIP.08": "26231", + "ZIP.09": "43848", + "ZIP.10": "15126", + "ZIP.100": "39736", + "ZIP.101": "98569", + "ZIP.102": "28915", + "ZIP.103": "24206", + "ZIP.104": "56529", + "ZIP.105": "57647", + "ZIP.106": "54917", + "ZIP.107": "42961", + "ZIP.108": "91110", + "ZIP.109": "63981", + "ZIP.11": "91137", + "ZIP.110": "14922", + "ZIP.111": "36420", + "ZIP.112": "23006", + "ZIP.113": "67467", + "ZIP.114": "32754", + "ZIP.115": "30903", + "ZIP.116": "20260", + "ZIP.117": "31671", + "ZIP.118": "51798", + "ZIP.119": "72325", + "ZIP.12": "61265", + "ZIP.120": "85816", + "ZIP.121": "68621", + "ZIP.122": "13955", + "ZIP.123": "36446", + "ZIP.124": "41766", + "ZIP.125": "68806", + "ZIP.126": "16725", + "ZIP.127": "15146", + "ZIP.128": "22744", + "ZIP.129": "35850", + "ZIP.13": "98294", + "ZIP.130": "88086", + "ZIP.131": "51649", + "ZIP.132": "18270", + "ZIP.133": "52867", + "ZIP.134": "39972", + "ZIP.135": "96976", + "ZIP.136": "63792", + "ZIP.137": "11376", + "ZIP.138": "94898", + "ZIP.139": "13595", + "ZIP.14": "25782", + "ZIP.140": "10516", + "ZIP.141": "90225", + "ZIP.142": "58943", + "ZIP.143": "39371", + "ZIP.144": "94945", + "ZIP.145": "28587", + "ZIP.146": "96576", + "ZIP.147": "57855", + "ZIP.148": "28488", + "ZIP.149": "26105", + "ZIP.15": "17920", + "ZIP.150": "83933", + "ZIP.151": "25858", + "ZIP.152": "34322", + "ZIP.153": "44438", + "ZIP.154": "73171", + "ZIP.155": "30122", + "ZIP.156": "34102", + "ZIP.157": "22685", + "ZIP.158": "71256", + "ZIP.159": "78451", + "ZIP.16": "18426", + "ZIP.160": "54364", + "ZIP.161": "13354", + "ZIP.162": "45375", + "ZIP.163": "40558", + "ZIP.164": "56458", + "ZIP.165": "28286", + "ZIP.166": "45266", + "ZIP.167": "47305", + "ZIP.168": "69399", + "ZIP.169": "83921", + "ZIP.17": "98235", + "ZIP.170": "26233", + "ZIP.171": "11101", + "ZIP.172": "15371", + "ZIP.173": "69913", + "ZIP.174": "35942", + "ZIP.175": "15882", + "ZIP.176": "25631", + "ZIP.177": "24610", + "ZIP.178": "44165", + "ZIP.179": "99076", + "ZIP.18": "40081", + "ZIP.180": "33786", + "ZIP.181": "70738", + "ZIP.182": "26653", + "ZIP.183": "14328", + "ZIP.184": "72305", + "ZIP.185": "62496", + "ZIP.186": "22152", + "ZIP.187": "10144", + "ZIP.188": "64147", + "ZIP.189": "48425", + "ZIP.19": "84093", + "ZIP.190": "14663", + "ZIP.191": "21076", + "ZIP.192": "18799", + "ZIP.193": "30450", + "ZIP.194": "63089", + "ZIP.195": "81019", + "ZIP.196": "68893", + "ZIP.197": "24996", + "ZIP.198": "51200", + "ZIP.199": "51211", + "ZIP.20": "28577", + "ZIP.200": "45692", + "ZIP.201": "92712", + "ZIP.202": "70466", + "ZIP.203": "79994", + "ZIP.204": "22437", + "ZIP.205": "25280", + "ZIP.206": "38935", + "ZIP.207": "71791", + "ZIP.208": "73134", + "ZIP.209": "56571", + "ZIP.21": "55565", + "ZIP.210": "14060", + "ZIP.211": "19505", + "ZIP.212": "72425", + "ZIP.213": "56575", + "ZIP.214": "74351", + "ZIP.215": "68786", + "ZIP.216": "51650", + "ZIP.217": "20004", + "ZIP.218": "18383", + "ZIP.219": "76614", + "ZIP.22": "17183", + "ZIP.220": "11634", + "ZIP.221": "18906", + "ZIP.222": "15765", + "ZIP.223": "41368", + "ZIP.224": "73241", + "ZIP.225": "76698", + "ZIP.226": "78567", + "ZIP.227": "97189", + "ZIP.228": "28545", + "ZIP.229": "76231", + "ZIP.23": "54601", + "ZIP.230": "75691", + "ZIP.231": "22246", + "ZIP.232": "51061", + "ZIP.233": "90578", + "ZIP.234": "56691", + "ZIP.235": "68014", + "ZIP.236": "51103", + "ZIP.237": "94167", + "ZIP.238": "57047", + "ZIP.239": "14867", + "ZIP.24": "67897", + "ZIP.240": "73520", + "ZIP.241": "15734", + "ZIP.242": "63435", + "ZIP.243": "25733", + "ZIP.244": "35474", + "ZIP.245": "24676", + "ZIP.246": "94627", + "ZIP.247": "53535", + "ZIP.248": "17879", + "ZIP.249": "15559", + "ZIP.25": "22752", + "ZIP.250": "53268", + "ZIP.251": "59166", + "ZIP.252": "11928", + "ZIP.253": "59402", + "ZIP.254": "33282", + "ZIP.255": "45721", + "ZIP.256": "43933", + "ZIP.257": "68101", + "ZIP.258": "33515", + "ZIP.259": "36634", + "ZIP.26": "86284", + "ZIP.260": "71286", + "ZIP.261": "19736", + "ZIP.262": "58058", + "ZIP.263": "55253", + "ZIP.264": "67473", + "ZIP.265": "41918", + "ZIP.266": "19515", + "ZIP.267": "36495", + "ZIP.268": "19430", + "ZIP.269": "22351", + "ZIP.27": "18376", + "ZIP.270": "77191", + "ZIP.271": "91393", + "ZIP.272": "49156", + "ZIP.273": "50298", + "ZIP.274": "87501", + "ZIP.275": "18652", + "ZIP.276": "53179", + "ZIP.277": "18767", + "ZIP.278": "63193", + "ZIP.279": "23968", + "ZIP.28": "38607", + "ZIP.280": "65164", + "ZIP.281": "68880", + "ZIP.282": "21286", + "ZIP.283": "72823", + "ZIP.284": "58470", + "ZIP.285": "67301", + "ZIP.286": "13394", + "ZIP.287": "31016", + "ZIP.288": "70372", + "ZIP.289": "67030", + "ZIP.29": "45200", + "ZIP.290": "40604", + "ZIP.291": "24317", + "ZIP.292": "45748", + "ZIP.293": "39127", + "ZIP.294": "26065", + "ZIP.295": "77721", + "ZIP.296": "31029", + "ZIP.297": "31880", + "ZIP.298": "60576", + "ZIP.299": "24671", + "ZIP.30": "21756", + "ZIP.300": "45549", + "ZIP.301": "13376", + "ZIP.302": "50016", + "ZIP.303": "33123", + "ZIP.304": "19769", + "ZIP.305": "22927", + "ZIP.306": "97789", + "ZIP.307": "46081", + "ZIP.308": "72151", + "ZIP.309": "15723", + "ZIP.31": "29741", + "ZIP.310": "46136", + "ZIP.311": "51949", + "ZIP.312": "68100", + "ZIP.313": "96888", + "ZIP.314": "64528", + "ZIP.315": "14171", + "ZIP.316": "79777", + "ZIP.317": "28709", + "ZIP.318": "11489", + "ZIP.319": "25103", + "ZIP.32": "96765", + "ZIP.320": "32213", + "ZIP.321": "78668", + "ZIP.322": "22245", + "ZIP.323": "15798", + "ZIP.324": "27156", + "ZIP.325": "37930", + "ZIP.326": "62971", + "ZIP.327": "21337", + "ZIP.328": "51622", + "ZIP.329": "67853", + "ZIP.33": "23932", + "ZIP.330": "10567", + "ZIP.331": "38415", + "ZIP.332": "15455", + "ZIP.333": "58263", + "ZIP.334": "42029", + "ZIP.335": "60279", + "ZIP.336": "37125", + "ZIP.337": "56240", + "ZIP.338": "88190", + "ZIP.339": "50308", + "ZIP.34": "89360", + "ZIP.340": "26859", + "ZIP.341": "64457", + "ZIP.342": "89091", + "ZIP.343": "82136", + "ZIP.344": "62377", + "ZIP.345": "36233", + "ZIP.346": "63837", + "ZIP.347": "58078", + "ZIP.348": "17043", + "ZIP.349": "30010", + "ZIP.35": "29839", + "ZIP.350": "60099", + "ZIP.351": "28810", + "ZIP.352": "98025", + "ZIP.353": "29178", + "ZIP.354": "87343", + "ZIP.355": "73273", + "ZIP.356": "30469", + "ZIP.357": "64034", + "ZIP.358": "39516", + "ZIP.359": "86057", + "ZIP.36": "25989", + "ZIP.360": "21309", + "ZIP.361": "90257", + "ZIP.362": "67875", + "ZIP.363": "40162", + "ZIP.364": "11356", + "ZIP.365": "73650", + "ZIP.366": "61810", + "ZIP.367": "72013", + "ZIP.368": "30431", + "ZIP.369": "22461", + "ZIP.37": "28898", + "ZIP.370": "19512", + "ZIP.371": "13375", + "ZIP.372": "55307", + "ZIP.373": "30625", + "ZIP.374": "83849", + "ZIP.375": "68908", + "ZIP.376": "26689", + "ZIP.377": "96451", + "ZIP.378": "38193", + "ZIP.379": "46820", + "ZIP.38": "91068", + "ZIP.380": "88885", + "ZIP.381": "84935", + "ZIP.382": "69035", + "ZIP.383": "83144", + "ZIP.384": "47537", + "ZIP.385": "56616", + "ZIP.386": "94983", + "ZIP.387": "48033", + "ZIP.388": "69952", + "ZIP.389": "25486", + "ZIP.39": "72550", + "ZIP.390": "61547", + "ZIP.391": "27385", + "ZIP.392": "61860", + "ZIP.393": "58048", + "ZIP.394": "56910", + "ZIP.395": "16807", + "ZIP.396": "17871", + "ZIP.397": "35258", + "ZIP.398": "31387", + "ZIP.399": "35458", + "ZIP.40": "10390", + "ZIP.400": "35576", + "ZIP.41": "18845", + "ZIP.42": "47770", + "ZIP.43": "82636", + "ZIP.44": "41367", + "ZIP.45": "76638", + "ZIP.46": "86198", + "ZIP.47": "81312", + "ZIP.48": "37126", + "ZIP.49": "39192", + "ZIP.50": "88424", + "ZIP.51": "72175", + "ZIP.52": "81426", + "ZIP.53": "53672", + "ZIP.54": "10445", + "ZIP.55": "42666", + "ZIP.56": "66864", + "ZIP.57": "66708", + "ZIP.58": "41248", + "ZIP.59": "48583", + "ZIP.60": "82276", + "ZIP.61": "18842", + "ZIP.62": "78890", + "ZIP.63": "49448", + "ZIP.64": "14089", + "ZIP.65": "38122", + "ZIP.66": "34425", + "ZIP.67": "79077", + "ZIP.68": "19849", + "ZIP.69": "43285", + "ZIP.70": "39861", + "ZIP.71": "66162", + "ZIP.72": "77610", + "ZIP.73": "13695", + "ZIP.74": "99543", + "ZIP.75": "83444", + "ZIP.76": "83041", + "ZIP.77": "12305", + "ZIP.78": "57665", + "ZIP.79": "68341", + "ZIP.80": "25003", + "ZIP.81": "57834", + "ZIP.82": "62878", + "ZIP.83": "49130", + "ZIP.84": "81096", + "ZIP.85": "18840", + "ZIP.86": "27700", + "ZIP.87": "23470", + "ZIP.88": "50412", + "ZIP.89": "21195", + "ZIP.90": "16021", + "ZIP.91": "76107", + "ZIP.92": "71954", + "ZIP.93": "68309", + "ZIP.94": "18119", + "ZIP.95": "98359", + "ZIP.96": "64544", + "ZIP.97": "10336", + "ZIP.98": "86379", + "ZIP.99": "27068" + }, + "9": { + "AGGCELSE.01": "ss_net_paid", + "AGGCTHEN.01": "ss_ext_discount_amt", + "RC.01": "74129", + "RC.02": "122840", + "RC.03": "56580", + "RC.04": "10097", + "RC.05": "165306" + }, + "10": { + "COUNTY.01": "Rush County", + "COUNTY.02": "Toole County", + "COUNTY.03": "Jefferson County", + "COUNTY.04": "Dona Ana County", + "COUNTY.05": "La Porte County", + "MONTH.01": "1", + "YEAR.01": "2002" + }, + "11": { + "SELECTONE.01": "t_s_secyear.customer_preferred_cust_flag", + "YEAR.01": "2001" + }, + "12": { + "CATEGORY.01": "Sports", + "CATEGORY.02": "Books", + "CATEGORY.03": "Home", + "SDATE.01": "1999-02-22", + "YEAR.01": "1999" + }, + "13": { + "ES.01": "Advanced Degree", + "ES.02": "College", + "ES.03": "2 yr Degree", + "MS.01": "M", + "MS.02": "S", + "MS.03": "W", + "STATE.01": "TX", + "STATE.02": "OH", + "STATE.03": "TX", + "STATE.04": "OR", + "STATE.05": "NM", + "STATE.06": "KY", + "STATE.07": "VA", + "STATE.08": "TX", + "STATE.09": "MS" + }, + "14": { + "DAY.01": "11", + "YEAR.01": "1999" + }, + "15": { + "QOY.01": "2", + "YEAR.01": "2001" + }, + "16": { + "COUNTY_A.01": "Williamson County", + "COUNTY_B.01": "Williamson County", + "COUNTY_C.01": "Williamson County", + "COUNTY_D.01": "Williamson County", + "COUNTY_E.01": "Williamson County", + "MONTH.01": "2", + "STATE.01": "GA", + "YEAR.01": "2002" + }, + "17": { + "YEAR.01": "2001" + }, + "18": { + "ES.01": "Unknown", + "GEN.01": "F", + "MONTH.01": "1", + "MONTH.02": "6", + "MONTH.03": "8", + "MONTH.04": "9", + "MONTH.05": "12", + "MONTH.06": "2", + "STATE.01": "MS", + "STATE.02": "IN", + "STATE.03": "ND", + "STATE.04": "OK", + "STATE.05": "NM", + "STATE.06": "VA", + "STATE.07": "MS", + "YEAR.01": "1998" + }, + "19": { + "MANAGER.01": "8", + "MONTH.01": "11", + "YEAR.01": "1998" + }, + "20": { + "CATEGORY.01": "Sports", + "CATEGORY.02": "Books", + "CATEGORY.03": "Home", + "SDATE.01": "1999-02-22", + "YEAR.01": "1999" + }, + "21": { + "SALES_DATE.01": "2000-03-11", + "YEAR.01": "2000" + }, + "22": { + "DMS.01": "1200" + }, + "23": { + "MONTH.01": "2", + "TOPPERCENT.01": "50", + "YEAR.01": "2000" + }, + "24": { + "AMOUNTONE.01": "ss_net_paid", + "COLOR.01": "peach", + "COLOR.02": "saddle", + "MARKET.01": "8" + }, + "25": { + "AGG.01": "sum", + "YEAR.01": "2001" + }, + "26": { + "ES.01": "College", + "GEN.01": "M", + "MS.01": "S", + "YEAR.01": "2000" + }, + "27": { + "ES.01": "College", + "GEN.01": "M", + "MS.01": "S", + "STATE_A.01": "TN", + "STATE_B.01": "TN", + "STATE_C.01": "TN", + "STATE_D.01": "TN", + "STATE_E.01": "TN", + "STATE_F.01": "TN", + "YEAR.01": "2002" + }, + "28": { + "COUPONAMT.01": "459", + "COUPONAMT.02": "2323", + "COUPONAMT.03": "12214", + "COUPONAMT.04": "6071", + "COUPONAMT.05": "836", + "COUPONAMT.06": "7326", + "LISTPRICE.01": "8", + "LISTPRICE.02": "90", + "LISTPRICE.03": "142", + "LISTPRICE.04": "135", + "LISTPRICE.05": "122", + "LISTPRICE.06": "154", + "WHOLESALECOST.01": "57", + "WHOLESALECOST.02": "31", + "WHOLESALECOST.03": "79", + "WHOLESALECOST.04": "38", + "WHOLESALECOST.05": "17", + "WHOLESALECOST.06": "7" + }, + "29": { + "AGG.01": "sum", + "MONTH.01": "9", + "YEAR.01": "1999" + }, + "30": { + "STATE.01": "GA", + "YEAR.01": "2002" + }, + "31": { + "AGG.01": "ss1.ca_county", + "YEAR.01": "2000" + }, + "32": { + "CSDATE.01": "2000-01-27", + "IMID.01": "977", + "YEAR.01": "2000" + }, + "33": { + "CATEGORY.01": "Electronics", + "GMT.01": "-5", + "MONTH.01": "5", + "YEAR.01": "1998" + }, + "34": { + "BPONE.01": ">10000", + "BPTWO.01": "Unknown", + "COUNTY_A.01": "Williamson County", + "COUNTY_B.01": "Williamson County", + "COUNTY_C.01": "Williamson County", + "COUNTY_D.01": "Williamson County", + "COUNTY_E.01": "Williamson County", + "COUNTY_F.01": "Williamson County", + "COUNTY_G.01": "Williamson County", + "COUNTY_H.01": "Williamson County", + "YEAR.01": "1999" + }, + "35": { + "AGGONE.01": "min", + "AGGTHREE.01": "avg", + "AGGTWO.01": "max", + "YEAR.01": "2002" + }, + "36": { + "STATE_A.01": "TN", + "STATE_B.01": "TN", + "STATE_C.01": "TN", + "STATE_D.01": "TN", + "STATE_E.01": "TN", + "STATE_F.01": "TN", + "STATE_G.01": "TN", + "STATE_H.01": "TN", + "YEAR.01": "2001" + }, + "37": { + "INVDATE.01": "2000-02-01", + "MANUFACT_ID.01": "677", + "MANUFACT_ID.02": "940", + "MANUFACT_ID.03": "694", + "MANUFACT_ID.04": "808", + "PRICE.01": "68" + }, + "38": { + "DMS.01": "1200" + }, + "39": { + "MONTH.01": "1", + "YEAR.01": "2001" + }, + "40": { + "SALES_DATE.01": "2000-03-11", + "YEAR.01": "2000" + }, + "41": { + "COLOR.01": "powder", + "COLOR.02": "khaki", + "COLOR.03": "brown", + "COLOR.04": "honeydew", + "COLOR.05": "floral", + "COLOR.06": "deep", + "COLOR.07": "light", + "COLOR.08": "cornflower", + "COLOR.09": "midnight", + "COLOR.10": "snow", + "COLOR.11": "cyan", + "COLOR.12": "papaya", + "COLOR.13": "orange", + "COLOR.14": "frosted", + "COLOR.15": "forest", + "COLOR.16": "ghost", + "MANUFACT.01": "738", + "SIZE.01": "medium", + "SIZE.02": "extra large", + "SIZE.03": "N/A", + "SIZE.04": "small", + "SIZE.05": "petite", + "SIZE.06": "large", + "UNIT.01": "Ounce", + "UNIT.02": "Oz", + "UNIT.03": "Bunch", + "UNIT.04": "Ton", + "UNIT.05": "N/A", + "UNIT.06": "Dozen", + "UNIT.07": "Box", + "UNIT.08": "Pound", + "UNIT.09": "Pallet", + "UNIT.10": "Gross", + "UNIT.11": "Cup", + "UNIT.12": "Dram", + "UNIT.13": "Each", + "UNIT.14": "Tbl", + "UNIT.15": "Lb", + "UNIT.16": "Bundle" + }, + "42": { + "MONTH.01": "11", + "YEAR.01": "2000" + }, + "43": { + "GMT.01": "-5", + "YEAR.01": "2000" + }, + "44": { + "NULLCOLSS.01": "ss_addr_sk", + "STORE.01": "4" + }, + "45": { + "GBOBC.01": "ca_city", + "QOY.01": "2", + "YEAR.01": "2001" + }, + "46": { + "CITY_A.01": "Fairview", + "CITY_B.01": "Midway", + "CITY_C.01": "Fairview", + "CITY_D.01": "Fairview", + "CITY_E.01": "Fairview", + "DEPCNT.01": "4", + "VEHCNT.01": "3", + "YEAR.01": "1999" + }, + "47": { + "ORDERBY.01": "s_store_name", + "SELECTONE.01": "v1.i_category, v1.i_brand, v1.s_store_name, v1.s_company_name", + "SELECTTWO.01": ",v1.d_year, v1.d_moy", + "YEAR.01": "1999" + }, + "48": { + "ES.01": "4 yr Degree", + "ES.02": "2 yr Degree", + "ES.03": "College", + "MS.01": "M", + "MS.02": "D", + "MS.03": "S", + "STATE.01": "CO", + "STATE.02": "OH", + "STATE.03": "TX", + "STATE.04": "OR", + "STATE.05": "MN", + "STATE.06": "KY", + "STATE.07": "VA", + "STATE.08": "CA", + "STATE.09": "MS", + "YEAR.01": "2000" + }, + "49": { + "MONTH.01": "12", + "YEAR.01": "2001" + }, + "50": { + "MONTH.01": "8", + "YEAR.01": "2001" + }, + "51": { + "DMS.01": "1200" + }, + "52": { + "MONTH.01": "11", + "YEAR.01": "2000" + }, + "53": { + "DMS.01": "1200" + }, + "54": { + "CATEGORY.01": "Women", + "CLASS.01": "maternity", + "MONTH.01": "12", + "YEAR.01": "1998" + }, + "55": { + "MANAGER.01": "28", + "MONTH.01": "11", + "YEAR.01": "1999" + }, + "56": { + "COLOR.01": "slate", + "COLOR.02": "blanched", + "COLOR.03": "burnished", + "GMT.01": "-5", + "MONTH.01": "2", + "YEAR.01": "2001" + }, + "57": { + "ORDERBY.01": "cc_name", + "SELECTONE.01": "v1.i_category, v1.i_brand, v1.cc_name", + "SELECTTWO.01": ",v1.d_year, v1.d_moy", + "YEAR.01": "1999" + }, + "58": { + "SALES_DATE.01": "2000-01-03" + }, + "59": { + "DMS.01": "1212" + }, + "60": { + "CATEGORY.01": "Music", + "GMT.01": "-5", + "MONTH.01": "9", + "YEAR.01": "1998" + }, + "61": { + "CATEGORY.01": "Jewelry", + "GMT.01": "-5", + "MONTH.01": "11", + "YEAR.01": "1998" + }, + "62": { + "DMS.01": "1200" + }, + "63": { + "DMS.01": "1200" + }, + "64": { + "COLOR.01": "purple", + "COLOR.02": "burlywood", + "COLOR.03": "indian", + "COLOR.04": "spring", + "COLOR.05": "floral", + "COLOR.06": "medium", + "PRICE.01": "64", + "YEAR.01": "1999" + }, + "65": { + "DMS.01": "1176" + }, + "66": { + "NETONE.01": "ws_net_paid", + "NETTWO.01": "cs_net_paid_inc_tax", + "SALESONE.01": "ws_ext_sales_price", + "SALESTWO.01": "cs_sales_price", + "SMC.01": "DHL", + "SMC.02": "BARIAN", + "TIMEONE.01": "30838", + "YEAR.01": "2001" + }, + "67": { + "DMS.01": "1200" + }, + "68": { + "CITY_A.01": "Fairview", + "CITY_B.01": "Midway", + "DEPCNT.01": "4", + "VEHCNT.01": "3", + "YEAR.01": "1999" + }, + "69": { + "MONTH.01": "4", + "STATE.01": "KY", + "STATE.02": "GA", + "STATE.03": "NM", + "YEAR.01": "2001" + }, + "70": { + "DMS.01": "1200" + }, + "71": { + "MONTH.01": "11", + "YEAR.01": "1999" + }, + "72": { + "BP.01": ">10000", + "MS.01": "D", + "YEAR.01": "1999" + }, + "73": { + "BPONE.01": ">10000", + "BPTWO.01": "Unknown", + "COUNTY_A.01": "Williamson County", + "COUNTY_B.01": "Franklin Parish", + "COUNTY_C.01": "Bronx County", + "COUNTY_D.01": "Orange County", + "YEAR.01": "1999" + }, + "74": { + "AGGONE.01": "sum", + "ORDERC.01": "1", + "ORDERC.02": "1", + "ORDERC.03": "1", + "YEAR.01": "2001" + }, + "75": { + "CATEGORY.01": "Books", + "YEAR.01": "2002" + }, + "76": { + "NULLCOLCS.01": "cs_ship_addr_sk", + "NULLCOLSS.01": "ss_store_sk", + "NULLCOLWS.01": "ws_ship_customer_sk" + }, + "77": { + "SALES_DATE.01": "2000-08-23" + }, + "78": { + "SELECTONE.01": "ss_sold_year, ss_item_sk, ss_customer_sk", + "YEAR.01": "2000" + }, + "79": { + "DEPCNT.01": "6", + "VEHCNT.01": "2", + "YEAR.01": "1999" + }, + "80": { + "SALES_DATE.01": "2000-08-23" + }, + "81": { + "STATE.01": "GA", + "YEAR.01": "2000" + }, + "82": { + "INVDATE.01": "2000-05-25", + "MANUFACT_ID.01": "129", + "MANUFACT_ID.02": "270", + "MANUFACT_ID.03": "821", + "MANUFACT_ID.04": "423", + "PRICE.01": "62" + }, + "83": { + "RETURNED_DATE_ONE.01": "2000-06-30", + "RETURNED_DATE_THREE.01": "2000-11-17", + "RETURNED_DATE_TWO.01": "2000-09-27" + }, + "84": { + "CITY.01": "Edgewood", + "INCOME.01": "38128" + }, + "85": { + "ES.01": "Advanced Degree", + "ES.02": "College", + "ES.03": "2 yr Degree", + "MS.01": "M", + "MS.02": "S", + "MS.03": "W", + "STATE.01": "IN", + "STATE.02": "OH", + "STATE.03": "NJ", + "STATE.04": "WI", + "STATE.05": "CT", + "STATE.06": "KY", + "STATE.07": "LA", + "STATE.08": "IA", + "STATE.09": "AR", + "YEAR.01": "2000" + }, + "86": { + "DMS.01": "1200" + }, + "87": { + "DMS.01": "1200" + }, + "88": { + "HOUR.01": "4", + "HOUR.02": "2", + "HOUR.03": "0", + "STORE.01": "Unknown" + }, + "89": { + "CAT_A.01": "Books", + "CAT_B.01": "Electronics", + "CAT_C.01": "Sports", + "CAT_D.01": "Men", + "CAT_E.01": "Jewelry", + "CAT_F.01": "Women", + "CLASS_A.01": "computers", + "CLASS_B.01": "stereo", + "CLASS_C.01": "football", + "CLASS_D.01": "shirts", + "CLASS_E.01": "birdal", + "CLASS_F.01": "dresses", + "YEAR.01": "1999" + }, + "90": { + "DEPCNT.01": "6", + "HOUR_AM.01": "8", + "HOUR_PM.01": "19" + }, + "91": { + "BUY_POTENTIAL.01": "Unknown", + "GMT.01": "-7", + "MONTH.01": "11", + "YEAR.01": "1998" + }, + "92": { + "IMID.01": "350", + "WSDATE.01": "2000-01-27" + }, + "93": { + "REASON.01": "reason 28" + }, + "94": { + "MONTH.01": "2", + "STATE.01": "IL", + "YEAR.01": "1999" + }, + "95": { + "MONTH.01": "2", + "STATE.01": "IL", + "YEAR.01": "1999" + }, + "96": { + "DEPCNT.01": "7", + "HOUR.01": "20" + }, + "97": { + "DMS.01": "1200" + }, + "98": { + "CATEGORY.01": "Sports", + "CATEGORY.02": "Books", + "CATEGORY.03": "Home", + "SDATE.01": "1999-02-22", + "YEAR.01": "1999" + }, + "99": { + "DMS.01": "1200" + } + } +} diff --git a/benchbox/core/tpcds/dataframe_queries/queries.py b/benchbox/core/tpcds/dataframe_queries/queries.py index 802568d2a6..9518921d61 100644 --- a/benchbox/core/tpcds/dataframe_queries/queries.py +++ b/benchbox/core/tpcds/dataframe_queries/queries.py @@ -64,6 +64,56 @@ def _tables(ctx: DataFrameContext, *names: str) -> tuple[Any, ...]: return tuple(ctx.get_table(name) for name in names) +def _none_for_null(frame: Any, columns: list[str]) -> Any: + """Report NULL as None rather than NaN in the named pandas columns, as the SQL surface does. + + Only a column that actually holds a NULL is converted, so a result without NULLs keeps its native + dtypes (object columns are not supported by every pandas-family backend). A lazy Dask frame cannot + be asked whether a column holds a NULL without computing it, so every named column is converted + lazily (``notna`` is missing on a Dask Series, hence ``~isna()``). + """ + if hasattr(frame, "npartitions"): + return frame.assign( + **{column: frame[column].astype(object).where(~frame[column].isna(), None) for column in columns} + ) + frame = frame.copy() + for column in columns: + nulls = frame[column].isna() + if nulls.any(): + frame[column] = frame[column].astype(object).where(~nulls, None) + return frame + + +def _sum_or_null_expression(ctx: DataFrameContext, value: Any) -> Any: + return ctx.when(value.count() > ctx.lit(0)).then(value.sum()).otherwise(ctx.lit(None)) + + +def _grouped_pandas_aggregates( + frame: Any, keys: str | list[str], aggregates: dict[str, tuple[str, str]], *, dropna: bool = True +) -> Any: + non_null = {alias: f"_{alias}_non_null" for alias, (_, method) in aggregates.items() if method == "sum"} + counts = {count: (aggregates[alias][0], "count") for alias, count in non_null.items()} + result = frame.groupby(keys, as_index=False, dropna=dropna).agg(**aggregates, **counts) + for alias, count in non_null.items(): + result[alias] = result[alias].where(result[count] > 0) + return result.drop(columns=list(counts)) + + +def _round_half_up_expression(expr: Any, decimals: int) -> Any: + """SQL ``ROUND`` for a non-negative value: halves round up, where Polars rounds them to even. + + ``0.625`` is exact in binary, so ``round(0.625, 2)`` is 0.63 in SQL and 0.62 on Polars. + """ + scale = 10**decimals + return (expr * scale + 0.5).floor() / scale + + +def _round_half_up_pandas(values: Any, decimals: int) -> Any: + """pandas counterpart of ``_round_half_up_expression`` (``Series.round`` rounds halves to even).""" + scale = 10**decimals + return ((values * scale + 0.5) // 1) / scale + + def _filter_value(params: Any, param_name: str | None, default: Any) -> Any: return params.get(param_name, default) if param_name else default @@ -95,20 +145,21 @@ def _date_item_sales_expression( alias: str, sort_by: tuple[str, ...], descending: tuple[bool, ...], + value_param: str | None = None, ) -> Any: + """``value_param`` names the parameter that picks the summed column, for a template that draws it.""" params = get_parameters(query_id) + value_col = _filter_value(params, value_param, value_col) date_dim, store_sales, item = _tables(ctx, "date_dim", "store_sales", "item") filtered = ( date_dim.join(store_sales, left_on="d_date_sk", right_on="ss_sold_date_sk") .join(item, left_on="ss_item_sk", right_on="i_item_sk") .filter(_equal_filter_expression(ctx, params, filters)) ) - return ( - filtered.group_by(*group_by) - .agg(ctx.col(value_col).sum().alias(alias)) - .sort(list(sort_by), descending=list(descending)) - .limit(100) - ) + value = ctx.col(value_col) + total = ctx.when(value.count() > ctx.lit(0)).then(value.sum()).otherwise(ctx.lit(None)).alias(alias) + grouped = filtered.group_by(*group_by).agg(total) + return _sort_null_largest_expression(ctx, grouped, list(sort_by), list(descending)).limit(100) def _date_item_sales_pandas( @@ -120,52 +171,116 @@ def _date_item_sales_pandas( alias: str, sort_by: tuple[str, ...], descending: tuple[bool, ...], + value_param: str | None = None, ) -> Any: params = get_parameters(query_id) + value_col = _filter_value(params, value_param, value_col) merged = ctx.get_table("date_dim").merge( ctx.get_table("store_sales"), left_on="d_date_sk", right_on="ss_sold_date_sk" ) merged = merged.merge(ctx.get_table("item"), left_on="ss_item_sk", right_on="i_item_sk") - return ( + grouped = ( _equal_filter_pandas(merged, params, filters) - .groupby(list(group_by), as_index=False) - .agg(**{alias: (value_col, "sum")}) - .sort_values(list(sort_by), ascending=[not value for value in descending]) - .head(100) + .groupby(list(group_by), as_index=False, dropna=False) + .agg(**{alias: (value_col, "sum"), "_non_null": (value_col, "count")}) ) + grouped[alias] = grouped[alias].where(grouped["_non_null"] > 0) + grouped = grouped.drop(columns="_non_null") + ordered = _sort_null_largest_pandas(grouped, list(sort_by), list(descending)).head(100) + return _none_for_null(ordered, [*group_by, alias]) -def _manufacturer_month_expression(ctx: DataFrameContext, query_id: int, value_col: str, alias: str) -> Any: +def _manufacturer_month_expression( + ctx: DataFrameContext, + query_id: int, + value_col: str, + alias: str, + id_col: str, + period_col: str, + avg_alias: str, + categories_a: tuple[str, ...], + classes_a: tuple[str, ...], + brands_a: tuple[str, ...], + categories_b: tuple[str, ...], + classes_b: tuple[str, ...], + brands_b: tuple[str, ...], + sort_by: tuple[str, ...], + dms_default: int = 1212, +) -> Any: params = get_parameters(query_id) - manufacturer_ids = params.get("manufacturer_ids", [88, 33, 160, 129]) + dms = params.get("dms", dms_default) col = ctx.col - return ( + lit = ctx.lit + in_a = ( + col("i_category").is_in(list(categories_a)) + & col("i_class").is_in(list(classes_a)) + & col("i_brand").is_in(list(brands_a)) + ) + in_b = ( + col("i_category").is_in(list(categories_b)) + & col("i_class").is_in(list(classes_b)) + & col("i_brand").is_in(list(brands_b)) + ) + grouped = ( ctx.get_table("store_sales") .join(ctx.get_table("item"), left_on="ss_item_sk", right_on="i_item_sk") .join(ctx.get_table("date_dim"), left_on="ss_sold_date_sk", right_on="d_date_sk") .join(ctx.get_table("store"), left_on="ss_store_sk", right_on="s_store_sk") - .filter(col("i_manufact_id").is_in(manufacturer_ids) & col("d_month_seq").is_in(list(range(1200, 1212)))) - .group_by("i_manufact_id", "d_qoy") - .agg(col(value_col).sum().alias(alias)) - .sort("i_manufact_id", "d_qoy") - .limit(100) - ) + .filter(col("d_month_seq").is_in(list(range(dms, dms + 12))) & (in_a | in_b)) + .group_by(id_col, period_col) + .agg(_sum_or_null_expression(ctx, col(value_col)).alias(alias)) + ) + with_avg = grouped.with_columns(ctx.window_avg(alias, partition_by=[id_col]).alias(avg_alias)) + # SQL keeps rows where ABS(sum-avg)/avg > 0.1 with avg > 0 (else NULL, filtered out). + # The period column only separates the groups; the SQL does not select it. + return _sort_null_largest_expression( + ctx, + with_avg.filter( + (col(avg_alias) > lit(0)) & ((col(alias) - col(avg_alias)).abs() / col(avg_alias) > lit(0.1)) + ).select([id_col, alias, avg_alias]), + list(sort_by), + ).limit(100) -def _manufacturer_month_pandas(ctx: DataFrameContext, query_id: int, value_col: str, alias: str) -> Any: +def _manufacturer_month_pandas( + ctx: DataFrameContext, + query_id: int, + value_col: str, + alias: str, + id_col: str, + period_col: str, + avg_alias: str, + categories_a: tuple[str, ...], + classes_a: tuple[str, ...], + brands_a: tuple[str, ...], + categories_b: tuple[str, ...], + classes_b: tuple[str, ...], + brands_b: tuple[str, ...], + sort_by: tuple[str, ...], + dms_default: int = 1212, +) -> Any: params = get_parameters(query_id) - manufacturer_ids = params.get("manufacturer_ids", [88, 33, 160, 129]) + dms = params.get("dms", dms_default) merged = ctx.get_table("store_sales").merge(ctx.get_table("item"), left_on="ss_item_sk", right_on="i_item_sk") merged = merged.merge(ctx.get_table("date_dim"), left_on="ss_sold_date_sk", right_on="d_date_sk") merged = merged.merge(ctx.get_table("store"), left_on="ss_store_sk", right_on="s_store_sk") - filtered = merged[ - (merged["i_manufact_id"].isin(manufacturer_ids)) & (merged["d_month_seq"].isin(list(range(1200, 1212)))) - ] - return ( - filtered.groupby(["i_manufact_id", "d_qoy"], as_index=False) - .agg(**{alias: (value_col, "sum")}) - .sort_values(["i_manufact_id", "d_qoy"]) - .head(100) + in_a = ( + (merged["i_category"].isin(list(categories_a))) + & (merged["i_class"].isin(list(classes_a))) + & (merged["i_brand"].isin(list(brands_a))) + ) + in_b = ( + (merged["i_category"].isin(list(categories_b))) + & (merged["i_class"].isin(list(classes_b))) + & (merged["i_brand"].isin(list(brands_b))) + ) + filtered = merged[(merged["d_month_seq"].isin(list(range(dms, dms + 12)))) & (in_a | in_b)] + grouped = _grouped_pandas_aggregates(filtered, [id_col, period_col], {alias: (value_col, "sum")}, dropna=False) + grouped[avg_alias] = grouped.groupby(id_col, dropna=False)[alias].transform("mean") + kept = grouped[(grouped[avg_alias] > 0) & ((grouped[alias] - grouped[avg_alias]).abs() / grouped[avg_alias] > 0.1)] + # The period column only separates the groups; the SQL does not select it. + return _none_for_null( + kept[[id_col, alias, avg_alias]].sort_values(list(sort_by)).head(100), [id_col, alias, avg_alias] ) @@ -177,26 +292,39 @@ def _item_category_sales_expression( date_key: str, value_col: str, category_param: str, - month_default: int, + sales_date_default: str, group_by: tuple[str, ...], sort_by: tuple[str, ...], + limit: int | None = 100, ) -> Any: + """``limit`` is the SQL ``LIMIT``; ``None`` for a template with none (Q98).""" params = get_parameters(query_id) - year = params.get("year", 1999) - month = params.get("month", month_default) categories = params.get(category_param, ["Sports", "Books", "Home"]) + start_date, end_date = _sales_date_window(query_id, sales_date_default) col = ctx.col lit = ctx.lit - return ( + grouped = ( ctx.get_table(sales_table) .join(ctx.get_table("item"), left_on=item_key, right_on="i_item_sk") .join(ctx.get_table("date_dim"), left_on=date_key, right_on="d_date_sk") - .filter(col("i_category").is_in(categories) & (col("d_year") == lit(year)) & (col("d_moy") == lit(month))) + .filter( + col("i_category").is_in(categories) & (col("d_date") >= lit(start_date)) & (col("d_date") <= lit(end_date)) + ) .group_by(*group_by) - .agg(col(value_col).sum().alias("itemrevenue")) - .sort(list(sort_by), descending=[False, False, True, False]) - .limit(100) + # SQL SUM() over all-NULL inputs is NULL (not 0.0), and so is the ratio built on it. + .agg( + ctx.when(col(value_col).count() > lit(0)) + .then(col(value_col).sum()) + .otherwise(lit(None)) + .alias("itemrevenue") + ) + ) + with_ratio = grouped.with_columns( + (col("itemrevenue") * 100 / ctx.window_sum("itemrevenue", partition_by=["i_class"])).alias("revenueratio") ) + # Every key ascends, so the reference puts NULLs last; Polars puts them first. + ranked = _sort_null_largest_expression(ctx, with_ratio, list(sort_by), [False] * len(sort_by)) + return ranked if limit is None else ranked.limit(limit) def _item_category_sales_pandas( @@ -207,23 +335,38 @@ def _item_category_sales_pandas( date_key: str, value_col: str, category_param: str, - month_default: int, + sales_date_default: str, group_by: tuple[str, ...], sort_by: tuple[str, ...], + limit: int | None = 100, ) -> Any: + """``limit`` is the SQL ``LIMIT``; ``None`` for a template with none (Q98).""" params = get_parameters(query_id) - year = params.get("year", 1999) - month = params.get("month", month_default) categories = params.get(category_param, ["Sports", "Books", "Home"]) + start_date, end_date = _sales_date_window(query_id, sales_date_default) merged = ctx.get_table(sales_table).merge(ctx.get_table("item"), left_on=item_key, right_on="i_item_sk") merged = merged.merge(ctx.get_table("date_dim"), left_on=date_key, right_on="d_date_sk") - filtered = merged[(merged["i_category"].isin(categories)) & (merged["d_year"] == year) & (merged["d_moy"] == month)] - return ( - filtered.groupby(list(group_by), as_index=False) - .agg(itemrevenue=(value_col, "sum")) - .sort_values(list(sort_by), ascending=[True, True, False, True]) - .head(100) - ) + filtered = merged[ + (merged["i_category"].isin(categories)) & (merged["d_date"] >= start_date) & (merged["d_date"] <= end_date) + ] + # SQL SUM() over all-NULL inputs is NULL (not 0.0), and so is the ratio built on it. Dask has no lambda + # aggregation, so the sum is masked where no input was non-NULL. A NULL key is its own group in SQL. + grouped = filtered.groupby(list(group_by), as_index=False, dropna=False).agg( + itemrevenue=(value_col, "sum"), priced=(value_col, "count") + ) + grouped["itemrevenue"] = grouped["itemrevenue"].where(grouped["priced"] > 0) + grouped = grouped.drop(columns=["priced"]) + grouped["revenueratio"] = ( + grouped["itemrevenue"] * 100 / grouped.groupby("i_class", dropna=False)["itemrevenue"].transform("sum") + ) + ordered = grouped.sort_values(list(sort_by), na_position="last") + # A NULL group key is NULL in SQL, not NaN. + result = _none_for_null(ordered if limit is None else ordered.head(limit), list(group_by)) + # Only a group with no non-NULL input is NULL; a zero-total class gives NaN (0/0) in SQL too. + null_groups = result["itemrevenue"].isna() + for column in ("itemrevenue", "revenueratio"): + result[column] = result[column].astype(object).where(~null_groups, None) + return result def _excess_discount_expression( @@ -234,30 +377,46 @@ def _excess_discount_expression( date_key: str, discount_col: str, manufact_id: int, + sales_date_default: str = "1998-03-18", ) -> Any: - params = get_parameters(query_id) - year = params.get("year", 2000) - days = params.get("days", 90) + manufact_id = get_parameters(query_id).get("manufact_id", manufact_id) + start_date, end_date = _sales_date_window(query_id, sales_date_default, days=90) sales = ctx.get_table(sales_table) date_dim = ctx.get_table("date_dim") col = ctx.col lit = ctx.lit + # The SQL's subquery is correlated on the item (``cs_item_sk = i_item_sk``): each sale is compared with + # the average discount of its own item over the window, not the average over every item. avg_discount = ( sales.join(date_dim, left_on=date_key, right_on="d_date_sk") - .filter((col("d_year") == lit(year)) & (col("d_dom") <= lit(days))) - .select(col(discount_col).mean().alias("avg_discount")) + .filter((col("d_date") >= lit(start_date)) & (col("d_date") <= lit(end_date))) + .group_by(item_key) + .agg(col(discount_col).mean().alias("avg_discount")) + .select(col(item_key).alias("avg_item_sk"), col("avg_discount")) ) return ( sales.join(ctx.get_table("item"), left_on=item_key, right_on="i_item_sk") .join(date_dim, left_on=date_key, right_on="d_date_sk") - .join(avg_discount, how="cross") + .join(avg_discount, left_on=item_key, right_on="avg_item_sk") .filter( (col("i_manufact_id") == lit(manufact_id)) - & (col("d_year") == lit(year)) - & (col("d_dom") <= lit(days)) + & (col("d_date") >= lit(start_date)) + & (col("d_date") <= lit(end_date)) & (col(discount_col) > lit(1.3) * col("avg_discount")) ) - .select(col(discount_col).sum().alias("excess_discount_amount")) + # SQL SUM() over an empty set is NULL (not 0.0): stay lazy and + # single-pass, selecting NULL when the row count is zero so every + # backend yields one NULL row like the reference query. + .select( + col(discount_col).sum().alias("excess_discount_amount"), + col(discount_col).count().alias("n"), + ) + .select( + ctx.when(col("n") > lit(0)) + .then(col("excess_discount_amount")) + .otherwise(lit(None)) + .alias("excess_discount_amount") + ) ) @@ -269,26 +428,32 @@ def _excess_discount_pandas( date_key: str, discount_col: str, manufact_id: int, + sales_date_default: str = "1998-03-18", ) -> Any: import pandas as pd - params = get_parameters(query_id) - year = params.get("year", 2000) - days = params.get("days", 90) + manufact_id = get_parameters(query_id).get("manufact_id", manufact_id) + start_date, end_date = _sales_date_window(query_id, sales_date_default, days=90) sales = ctx.get_table(sales_table) - date_dim = ctx.get_table("date_dim") + date_dim = ctx.get_table("date_dim").copy() + if len(date_dim) > 0 and hasattr(date_dim["d_date"].iloc[0], "date"): + date_dim["d_date"] = pd.to_datetime(date_dim["d_date"]).dt.date + # The SQL's subquery is correlated on the item: compare each sale with its own item's average discount. merged_for_avg = sales.merge(date_dim, left_on=date_key, right_on="d_date_sk") - avg_discount = merged_for_avg[(merged_for_avg["d_year"] == year) & (merged_for_avg["d_dom"] <= days)][ - discount_col - ].mean() + in_window = merged_for_avg[(merged_for_avg["d_date"] >= start_date) & (merged_for_avg["d_date"] <= end_date)] + avg_discount = in_window.groupby(item_key)[discount_col].mean().rename("avg_discount").reset_index() merged = sales.merge(ctx.get_table("item"), left_on=item_key, right_on="i_item_sk") merged = merged.merge(date_dim, left_on=date_key, right_on="d_date_sk") + merged = merged.merge(avg_discount, on=item_key) filtered = merged[ (merged["i_manufact_id"] == manufact_id) - & (merged["d_year"] == year) - & (merged["d_dom"] <= days) - & (merged[discount_col] > 1.3 * avg_discount) + & (merged["d_date"] >= start_date) + & (merged["d_date"] <= end_date) + & (merged[discount_col] > 1.3 * merged["avg_discount"]) ] + # SQL SUM() over an empty set is NULL (not 0.0): preserve the NULL row. + if len(filtered) == 0: + return pd.DataFrame({"excess_discount_amount": [None]}) return pd.DataFrame({"excess_discount_amount": [filtered[discount_col].sum()]}) @@ -327,7 +492,7 @@ def channel(table: str, item_key: str, date_key: str, addr_key: str, value_col: .join(addr_filter, left_on=addr_key, right_on="ca_address_sk", how="inner") .join(item_ids, on="i_item_id", how="semi") .group_by("i_item_id") - .agg(col(value_col).sum().alias("total_sales")) + .agg(_sum_or_null_expression(ctx, col(value_col)).alias("total_sales")) ) combined = ctx.concat( @@ -337,9 +502,11 @@ def channel(table: str, item_key: str, date_key: str, addr_key: str, value_col: channel("web_sales", "ws_item_sk", "ws_sold_date_sk", "ws_bill_addr_sk", "ws_ext_sales_price"), ] ) - return ( - combined.group_by("i_item_id").agg(col("total_sales").sum().alias("total_sales")).sort(list(sort_by)).head(100) - ) + return _sort_null_largest_expression( + ctx, + combined.group_by("i_item_id").agg(_sum_or_null_expression(ctx, col("total_sales")).alias("total_sales")), + list(sort_by), + ).head(100) def _three_channel_item_sales_pandas( @@ -363,13 +530,13 @@ def _three_channel_item_sales_pandas( addr_filter = addr_filter[addr_filter["ca_gmt_offset"] == params.get("gmt_offset", -5)] def channel(table: str, item_key: str, date_key: str, addr_key: str, value_col: str) -> Any: - return ( + return _grouped_pandas_aggregates( ctx.get_table(table) .merge(item[item["i_item_id"].isin(item_ids)], left_on=item_key, right_on="i_item_sk", how="inner") .merge(date_filter, left_on=date_key, right_on="d_date_sk", how="inner") - .merge(addr_filter, left_on=addr_key, right_on="ca_address_sk", how="inner") - .groupby("i_item_id", as_index=False) - .agg(total_sales=(value_col, "sum")) + .merge(addr_filter, left_on=addr_key, right_on="ca_address_sk", how="inner"), + "i_item_id", + {"total_sales": (value_col, "sum")}, ) combined = ctx.concat( @@ -379,12 +546,12 @@ def channel(table: str, item_key: str, date_key: str, addr_key: str, value_col: channel("web_sales", "ws_item_sk", "ws_sold_date_sk", "ws_bill_addr_sk", "ws_ext_sales_price"), ] ) - return ( - combined.groupby("i_item_id", as_index=False) - .agg(total_sales=("total_sales", "sum")) + result = ( + _grouped_pandas_aggregates(combined, "i_item_id", {"total_sales": ("total_sales", "sum")}) .sort_values(list(sort_by)) .head(100) ) + return _none_for_null(result, list(result.columns)) def _sales_date_window(query_id: int, default: str, days: int = 30) -> tuple[Any, Any]: @@ -424,8 +591,13 @@ def _sales_returns_rollup_expression(ctx: DataFrameContext, combined: Any) -> An col("profit").sum().alias("profit"), ], ctx=ctx, + # SQL SUM() over all-NULL inputs is NULL (not 0.0): restore NULL + # for groups with zero non-null inputs (e.g. a NULL-id channel row). + count_sources={"sales": "sales", "returns": "returns", "profit": "profit"}, ) - .sort(["channel", "id"]) + # The SQL projection has no GROUPING() column: drop the helper's grouping_id. + .select(["channel", "id", "sales", "returns", "profit"]) + .sort(["channel", "id"], nulls_last=True) .limit(100) ) @@ -443,7 +615,10 @@ def _sales_returns_rollup_pandas(ctx: DataFrameContext, combined: Any) -> Any: "profit": ("profit", "sum"), }, ctx=ctx, - ) + # SQL SUM() over all-NULL inputs is NULL (not 0.0): restore NULL + # for groups with zero non-null inputs (e.g. a NULL-id channel row). + count_sources={"sales": "sales", "returns": "returns", "profit": "profit"}, + )[["channel", "id", "sales", "returns", "profit"]] .sort_values(["channel", "id"]) .head(100) ) @@ -726,14 +901,28 @@ def _q77_expression_channel( sales = ctx.get_table(sales_table).join(date_filtered, left_on=sales_date_col, right_on="d_date_sk") if sales_dim_table: sales = sales.join(ctx.get_table(sales_dim_table), left_on=sales_dim_left, right_on=sales_dim_right) + # SQL SUM() over all-NULL inputs is NULL (not 0.0): guard each sum with its + # non-null count so NULL-sk groups keep NULL sums like the reference query. sales = sales.group_by(sales_group).agg( - [col(sales_col).sum().alias("sales"), col(profit_col).sum().alias("profit")] + [ + ctx.when(col(sales_col).count() > lit(0)).then(col(sales_col).sum()).otherwise(lit(None)).alias("sales"), + ctx.when(col(profit_col).count() > lit(0)).then(col(profit_col).sum()).otherwise(lit(None)).alias("profit"), + ] ) returns = ctx.get_table(returns_table).join(date_filtered, left_on=returns_date_col, right_on="d_date_sk") if returns_dim_table: returns = returns.join(ctx.get_table(returns_dim_table), left_on=returns_dim_left, right_on=returns_dim_right) returns = returns.group_by(returns_group).agg( - [col(returns_col).sum().alias("returns"), col(loss_col).sum().alias("profit_loss")] + [ + ctx.when(col(returns_col).count() > lit(0)) + .then(col(returns_col).sum()) + .otherwise(lit(None)) + .alias("returns"), + ctx.when(col(loss_col).count() > lit(0)) + .then(col(loss_col).sum()) + .otherwise(lit(None)) + .alias("profit_loss"), + ] ) join_kwargs: dict[str, Any] = {"how": join_how} if join_how != "cross": @@ -784,7 +973,22 @@ def _q77_pandas_channel( sales = sales.merge( ctx.get_table(sales_dim_table)[[sales_dim_right]], left_on=sales_dim_left, right_on=sales_dim_right ) - sales = sales.groupby(sales_group, as_index=False).agg(sales=(sales_col, "sum"), profit=(profit_col, "sum")) + # SQL GROUP BY keeps NULL keys and SQL SUM() over all-NULL inputs is NULL: + # keep NaN groups, then restore None for empty groups in object columns. + import pandas as _pd + + sales = sales.groupby(sales_group, as_index=False, dropna=False).agg( + sales=(sales_col, "sum"), + n_sales=(sales_col, "count"), + profit=(profit_col, "sum"), + n_profit=(profit_col, "count"), + ) + sales["sales"] = _pd.Series( + [value if count > 0 else None for value, count in zip(sales["sales"], sales["n_sales"])], dtype=object + ) + sales["profit"] = _pd.Series( + [value if count > 0 else None for value, count in zip(sales["profit"], sales["n_profit"])], dtype=object + ) returns = ctx.get_table(returns_table).merge(date_filtered, left_on=returns_date_col, right_on="d_date_sk") if returns_dim_table: returns = returns.merge( @@ -792,8 +996,18 @@ def _q77_pandas_channel( left_on=returns_dim_left, right_on=returns_dim_right, ) - returns = returns.groupby(returns_group, as_index=False).agg( - returns=(returns_col, "sum"), profit_loss=(loss_col, "sum") + returns = returns.groupby(returns_group, as_index=False, dropna=False).agg( + returns=(returns_col, "sum"), + n_returns=(returns_col, "count"), + profit_loss=(loss_col, "sum"), + n_loss=(loss_col, "count"), + ) + returns["returns"] = _pd.Series( + [value if count > 0 else None for value, count in zip(returns["returns"], returns["n_returns"])], dtype=object + ) + returns["profit_loss"] = _pd.Series( + [value if count > 0 else None for value, count in zip(returns["profit_loss"], returns["n_loss"])], + dtype=object, ) if join_how == "cross": sales["_key"] = 1 @@ -801,10 +1015,19 @@ def _q77_pandas_channel( result = sales.merge(returns, on="_key").drop(columns=["_key"]) else: result = sales.merge(returns, left_on=sales_group, right_on=returns_group, how=join_how) - result["returns"] = result["returns"].fillna(0) - result["profit_loss"] = result["profit_loss"].fillna(0) - result["profit"] = result["profit"] - result["profit_loss"] + result["returns"] = result["returns"].where(result["returns"].notna(), 0) + result["profit_loss"] = result["profit_loss"].where(result["profit_loss"].notna(), 0) + # SQL NULL propagates through the subtraction; None - x raises in pandas, + # so compute with explicit None handling. + result["profit"] = [ + None if profit is None or loss is None else profit - loss + for profit, loss in zip(result["profit"], result["profit_loss"]) + ] result["channel"] = channel_name + # A NULL dimension id arrives as float NaN, which the order-key grouping + # distinguishes from SQL NULL: map NaN back to None in an object column so + # NULL keys group and compare equal. + result[sales_group] = result[sales_group].astype(object).where(result[sales_group].notna(), None) result["id"] = result[sales_group] return result[["channel", "id", "sales", "returns", "profit"]] @@ -862,6 +1085,9 @@ def _promotion_sales_expression( ) -> Any: params = get_parameters(query_id) year = params.get("year", 2000) + gender = params.get("gender", "M") + marital_status = params.get("marital_status", "S") + education = params.get("education", "College") col, lit = ctx.col, ctx.lit return ( ctx.get_table(sales_table) @@ -870,9 +1096,9 @@ def _promotion_sales_expression( .join(ctx.get_table("item"), left_on=item_key, right_on="i_item_sk") .join(ctx.get_table("promotion"), left_on=promo_key, right_on="p_promo_sk") .filter( - (col("cd_gender") == lit("M")) - & (col("cd_marital_status") == lit("S")) - & (col("cd_education_status") == lit("College")) + (col("cd_gender") == lit(gender)) + & (col("cd_marital_status") == lit(marital_status)) + & (col("cd_education_status") == lit(education)) & ((col("p_channel_email") == lit("N")) | (col("p_channel_event") == lit("N"))) & (col("d_year") == lit(year)) ) @@ -903,6 +1129,9 @@ def _promotion_sales_pandas( ) -> Any: params = get_parameters(query_id) year = params.get("year", 2000) + gender = params.get("gender", "M") + marital_status = params.get("marital_status", "S") + education = params.get("education", "College") merged = ctx.get_table(sales_table).merge( ctx.get_table("customer_demographics"), left_on=cdemo_key, right_on="cd_demo_sk" ) @@ -910,13 +1139,13 @@ def _promotion_sales_pandas( merged = merged.merge(ctx.get_table("item"), left_on=item_key, right_on="i_item_sk") merged = merged.merge(ctx.get_table("promotion"), left_on=promo_key, right_on="p_promo_sk") filtered = merged[ - (merged["cd_gender"] == "M") - & (merged["cd_marital_status"] == "S") - & (merged["cd_education_status"] == "College") + (merged["cd_gender"] == gender) + & (merged["cd_marital_status"] == marital_status) + & (merged["cd_education_status"] == education) & ((merged["p_channel_email"] == "N") | (merged["p_channel_event"] == "N")) & (merged["d_year"] == year) ] - return ( + result = ( filtered.groupby(["i_item_id"], as_index=False) .agg( agg1=(quantity_col, "mean"), @@ -927,6 +1156,8 @@ def _promotion_sales_pandas( .sort_values(["i_item_id"]) .head(100) ) + # AVG over only NULLs is NULL; pandas reports NaN. + return _none_for_null(result, ["agg1", "agg2", "agg3", "agg4"]) def _web_multi_warehouse_orders_expression(ctx: DataFrameContext, web_sales: Any, optimized: bool) -> Any: @@ -990,11 +1221,22 @@ def _web_multi_warehouse_expression( .join(multi_warehouse_orders, on="ws_order_number", how="semi") .join(returned_orders, left_on="ws_order_number", right_on="wr_order_number", how=return_mode) ) - return result.select( + # SQL SUM() over an empty set is NULL (not 0.0) while COUNT DISTINCT is 0: + # select NULL sums when the row count is zero so every backend yields one + # NULL row like the reference query. + tallied = result.select( [ col("ws_order_number").n_unique().alias("order count"), - ctx.sum("ws_ext_ship_cost").alias("total shipping cost"), - ctx.sum("ws_net_profit").alias("total net profit"), + ctx.sum("ws_ext_ship_cost").alias("ship_cost"), + ctx.sum("ws_net_profit").alias("net_profit"), + col("ws_order_number").count().alias("n"), + ] + ) + return tallied.select( + [ + col("order count"), + ctx.when(col("n") > lit(0)).then(col("ship_cost")).otherwise(lit(None)).alias("total shipping cost"), + ctx.when(col("n") > lit(0)).then(col("net_profit")).otherwise(lit(None)).alias("total net profit"), ] ) @@ -1017,9 +1259,10 @@ def _web_multi_warehouse_pandas( ) start_date = datetime(year, month, 1).date() - if return_mode == "anti": - start_date = pd.Timestamp(datetime(year, month, 1)) end_date = start_date + timedelta(days=60) + date_dim = date_dim.copy() + if len(date_dim) > 0 and hasattr(date_dim["d_date"].iloc[0], "date"): + date_dim["d_date"] = pd.to_datetime(date_dim["d_date"]).dt.date date_filtered = date_dim[(date_dim["d_date"] >= start_date) & (date_dim["d_date"] <= end_date)][["d_date_sk"]] ca_filtered = customer_address[customer_address["ca_state"].isin(states)] ws_filtered = web_site[web_site["web_company_name"] == "pri"] @@ -1047,6 +1290,9 @@ def _web_multi_warehouse_pandas( )[["wr_order_number"]] result = result.merge(returned_multi_wh, left_on="ws_order_number", right_on="wr_order_number") + # SQL SUM() over an empty set is NULL (not 0.0) while COUNT DISTINCT is 0. + if len(result) == 0: + return pd.DataFrame({"order count": [0], "total shipping cost": [None], "total net profit": [None]}) return pd.DataFrame( { "order count": [result["ws_order_number"].nunique()], @@ -1064,23 +1310,30 @@ def _inventory_item_expression( price_param_names: tuple[str, str], price_defaults: tuple[int, int], inventory_first: bool, + manufact_param: str = "manufact_ids", + manufact_default: tuple[int, ...] = (), + sales_date_default: str = "2001-06-02", ) -> Any: params = get_parameters(query_id) - year = params.get("year", 2000) - month_start = params.get("month_start", 1) - month_end = params.get("month_end", 6) + start_date, end_date = _sales_date_window(query_id, sales_date_default, days=60) price_min = params.get(price_param_names[0], price_defaults[0]) price_max = params.get(price_param_names[1], price_defaults[1]) + manufact_ids = params.get(manufact_param, list(manufact_default)) col, lit = ctx.col, ctx.lit price_filter = (col("i_current_price") >= lit(price_min)) & (col("i_current_price") <= lit(price_max)) - date_filter = (col("d_year") == lit(year)) & (col("d_moy") >= lit(month_start)) & (col("d_moy") <= lit(month_end)) + date_filter = (col("d_date") >= lit(start_date)) & (col("d_date") <= lit(end_date)) inventory_filter = (col("inv_quantity_on_hand") >= lit(100)) & (col("inv_quantity_on_hand") <= lit(500)) + manufact_filter = col("i_manufact_id").is_in(manufact_ids) return ( ctx.get_table("item") .join(ctx.get_table("inventory"), left_on="i_item_sk", right_on="inv_item_sk") .join(ctx.get_table("date_dim"), left_on="inv_date_sk", right_on="d_date_sk") .join(ctx.get_table(source_table), left_on="i_item_sk", right_on=source_key) - .filter(price_filter & (inventory_filter & date_filter if inventory_first else date_filter & inventory_filter)) + .filter( + price_filter + & manufact_filter + & (inventory_filter & date_filter if inventory_first else date_filter & inventory_filter) + ) .select("i_item_id", "i_item_desc", "i_current_price") .unique() .sort("i_item_id") @@ -1096,22 +1349,31 @@ def _inventory_item_pandas( price_param_names: tuple[str, str], price_defaults: tuple[int, int], _inventory_first: bool, + manufact_param: str = "manufact_ids", + manufact_default: tuple[int, ...] = (), + sales_date_default: str = "2001-06-02", ) -> Any: + import pandas as pd + params = get_parameters(query_id) - year = params.get("year", 2000) - month_start = params.get("month_start", 1) - month_end = params.get("month_end", 6) + start_date, end_date = _sales_date_window(query_id, sales_date_default, days=60) price_min = params.get(price_param_names[0], price_defaults[0]) price_max = params.get(price_param_names[1], price_defaults[1]) + manufact_ids = params.get(manufact_param, list(manufact_default)) item, inventory, date_dim = _tables(ctx, "item", "inventory", "date_dim") source = ctx.get_table(source_table) - item_filtered = item[(item["i_current_price"] >= price_min) & (item["i_current_price"] <= price_max)] - inv_filtered = inventory[(inventory["inv_quantity_on_hand"] >= 100) & (inventory["inv_quantity_on_hand"] <= 500)] - date_filtered = date_dim[ - (date_dim["d_year"] == year) & (date_dim["d_moy"] >= month_start) & (date_dim["d_moy"] <= month_end) + item_filtered = item[ + (item["i_current_price"] >= price_min) + & (item["i_current_price"] <= price_max) + & (item["i_manufact_id"].isin(manufact_ids)) ] + inv_filtered = inventory[(inventory["inv_quantity_on_hand"] >= 100) & (inventory["inv_quantity_on_hand"] <= 500)] + date_dim = date_dim.copy() + if len(date_dim) > 0 and hasattr(date_dim["d_date"].iloc[0], "date"): + date_dim["d_date"] = pd.to_datetime(date_dim["d_date"]).dt.date + date_filtered = date_dim[(date_dim["d_date"] >= start_date) & (date_dim["d_date"] <= end_date)] merged = item_filtered.merge(inv_filtered, left_on="i_item_sk", right_on="inv_item_sk") merged = merged.merge(date_filtered, left_on="inv_date_sk", right_on="d_date_sk") merged = merged[merged["i_item_sk"].isin(_to_list(source[source_key].unique()))] @@ -1207,12 +1469,21 @@ def _joined_agg_expr_condition(ctx: DataFrameContext, params: Any, condition: _J return col(column).cast_string().str.slice(0, condition[2]) == lit( _resolve_joined_agg_value(params, condition[3]) ) + if op == "substr_in": + return ( + col(column) + .cast_string() + .str.slice(0, _resolve_joined_agg_value(params, condition[2])) + .is_in([str(value) for value in list(_resolve_joined_agg_value(params, condition[3]))]) + ) if op == "prefix_ne_cols": return col(column).cast_string().str.slice(0, condition[3]) != col(condition[2]).cast_string().str.slice( 0, condition[3] ) if op == "starts_with": return col(column).str.starts_with(_resolve_joined_agg_value(params, condition[2])) + if op == "ratio_gt": + return (col(condition[1]) / col(condition[2])) > lit(_resolve_joined_agg_value(params, condition[3])) raise ValueError(f"Unsupported joined aggregate condition: {op}") @@ -1236,13 +1507,52 @@ def _joined_agg_pandas_condition(frame: Any, params: Any, condition: _JoinedAggC return frame[column].isin(list(_resolve_joined_agg_value(params, condition[2]))) if op == "prefix_eq": return frame[column].astype(str).str[: condition[2]] == _resolve_joined_agg_value(params, condition[3]) + if op == "substr_in": + length = _resolve_joined_agg_value(params, condition[2]) + values = [str(value) for value in list(_resolve_joined_agg_value(params, condition[3]))] + return frame[column].astype(str).str[:length].isin(values) if op == "prefix_ne_cols": - return frame[column].astype(str).str[: condition[3]] != frame[condition[2]].astype(str).str[: condition[3]] + both_present = frame[column].notna() & frame[condition[2]].notna() + differs = frame[column].astype(str).str[: condition[3]] != frame[condition[2]].astype(str).str[: condition[3]] + return differs & both_present if op == "starts_with": return frame[column].str.startswith(_resolve_joined_agg_value(params, condition[2])) + if op == "ratio_gt": + return (frame[condition[1]] / frame[condition[2]]) > _resolve_joined_agg_value(params, condition[3]) raise ValueError(f"Unsupported joined aggregate condition: {op}") +def _sort_null_largest_expression( + ctx: DataFrameContext, frame: Any, columns: list[str], descending: list[bool] | None = None +) -> Any: + descending = [False] * len(columns) if descending is None else descending + flags = [f"_null_{index}" for index in range(len(columns))] + original = frame.columns + keyed = frame.with_columns(*(ctx.col(name).is_null().alias(flag) for name, flag in zip(columns, flags))) + by = [name for pair in zip(flags, columns) for name in pair] + flag_descending = [value for value in descending for _ in range(2)] + return keyed.sort(by, descending=flag_descending).select(original) + + +def _sort_null_largest_pandas(frame: Any, columns: list[str], descending: list[bool]) -> Any: + if all(descending): + return frame.sort_values(columns, ascending=[False] * len(columns), na_position="first") + if not any(descending): + return frame.sort_values(columns, ascending=[True] * len(columns), na_position="last") + flags = [f"_null_{index}" for index in range(len(columns))] + keyed = frame.assign(**{flag: frame[name].isna() for name, flag in zip(columns, flags)}) + by = [name for pair in zip(flags, columns) for name in pair] + ascending = [not value for value in descending for _ in range(2)] + ordered = keyed.sort_values(by, ascending=ascending, na_position="last") + if hasattr(frame, "npartitions"): + return ordered.map_partitions(_drop_columns, flags, meta=frame._meta) + return ordered.drop(columns=flags) + + +def _drop_columns(partition: Any, columns: list[str]) -> Any: + return partition.drop(columns=columns) + + def _joined_agg_expression_impl(ctx: DataFrameContext, spec: dict[str, Any]) -> Any: params = get_parameters(spec["query_id"]) frame = ctx.get_table(spec["base"]) @@ -1254,16 +1564,26 @@ def _joined_agg_expression_impl(ctx: DataFrameContext, spec: dict[str, Any]) -> if predicate is not None: frame = frame.filter(predicate) result = frame.group_by(*spec["group_by"]).agg( - *(getattr(ctx.col(source), func)().alias(alias) for alias, source, func in spec["aggs"]) + *( + ( + _sum_or_null_expression(ctx, ctx.col(source)) if func == "sum" else getattr(ctx.col(source), func)() + ).alias(alias) + for alias, source, func in spec["aggs"] + ) ) post_filter = spec.get("post_filter") if post_filter is not None: result = result.filter(_joined_agg_expr_condition(ctx, params, post_filter)) - result = result.sort( - list(spec["sort_by"]), descending=list(spec.get("descending", (False,) * len(spec["sort_by"]))) + result = _sort_null_largest_expression( + ctx, + result, + list(spec["sort_by"]), + list(spec.get("descending", (False,) * len(spec["sort_by"]))), ) limit = spec.get("limit", 100) - return result if limit is None else result.limit(limit) + result = result if limit is None else result.limit(limit) + # Group keys the SQL groups by but does not return (Q91's marital and education status). + return result.select(*spec["select"]) if "select" in spec else result def _joined_agg_pandas_impl(ctx: DataFrameContext, spec: dict[str, Any]) -> Any: @@ -1276,16 +1596,25 @@ def _joined_agg_pandas_impl(ctx: DataFrameContext, spec: dict[str, Any]) -> Any: predicate = _joined_agg_pandas_condition(frame, params, ("and", *spec.get("filters", ()))) if predicate is not None: frame = frame[predicate] - result = frame.groupby(list(spec["group_by"]), as_index=False).agg( - **{alias: (source, func) for alias, source, func in spec["aggs"]} - ) + # SQL GROUP BY keeps a NULL key as its own group; pandas drops it unless told otherwise. + result = _grouped_pandas_aggregates( + frame, + list(spec["group_by"]), + {alias: (source, func) for alias, source, func in spec["aggs"]}, + dropna=False, + ) + for column in spec["group_by"]: + result[column] = result[column].astype(object).where(~result[column].isna(), None) post_filter = spec.get("post_filter") if post_filter is not None: result = result[_joined_agg_pandas_condition(result, params, post_filter)] descending = spec.get("descending", (False,) * len(spec["sort_by"])) - result = result.sort_values(list(spec["sort_by"]), ascending=[not value for value in descending]) + result = _sort_null_largest_pandas(result, list(spec["sort_by"]), list(descending)) limit = spec.get("limit", 100) - return result if limit is None else result.head(limit) + result = result if limit is None else result.head(limit) + # Group keys the SQL groups by but does not return (Q91's marital and education status). + result = result[list(spec["select"])] if "select" in spec else result + return _none_for_null(result, list(result.columns)) def _make_joined_agg_impl(query_id: int, title: str, family: str, spec: dict[str, Any]) -> QueryImpl: @@ -1321,7 +1650,12 @@ def _state_average_returns_expression_impl(ctx: DataFrameContext, spec: dict[str col(spec["return_customer_key"]).alias("ctr_customer_sk"), col("ca_state").alias("ctr_state"), ) - .agg(col(spec["return_amount"]).sum().alias("ctr_total_return")) + .agg( + ctx.when(col(spec["return_amount"]).count() > lit(0)) + .then(col(spec["return_amount"]).sum()) + .otherwise(lit(None)) + .alias("ctr_total_return") + ) ) state_avg = ctr.group_by("ctr_state").agg(col("ctr_total_return").mean().alias("state_avg")) ca_filtered = customer_address.filter(col("ca_state") == lit(state)) @@ -1353,10 +1687,13 @@ def _state_average_returns_pandas_impl(ctx: DataFrameContext, spec: dict[str, An merged = returns.merge(date_dim, left_on=spec["return_date_key"], right_on="d_date_sk") merged = merged.merge(customer_address, left_on=spec["return_addr_key"], right_on="ca_address_sk") merged = merged[merged["d_year"] == year] - ctr = merged.groupby([spec["return_customer_key"], "ca_state"], as_index=False).agg( - ctr_total_return=(spec["return_amount"], "sum") + ctr = merged.groupby([spec["return_customer_key"], "ca_state"], as_index=False, dropna=False).agg( + ctr_total_return=(spec["return_amount"], "sum"), returned_rows=(spec["return_amount"], "count") + ) + ctr["ctr_total_return"] = ctr["ctr_total_return"].where(ctr["returned_rows"] > 0) + ctr = ctr.drop(columns="returned_rows").rename( + columns={spec["return_customer_key"]: "ctr_customer_sk", "ca_state": "ctr_state"} ) - ctr = ctr.rename(columns={spec["return_customer_key"]: "ctr_customer_sk", "ca_state": "ctr_state"}) state_avg = ctr.groupby("ctr_state", as_index=False).agg(state_avg=("ctr_total_return", "mean")) ctr_filtered = ctr.merge(state_avg, on="ctr_state") ctr_filtered = ctr_filtered[ctr_filtered["ctr_total_return"] > ctr_filtered["state_avg"] * 1.2] @@ -1364,7 +1701,8 @@ def _state_average_returns_pandas_impl(ctx: DataFrameContext, spec: dict[str, An result = ctr_filtered.merge(customer, left_on="ctr_customer_sk", right_on="c_customer_sk") result = result.merge(ca_filtered, left_on="c_current_addr_sk", right_on="ca_address_sk") cols = [column for column in spec["pandas_select"] if column in result.columns] - return result[cols].sort_values(cols).head(100) + result = result[cols].sort_values(cols).head(100) + return _none_for_null(result, list(result.columns)) def _make_state_average_returns_impl(query_id: int, title: str, family: str, spec: dict[str, Any]) -> QueryImpl: @@ -1392,6 +1730,9 @@ def impl(ctx: DataFrameContext) -> Any: _register_generated_impl(_make_state_average_returns_impl(_query_id, _query_title, "pandas", _spec)) +_Q96_STORE_NAME = "ese" + + def q96_expression_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q96: Store Sales Time (Expression Family). @@ -1407,8 +1748,9 @@ def q96_expression_impl(ctx: DataFrameContext) -> Any: lit = ctx.lit params = get_parameters(96) - hours = params.get("hours", [(8, 9)]) - t_hour = hours[0][0] + t_hour = int(params.get("hour", 8)) + dep_count = int(params.get("dep_count", 5)) + store_name = _Q96_STORE_NAME return ( store_sales.join(time_dim, left_on="ss_sold_time_sk", right_on="t_time_sk") @@ -1417,9 +1759,8 @@ def q96_expression_impl(ctx: DataFrameContext) -> Any: .filter( (col("t_hour") == lit(t_hour)) & (col("t_minute") >= lit(30)) - & (col("hd_dep_count") == lit(0)) - & (col("s_number_employees") >= lit(200)) - & (col("s_number_employees") <= lit(295)) + & (col("hd_dep_count") == lit(dep_count)) + & (col("s_store_name") == lit(store_name)) ) .select(col("ss_sold_time_sk").count().alias("count")) ) @@ -1434,8 +1775,9 @@ def q96_pandas_impl(ctx: DataFrameContext) -> Any: ) params = get_parameters(96) - hours = params.get("hours", [(8, 9)]) - t_hour = hours[0][0] + t_hour = int(params.get("hour", 8)) + dep_count = int(params.get("dep_count", 5)) + store_name = _Q96_STORE_NAME # Join tables merged = store_sales.merge(time_dim, left_on="ss_sold_time_sk", right_on="t_time_sk") @@ -1446,9 +1788,8 @@ def q96_pandas_impl(ctx: DataFrameContext) -> Any: filtered = merged[ (merged["t_hour"] == t_hour) & (merged["t_minute"] >= 30) - & (merged["hd_dep_count"] == 0) - & (merged["s_number_employees"] >= 200) - & (merged["s_number_employees"] <= 295) + & (merged["hd_dep_count"] == dep_count) + & (merged["s_store_name"] == store_name) ] # Count @@ -1456,6 +1797,20 @@ def q96_pandas_impl(ctx: DataFrameContext) -> Any: return pd.DataFrame({"count": [count]}) +# The template fixes the base month (``d1.d_moy = 4``) and the return and re-purchase window +# (``d_moy between 4 and 10``); it draws the year and the aggregate applied to all three measures. +_Q25_FIRST_MONTH = 4 +_Q25_AGGREGATES = {"sum": "sum", "min": "min", "max": "max", "avg": "mean", "stddev_samp": "std"} + + +def _q25_aggregate(name: str) -> str: + """The column-aggregate method (Polars-style and pandas share these names) for a template ``AGG`` value.""" + try: + return _Q25_AGGREGATES[name] + except KeyError: + raise ValueError(f"Q25 AGG must be one of {sorted(_Q25_AGGREGATES)}, got {name!r}") from None + + def q25_expression_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q25: Store/Catalog Sales Item Analysis (Expression Family). @@ -1465,7 +1820,9 @@ def q25_expression_impl(ctx: DataFrameContext) -> Any: Pattern: Multi-join -> filter -> group by -> aggregate -> order by """ params = get_parameters(25) - year = params.get("year", 2001) + year = params.get("year", 2000) + agg = _q25_aggregate(params.get("agg", "sum")) + month = _Q25_FIRST_MONTH store_sales, store_returns, catalog_sales, date_dim, store, item = _tables( ctx, "store_sales", "store_returns", "catalog_sales", "date_dim", "store", "item" @@ -1473,12 +1830,13 @@ def q25_expression_impl(ctx: DataFrameContext) -> Any: col = ctx.col lit = ctx.lit - # Filter date_dim for sales and returns periods - d1 = date_dim.filter((col("d_year") == lit(year)) & (col("d_qoy").is_in([1, 2, 3]))) - d2 = date_dim.filter((col("d_year") == lit(year + 1)) & (col("d_qoy").is_in([1, 2, 3]))) - d3 = date_dim.filter((col("d_year") == lit(year + 1)) & (col("d_qoy").is_in([1, 2, 3]))) + # Date windows from the SQL template: d1 is the base month, d2/d3 span + # month..month+6, all in the same year. + d1 = date_dim.filter((col("d_year") == lit(year)) & (col("d_moy") == lit(month))) + d2 = date_dim.filter((col("d_year") == lit(year)) & (col("d_moy") >= lit(month)) & (col("d_moy") <= lit(month + 6))) + d3 = date_dim.filter((col("d_year") == lit(year)) & (col("d_moy") >= lit(month)) & (col("d_moy") <= lit(month + 6))) - return ( + joined_returns = ( store_sales.join(item, left_on="ss_item_sk", right_on="i_item_sk") .join(store, left_on="ss_store_sk", right_on="s_store_sk") .join(d1, left_on="ss_sold_date_sk", right_on="d_date_sk") @@ -1488,36 +1846,53 @@ def q25_expression_impl(ctx: DataFrameContext) -> Any: right_on=["sr_customer_sk", "sr_item_sk", "sr_ticket_number"], ) .join(d2.select("d_date_sk"), left_on="sr_returned_date_sk", right_on="d_date_sk") - .join( - catalog_sales, - left_on=["ss_customer_sk", "ss_item_sk"], - right_on=["cs_bill_customer_sk", "cs_item_sk"], - ) - .join(d3.select("d_date_sk"), left_on="cs_sold_date_sk", right_on="d_date_sk") - .group_by("i_item_id", "i_item_desc", "s_store_id", "s_store_name") - .agg( - col("ss_net_profit").sum().alias("store_sales_profit"), - col("sr_net_loss").sum().alias("store_returns_loss"), - col("cs_net_profit").sum().alias("catalog_sales_profit"), - ) - .sort("i_item_id", "i_item_desc", "s_store_id", "s_store_name") - .limit(100) + ) + return ( + # SQL joins the catalog leg on the store_returns keys; the inner join + # above drops the right-side sr_* keys, so use the equal preserved + # ss_* keys (identical values after the inner equi-join). + _sort_null_largest_expression( + ctx, + joined_returns.join( + catalog_sales, + left_on=["ss_customer_sk", "ss_item_sk"], + right_on=["cs_bill_customer_sk", "cs_item_sk"], + ) + .join(d3.select("d_date_sk"), left_on="cs_sold_date_sk", right_on="d_date_sk") + .group_by("i_item_id", "i_item_desc", "s_store_id", "s_store_name") + .agg( + *( + (_sum_or_null_expression(ctx, col(source)) if agg == "sum" else getattr(col(source), agg)()).alias( + alias + ) + for source, alias in ( + ("ss_net_profit", "store_sales_profit"), + ("sr_net_loss", "store_returns_loss"), + ("cs_net_profit", "catalog_sales_profit"), + ) + ), + ), + ["i_item_id", "i_item_desc", "s_store_id", "s_store_name"], + ).limit(100) ) def q25_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q25: Store/Catalog Sales Item Analysis (Pandas Family).""" params = get_parameters(25) - year = params.get("year", 2001) + year = params.get("year", 2000) + agg = _q25_aggregate(params.get("agg", "sum")) + month = _Q25_FIRST_MONTH store_sales, store_returns, catalog_sales, date_dim, store, item = _tables( ctx, "store_sales", "store_returns", "catalog_sales", "date_dim", "store", "item" ) - # Filter date_dim - d1 = date_dim[(date_dim["d_year"] == year) & (date_dim["d_qoy"].isin([1, 2, 3]))] - d2 = date_dim[(date_dim["d_year"] == year + 1) & (date_dim["d_qoy"].isin([1, 2, 3]))] - d3 = date_dim[(date_dim["d_year"] == year + 1) & (date_dim["d_qoy"].isin([1, 2, 3]))] + # Date windows from the SQL template: d1 is the base month, d2/d3 span + # month..month+6, all in the same year. + d1 = date_dim[(date_dim["d_year"] == year) & (date_dim["d_moy"] == month)] + d2 = date_dim[(date_dim["d_year"] == year) & (date_dim["d_moy"] >= month) & (date_dim["d_moy"] <= month + 6)] + d3 = date_dim[(date_dim["d_year"] == year) & (date_dim["d_moy"] >= month) & (date_dim["d_moy"] <= month + 6)] # Build joins merged = store_sales.merge(item, left_on="ss_item_sk", right_on="i_item_sk") @@ -1529,122 +1904,755 @@ def q25_pandas_impl(ctx: DataFrameContext) -> Any: right_on=["sr_customer_sk", "sr_item_sk", "sr_ticket_number"], ) merged = merged.merge(d2[["d_date_sk"]], left_on="sr_returned_date_sk", right_on="d_date_sk") + # SQL joins the catalog leg on the store_returns keys, not the store-sales keys. merged = merged.merge( catalog_sales, - left_on=["ss_customer_sk", "ss_item_sk"], + left_on=["sr_customer_sk", "sr_item_sk"], right_on=["cs_bill_customer_sk", "cs_item_sk"], ) merged = merged.merge(d3[["d_date_sk"]], left_on="cs_sold_date_sk", right_on="d_date_sk") # Group and aggregate - return ( - merged.groupby(["i_item_id", "i_item_desc", "s_store_id", "s_store_name"], as_index=False) - .agg( - store_sales_profit=("ss_net_profit", "sum"), - store_returns_loss=("sr_net_loss", "sum"), - catalog_sales_profit=("cs_net_profit", "sum"), + result = ( + _grouped_pandas_aggregates( + merged, + ["i_item_id", "i_item_desc", "s_store_id", "s_store_name"], + { + "store_sales_profit": ("ss_net_profit", agg), + "store_returns_loss": ("sr_net_loss", agg), + "catalog_sales_profit": ("cs_net_profit", agg), + }, + dropna=False, ) .sort_values(["i_item_id", "i_item_desc", "s_store_id", "s_store_name"]) .head(100) ) + return _none_for_null(result, list(result.columns)) -# ============================================================================= -# Moderate Queries - CTEs, subqueries, and more complex patterns -# ============================================================================= +def q43_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q43: Store Sales Day Analysis (Expression Family). + Pivots store sales into per-day-of-week sums with conditional aggregation. -def q1_expression_impl(ctx: DataFrameContext) -> Any: - """TPC-DS Q1: Customer Returns Analysis (Expression Family). + Tables: date_dim, store_sales, store + Pattern: 3-way join -> filter -> conditional-agg pivot -> order by + """ + params = get_parameters(43) + gmt_offset = params.get("gmt_offset", -5.0) + year = params.get("year", 1998) + col = ctx.col + lit = ctx.lit + days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + aliases = ["sun_sales", "mon_sales", "tue_sales", "wed_sales", "thu_sales", "fri_sales", "sat_sales"] + return _sort_null_largest_expression( + ctx, + ctx.get_table("date_dim") + .join(ctx.get_table("store_sales"), left_on="d_date_sk", right_on="ss_sold_date_sk") + .join(ctx.get_table("store"), left_on="ss_store_sk", right_on="s_store_sk") + .filter((col("s_gmt_offset") == lit(gmt_offset)) & (col("d_year") == lit(year))) + .group_by("s_store_name", "s_store_id") + .agg( + *( + _sum_or_null_expression( + ctx, ctx.when(col("d_day_name") == lit(day)).then(col("ss_sales_price")).otherwise(lit(None)) + ).alias(alias) + for day, alias in zip(days, aliases) + ) + ), + ["s_store_name", "s_store_id", *aliases], + ).limit(100) - Finds customers with above-average returns for their state. - Uses CTE pattern for state-level aggregation. - Tables: store_returns, date_dim, store, customer, customer_address - Pattern: CTE -> join -> filter by aggregation -> order by - """ - params = get_parameters(1) - year = params.get("year", 2000) - state = params.get("state", "TN") +def q43_pandas_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q43: Store Sales Day Analysis (Pandas Family).""" + params = get_parameters(43) + gmt_offset = params.get("gmt_offset", -5.0) + year = params.get("year", 1998) + + date_dim, store_sales, store = _tables(ctx, "date_dim", "store_sales", "store") + merged = date_dim.merge(store_sales, left_on="d_date_sk", right_on="ss_sold_date_sk") + merged = merged.merge(store, left_on="ss_store_sk", right_on="s_store_sk") + filtered = merged[(merged["s_gmt_offset"] == gmt_offset) & (merged["d_year"] == year)] - store_returns, date_dim, store, customer, customer_address = _tables( - ctx, "store_returns", "date_dim", "store", "customer", "customer_address" + days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + aliases = ["sun_sales", "mon_sales", "tue_sales", "wed_sales", "thu_sales", "fri_sales", "sat_sales"] + for day, alias in zip(days, aliases): + filtered[alias] = filtered["ss_sales_price"].where(filtered["d_day_name"] == day) + result = ( + _grouped_pandas_aggregates( + filtered, ["s_store_name", "s_store_id"], {alias: (alias, "sum") for alias in aliases}, dropna=False + ) + .sort_values(["s_store_name", "s_store_id", *aliases]) + .head(100) ) + return _none_for_null(result, list(result.columns)) + + +def _ticket_household_inner_expression( + ctx: DataFrameContext, + query_id: int, + year_default: int, + cities_default: list[str], + dep_default: int, + aggs: tuple[tuple[str, str], ...], + dom_range: tuple[int, int] | None = None, + dow_list: tuple[int, ...] | None = None, +) -> Any: + """Shared ticket-household inner aggregate (TPC-DS Q46/Q68 ``dn`` subquery).""" + params = get_parameters(query_id) + year = params.get("year", year_default) + years = [year + offset for offset in params.get("year_offsets", [0, 1, 2])] + cities = params.get("cities", cities_default) + dep_count = params.get("dep_count", dep_default) + vehicle_count = params.get("vehicle_count", 3) col = ctx.col lit = ctx.lit - - # CTE: Calculate customer returns by state - # Note: After joins, right-side join keys are dropped. Use sr_customer_sk (preserved) - # instead of c_customer_sk (dropped) in group_by - customer_total = ( - store_returns.join(date_dim, left_on="sr_returned_date_sk", right_on="d_date_sk") - .join(store, left_on="sr_store_sk", right_on="s_store_sk") - .join(customer, left_on="sr_customer_sk", right_on="c_customer_sk") - .join(customer_address, left_on="c_current_addr_sk", right_on="ca_address_sk") - .filter((col("d_year") == lit(year)) & (col("ca_state") == lit(state))) - .group_by("sr_customer_sk", "c_customer_id", "c_first_name", "c_last_name", "c_salutation") - .agg(col("sr_return_amt").sum().alias("ctr_total_return")) + date_filter = col("d_year").is_in(years) + if dom_range is not None: + date_filter = date_filter & col("d_dom").is_between(dom_range[0], dom_range[1]) + if dow_list is not None: + date_filter = date_filter & col("d_dow").is_in(list(dow_list)) + return ( + ctx.get_table("store_sales") + .join(ctx.get_table("date_dim"), left_on="ss_sold_date_sk", right_on="d_date_sk") + .join(ctx.get_table("store"), left_on="ss_store_sk", right_on="s_store_sk") + .join(ctx.get_table("household_demographics"), left_on="ss_hdemo_sk", right_on="hd_demo_sk") + .join(ctx.get_table("customer_address"), left_on="ss_addr_sk", right_on="ca_address_sk") + .filter( + date_filter + & col("s_city").is_in(cities) + & ((col("hd_dep_count") == lit(dep_count)) | (col("hd_vehicle_count") == lit(vehicle_count))) + ) + .group_by("ss_ticket_number", "ss_customer_sk", "ss_addr_sk", col("ca_city").alias("bought_city")) + .agg(*(col(source).sum().alias(alias) for alias, source in aggs)) ) - # Calculate state average - state_avg = customer_total.select(col("ctr_total_return").mean().alias("avg_return")) - # Main result: filter customers with above-average returns +def _ticket_household_outer_expression( + ctx: DataFrameContext, + inner: Any, + select_cols: list[str], + sort_cols: list[str], +) -> Any: + """Shared customer/current-address outer join for TPC-DS Q46/Q68.""" + col = ctx.col return ( - customer_total.join(state_avg, how="cross") - .filter(col("ctr_total_return") > col("avg_return") * lit(1.2)) - .select("c_customer_id", "c_salutation", "c_first_name", "c_last_name", "ctr_total_return") - .sort("ctr_total_return", descending=True) + inner.join(ctx.get_table("customer"), left_on="ss_customer_sk", right_on="c_customer_sk") + .join( + ctx.get_table("customer_address"), + left_on="c_current_addr_sk", + right_on="ca_address_sk", + ) + .filter(col("ca_city") != col("bought_city")) + .select(*select_cols) + # DuckDB ASC sorts NULLs last; match that placement explicitly. + .sort(sort_cols, nulls_last=True) .limit(100) ) -def q1_pandas_impl(ctx: DataFrameContext) -> Any: - """TPC-DS Q1: Customer Returns Analysis (Pandas Family).""" - params = get_parameters(1) - year = params.get("year", 2000) - state = params.get("state", "TN") +def q46_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q46: Store Sales Household Analysis (Expression Family). - store_returns, date_dim, store, customer, customer_address = _tables( - ctx, "store_returns", "date_dim", "store", "customer", "customer_address" - ) + Inner ticket aggregate over a weekend window joined to the customer's + current address, keeping tickets bought away from home. - # Build joins - merged = store_returns.merge(date_dim, left_on="sr_returned_date_sk", right_on="d_date_sk") - merged = merged.merge(store, left_on="sr_store_sk", right_on="s_store_sk") - merged = merged.merge(customer, left_on="sr_customer_sk", right_on="c_customer_sk") - merged = merged.merge(customer_address, left_on="c_current_addr_sk", right_on="ca_address_sk") + Tables: store_sales, date_dim, store, household_demographics, customer_address, customer + Pattern: subquery -> self-join -> inequality filter -> order by + """ + inner = _ticket_household_inner_expression( + ctx, + 46, + 1999, + ["Midway", "Fairview", "Fairview", "Fairview", "Fairview"], + 5, + (("amt", "ss_coupon_amt"), ("profit", "ss_net_profit")), + dow_list=(6, 0), + ) + return _ticket_household_outer_expression( + ctx, + inner, + ["c_last_name", "c_first_name", "ca_city", "bought_city", "ss_ticket_number", "amt", "profit"], + ["c_last_name", "c_first_name", "ca_city", "bought_city", "ss_ticket_number"], + ) - # Filter - filtered = merged[(merged["d_year"] == year) & (merged["ca_state"] == state)] - # Calculate customer totals - customer_total = filtered.groupby( - ["c_customer_sk", "c_customer_id", "c_first_name", "c_last_name", "c_salutation"], as_index=False - ).agg(ctr_total_return=("sr_return_amt", "sum")) +def q46_pandas_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q46: Store Sales Household Analysis (Pandas Family).""" + params = get_parameters(46) + year = params.get("year", 1999) + years = [year + offset for offset in params.get("year_offsets", [0, 1, 2])] + cities = params.get("cities", ["Midway", "Fairview", "Fairview", "Fairview", "Fairview"]) + dep_count = params.get("dep_count", 5) + vehicle_count = params.get("vehicle_count", 3) + dow = params.get("dow", [6, 0]) - # Calculate state average and filter - avg_return = customer_total["ctr_total_return"].mean() - return ( - customer_total[customer_total["ctr_total_return"] > avg_return * 1.2][ - ["c_customer_id", "c_salutation", "c_first_name", "c_last_name", "ctr_total_return"] - ] - .sort_values("ctr_total_return", ascending=False) - .head(100) + store_sales, date_dim, store, household_demographics, customer_address, customer = _tables( + ctx, "store_sales", "date_dim", "store", "household_demographics", "customer_address", "customer" ) + merged = store_sales.merge(date_dim, left_on="ss_sold_date_sk", right_on="d_date_sk") + merged = merged.merge(store, left_on="ss_store_sk", right_on="s_store_sk") + merged = merged.merge(household_demographics, left_on="ss_hdemo_sk", right_on="hd_demo_sk") + merged = merged.merge(customer_address, left_on="ss_addr_sk", right_on="ca_address_sk") + filtered = merged[ + (merged["d_year"].isin(years)) + & (merged["d_dow"].isin(dow)) + & (merged["s_city"].isin(cities)) + & ((merged["hd_dep_count"] == dep_count) | (merged["hd_vehicle_count"] == vehicle_count)) + ] + inner = ( + filtered.groupby(["ss_ticket_number", "ss_customer_sk", "ss_addr_sk", "ca_city"], as_index=False) + .agg(amt=("ss_coupon_amt", "sum"), profit=("ss_net_profit", "sum")) + .rename(columns={"ca_city": "bought_city"}) + ) + outer = inner.merge(customer, left_on="ss_customer_sk", right_on="c_customer_sk") + # The inner query renamed its address city to bought_city, so the current + # address merge has no ca_city collision and lands as plain ca_city. + outer = outer.merge(customer_address, left_on="c_current_addr_sk", right_on="ca_address_sk") + # SQL <> drops NULL cities (UNKNOWN); pandas != keeps NaN rows, so exclude + # nulls explicitly to match. + outer = outer[(outer["ca_city"] != outer["bought_city"]) & outer["ca_city"].notna() & outer["bought_city"].notna()] + cols = ["c_last_name", "c_first_name", "ca_city", "bought_city", "ss_ticket_number", "amt", "profit"] + result = outer[cols].sort_values(cols).head(100) + # A NULL merged through pandas object columns arrives as float NaN, which + # the strict comparator distinguishes from SQL NULL: map NaN back to None. + for column in result.columns: + if result[column].dtype == object: + result[column] = result[column].where(result[column].notna(), None) + return result -def q6_expression_impl(ctx: DataFrameContext) -> Any: - """TPC-DS Q6: Customer State/Item Analysis (Expression Family). +def q68_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q68: Store Sales Customer Household (Expression Family). - Finds items where more than 10% of customers are from a specific state. - Uses subquery pattern. + Same ticket-away-from-home shape as Q46 over a day-of-month window with + extended-price aggregates. - Tables: customer_address, customer, store_sales, date_dim, item - Pattern: Subquery -> join -> filter -> group by -> having + Tables: store_sales, date_dim, store, household_demographics, customer_address, customer + Pattern: subquery -> self-join -> inequality filter -> order by """ - params = get_parameters(6) - year = params.get("year", 2001) + inner = _ticket_household_inner_expression( + ctx, + 68, + 1999, + ["Midway", "Fairview"], + 5, + ( + ("extended_price", "ss_ext_sales_price"), + ("list_price", "ss_ext_list_price"), + ("extended_tax", "ss_ext_tax"), + ), + dom_range=(1, 2), + ) + return _ticket_household_outer_expression( + ctx, + inner, + [ + "c_last_name", + "c_first_name", + "ca_city", + "bought_city", + "ss_ticket_number", + "extended_price", + "extended_tax", + "list_price", + ], + ["c_last_name", "ss_ticket_number"], + ) + + +def q68_pandas_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q68: Store Sales Customer Household (Pandas Family).""" + params = get_parameters(68) + year = params.get("year", 1999) + years = [year + offset for offset in params.get("year_offsets", [0, 1, 2])] + cities = params.get("cities", ["Midway", "Fairview"]) + dep_count = params.get("dep_count", 5) + vehicle_count = params.get("vehicle_count", 3) + + store_sales, date_dim, store, household_demographics, customer_address, customer = _tables( + ctx, "store_sales", "date_dim", "store", "household_demographics", "customer_address", "customer" + ) + merged = store_sales.merge(date_dim, left_on="ss_sold_date_sk", right_on="d_date_sk") + merged = merged.merge(store, left_on="ss_store_sk", right_on="s_store_sk") + merged = merged.merge(household_demographics, left_on="ss_hdemo_sk", right_on="hd_demo_sk") + merged = merged.merge(customer_address, left_on="ss_addr_sk", right_on="ca_address_sk") + filtered = merged[ + (merged["d_year"].isin(years)) + & (merged["d_dom"] >= 1) + & (merged["d_dom"] <= 2) + & (merged["s_city"].isin(cities)) + & ((merged["hd_dep_count"] == dep_count) | (merged["hd_vehicle_count"] == vehicle_count)) + ] + inner = ( + filtered.groupby(["ss_ticket_number", "ss_customer_sk", "ss_addr_sk", "ca_city"], as_index=False) + .agg( + extended_price=("ss_ext_sales_price", "sum"), + list_price=("ss_ext_list_price", "sum"), + extended_tax=("ss_ext_tax", "sum"), + ) + .rename(columns={"ca_city": "bought_city"}) + ) + outer = inner.merge(customer, left_on="ss_customer_sk", right_on="c_customer_sk") + # The inner query renamed its address city to bought_city, so the current + # address merge has no ca_city collision and lands as plain ca_city. + outer = outer.merge(customer_address, left_on="c_current_addr_sk", right_on="ca_address_sk") + # SQL <> drops NULL cities (UNKNOWN); pandas != keeps NaN rows, so exclude + # nulls explicitly to match. + outer = outer[(outer["ca_city"] != outer["bought_city"]) & outer["ca_city"].notna() & outer["bought_city"].notna()] + cols = [ + "c_last_name", + "c_first_name", + "ca_city", + "bought_city", + "ss_ticket_number", + "extended_price", + "extended_tax", + "list_price", + ] + result = outer[cols].sort_values(["c_last_name", "ss_ticket_number"]).head(100) + # A NULL merged through pandas object columns arrives as float NaN, which + # the strict comparator distinguishes from SQL NULL: map NaN back to None. + for column in result.columns: + if result[column].dtype == object: + result[column] = result[column].where(result[column].notna(), None) + return result + + +def q79_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q79: Store Sales Customer/Store Profit (Expression Family). + + Ticket coupon/profit aggregate over a Monday window with an employee-count + filter, joined to customers and ordered by city prefix. + + Tables: store_sales, date_dim, store, household_demographics, customer + Pattern: subquery -> join -> order by substring -> limit + """ + params = get_parameters(79) + year = params.get("year", 1998) + years = [year, year + 1, year + 2] + dep_count = params.get("dep_count", 8) + vehicle_count = params.get("vehicle_count", 0) + col = ctx.col + lit = ctx.lit + inner = ( + ctx.get_table("store_sales") + .join(ctx.get_table("date_dim"), left_on="ss_sold_date_sk", right_on="d_date_sk") + .join(ctx.get_table("store"), left_on="ss_store_sk", right_on="s_store_sk") + .join(ctx.get_table("household_demographics"), left_on="ss_hdemo_sk", right_on="hd_demo_sk") + .filter( + col("d_year").is_in(years) + & (col("d_dow") == lit(1)) + & ((col("hd_dep_count") == lit(dep_count)) | (col("hd_vehicle_count") > lit(vehicle_count))) + & col("s_number_employees").is_between(200, 295) + ) + .group_by("ss_ticket_number", "ss_customer_sk", "ss_addr_sk", col("s_city").alias("s_city")) + # SQL SUM() over all-NULL inputs is NULL (not 0.0): carry non-null + # counts so the outer select can restore NULLs. + .agg( + col("ss_coupon_amt").sum().alias("amt"), + col("ss_coupon_amt").count().alias("n_amt"), + col("ss_net_profit").sum().alias("profit"), + col("ss_net_profit").count().alias("n_profit"), + ) + ) + return ( + inner.join(ctx.get_table("customer"), left_on="ss_customer_sk", right_on="c_customer_sk") + .select( + "c_last_name", + "c_first_name", + col("s_city").cast_string().str.slice(0, 30).alias("s_city"), + "ss_ticket_number", + ctx.when(col("n_amt") > lit(0)).then(col("amt")).otherwise(lit(None)).alias("amt"), + ctx.when(col("n_profit") > lit(0)).then(col("profit")).otherwise(lit(None)).alias("profit"), + ) + # DuckDB ASC sorts NULLs last; match that placement explicitly. + .sort(["c_last_name", "c_first_name", "s_city", "profit"], nulls_last=True) + .limit(100) + ) + + +def q79_pandas_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q79: Store Sales Customer/Store Profit (Pandas Family).""" + params = get_parameters(79) + year = params.get("year", 1998) + years = [year, year + 1, year + 2] + dep_count = params.get("dep_count", 8) + vehicle_count = params.get("vehicle_count", 0) + + store_sales, date_dim, store, household_demographics, customer = _tables( + ctx, "store_sales", "date_dim", "store", "household_demographics", "customer" + ) + merged = store_sales.merge(date_dim, left_on="ss_sold_date_sk", right_on="d_date_sk") + merged = merged.merge(store, left_on="ss_store_sk", right_on="s_store_sk") + merged = merged.merge(household_demographics, left_on="ss_hdemo_sk", right_on="hd_demo_sk") + filtered = merged[ + (merged["d_year"].isin(years)) + & (merged["d_dow"] == 1) + & ((merged["hd_dep_count"] == dep_count) | (merged["hd_vehicle_count"] > vehicle_count)) + & (merged["s_number_employees"] >= 200) + & (merged["s_number_employees"] <= 295) + ] + inner = filtered.groupby( + ["ss_ticket_number", "ss_customer_sk", "ss_addr_sk", "s_city"], as_index=False, dropna=False + ).agg( + amt=("ss_coupon_amt", "sum"), + n_amt=("ss_coupon_amt", "count"), + profit=("ss_net_profit", "sum"), + n_profit=("ss_net_profit", "count"), + ) + # SQL SUM() over all-NULL inputs is NULL (not 0.0): restore None for empty + # groups in object columns so NULLs compare equal. Assigning None into a + # float column coerces back to NaN, so build explicit object Series. + import pandas as _pd + + inner["amt"] = _pd.Series( + [value if count > 0 else None for value, count in zip(inner["amt"], inner["n_amt"])], dtype=object + ) + inner["profit"] = _pd.Series( + [value if count > 0 else None for value, count in zip(inner["profit"], inner["n_profit"])], dtype=object + ) + outer = inner.merge(customer, left_on="ss_customer_sk", right_on="c_customer_sk") + outer["s_city"] = outer["s_city"].astype(str).str[:30] + cols = ["c_last_name", "c_first_name", "s_city", "ss_ticket_number", "amt", "profit"] + result = outer[cols].sort_values(["c_last_name", "c_first_name", "s_city", "profit"]).head(100) + for column in result.columns: + if result[column].dtype == object: + result[column] = result[column].where(result[column].notna(), None) + return result + + +_Q50_STORE_COLS = [ + "s_store_name", + "s_company_id", + "s_street_number", + "s_street_name", + "s_street_type", + "s_suite_number", + "s_city", + "s_county", + "s_state", + "s_zip", +] + + +def q50_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q50: Store Sales Returns Analysis (Expression Family). + + Buckets store-return lags into five date-difference ranges per store + address. + + Tables: store_sales, store_returns, store, date_dim + Pattern: multi-key join -> date-diff buckets -> group by -> order by + """ + params = get_parameters(50) + year = params.get("year", 2000) + month = params.get("month", 9) + col = ctx.col + lit = ctx.lit + lag = col("sr_returned_date_sk") - col("ss_sold_date_sk") + return ( + ctx.get_table("store_sales") + .join( + ctx.get_table("store_returns"), + left_on=["ss_ticket_number", "ss_item_sk", "ss_customer_sk"], + right_on=["sr_ticket_number", "sr_item_sk", "sr_customer_sk"], + ) + .join(ctx.get_table("store"), left_on="ss_store_sk", right_on="s_store_sk") + .join(ctx.get_table("date_dim").select("d_date_sk"), left_on="ss_sold_date_sk", right_on="d_date_sk") + .join( + ctx.get_table("date_dim").filter((col("d_year") == lit(year)) & (col("d_moy") == lit(month))), + left_on="sr_returned_date_sk", + right_on="d_date_sk", + ) + .group_by(*_Q50_STORE_COLS) + .agg( + ctx.when(lag <= 30).then(1).otherwise(0).sum().alias("30 days"), + ctx.when((lag > 30) & (lag <= 60)).then(1).otherwise(0).sum().alias("31-60 days"), + ctx.when((lag > 60) & (lag <= 90)).then(1).otherwise(0).sum().alias("61-90 days"), + ctx.when((lag > 90) & (lag <= 120)).then(1).otherwise(0).sum().alias("91-120 days"), + ctx.when(lag > 120).then(1).otherwise(0).sum().alias(">120 days"), + ) + .sort(_Q50_STORE_COLS, nulls_last=True) + .limit(100) + ) + + +def q50_pandas_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q50: Store Sales Returns Analysis (Pandas Family).""" + params = get_parameters(50) + year = params.get("year", 2000) + month = params.get("month", 9) + + store_sales, store_returns, store, date_dim = _tables(ctx, "store_sales", "store_returns", "store", "date_dim") + merged = store_sales.merge( + store_returns, + left_on=["ss_ticket_number", "ss_item_sk", "ss_customer_sk"], + right_on=["sr_ticket_number", "sr_item_sk", "sr_customer_sk"], + ) + merged = merged.merge(store, left_on="ss_store_sk", right_on="s_store_sk") + merged = merged.merge(date_dim[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") + d2 = date_dim[(date_dim["d_year"] == year) & (date_dim["d_moy"] == month)] + merged = merged.merge(d2[["d_date_sk"]], left_on="sr_returned_date_sk", right_on="d_date_sk") + lag = merged["sr_returned_date_sk"] - merged["ss_sold_date_sk"] + merged["30 days"] = (lag <= 30).astype(int) + merged["31-60 days"] = ((lag > 30) & (lag <= 60)).astype(int) + merged["61-90 days"] = ((lag > 60) & (lag <= 90)).astype(int) + merged["91-120 days"] = ((lag > 90) & (lag <= 120)).astype(int) + merged[">120 days"] = (lag > 120).astype(int) + buckets = ["30 days", "31-60 days", "61-90 days", "91-120 days", ">120 days"] + return ( + merged.groupby(_Q50_STORE_COLS, as_index=False, dropna=False) + .agg(**{bucket: (bucket, "sum") for bucket in buckets}) + .sort_values(_Q50_STORE_COLS) + .head(100) + ) + + +def q65_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q65: Store Sales Item Profit (Expression Family). + + Finds store/item pairs whose revenue is at most 10% of the store average + over a twelve-month window. + + Tables: store_sales, date_dim, store, item + Pattern: nested aggregate -> average per store -> outlier filter + """ + params = get_parameters(65) + dms = params.get("dms", 1212) + col = ctx.col + lit = ctx.lit + sales = ctx.get_table("store_sales").join( + ctx.get_table("date_dim").filter(col("d_month_seq").is_between(dms, dms + 11)), + left_on="ss_sold_date_sk", + right_on="d_date_sk", + ) + revenue = sales.group_by("ss_store_sk", "ss_item_sk").agg( + _sum_or_null_expression(ctx, col("ss_sales_price")).alias("revenue") + ) + store_avg = revenue.group_by("ss_store_sk").agg(col("revenue").mean().alias("ave")) + flagged = ( + revenue.join(store_avg, on="ss_store_sk") + .filter(col("revenue") <= lit(0.1) * col("ave")) + .select("ss_store_sk", "ss_item_sk", "revenue") + ) + return ( + flagged.join(ctx.get_table("store"), left_on="ss_store_sk", right_on="s_store_sk") + .join(ctx.get_table("item"), left_on="ss_item_sk", right_on="i_item_sk") + .select("s_store_name", "i_item_desc", "revenue", "i_current_price", "i_wholesale_cost", "i_brand") + .sort(["s_store_name", "i_item_desc"], nulls_last=True) + .limit(100) + ) + + +def q65_pandas_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q65: Store Sales Item Profit (Pandas Family).""" + params = get_parameters(65) + dms = params.get("dms", 1212) + + store_sales, date_dim, store, item = _tables(ctx, "store_sales", "date_dim", "store", "item") + window = date_dim[(date_dim["d_month_seq"] >= dms) & (date_dim["d_month_seq"] <= dms + 11)] + sales = store_sales.merge(window[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") + revenue = _grouped_pandas_aggregates( + sales, ["ss_store_sk", "ss_item_sk"], {"revenue": ("ss_sales_price", "sum")}, dropna=False + ) + store_avg = revenue.groupby("ss_store_sk", as_index=False).agg(ave=("revenue", "mean")) + flagged = revenue.merge(store_avg, on="ss_store_sk") + flagged = flagged[flagged["revenue"] <= 0.1 * flagged["ave"]][["ss_store_sk", "ss_item_sk", "revenue"]] + outer = flagged.merge(store, left_on="ss_store_sk", right_on="s_store_sk") + outer = outer.merge(item, left_on="ss_item_sk", right_on="i_item_sk") + cols = ["s_store_name", "i_item_desc", "revenue", "i_current_price", "i_wholesale_cost", "i_brand"] + result = outer[cols].sort_values(["s_store_name", "i_item_desc"]).head(100) + return _none_for_null(result, cols) + + +def _q89_groups(params: Any) -> tuple[tuple[list[str], list[str]], tuple[list[str], list[str]]]: + categories = list(params.get("categories", ["Home", "Books", "Electronics", "Shoes", "Jewelry", "Men"])) + classes = list(params.get("classes", ["wallpaper", "parenting", "musical", "womens", "birdal", "pants"])) + if len(categories) != 6 or len(classes) != 6: + raise ValueError(f"Q89 needs six categories and six classes, got {categories!r} and {classes!r}") + return (categories[:3], classes[:3]), (categories[3:], classes[3:]) + + +def q89_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q89: Store Item Sales Profit (Expression Family). + + Monthly sales compared against the windowed average per + category/brand/store with a 10% deviation filter. + + Tables: item, store_sales, date_dim, store + Pattern: filtered aggregate -> window average -> deviation filter + """ + params = get_parameters(89) + year = params.get("year", 2000) + (categories_a, classes_a), (categories_b, classes_b) = _q89_groups(params) + col = ctx.col + lit = ctx.lit + in_a = col("i_category").is_in(categories_a) & col("i_class").is_in(classes_a) + in_b = col("i_category").is_in(categories_b) & col("i_class").is_in(classes_b) + grouped = ( + ctx.get_table("item") + .join(ctx.get_table("store_sales"), left_on="i_item_sk", right_on="ss_item_sk") + .join(ctx.get_table("date_dim"), left_on="ss_sold_date_sk", right_on="d_date_sk") + .join(ctx.get_table("store"), left_on="ss_store_sk", right_on="s_store_sk") + .filter((col("d_year") == lit(year)) & (in_a | in_b)) + .group_by("i_category", "i_class", "i_brand", "s_store_name", "s_company_name", "d_moy") + .agg(col("ss_sales_price").sum().alias("sum_sales")) + ) + partition = ["i_category", "i_brand", "s_store_name", "s_company_name"] + with_avg = grouped.with_columns(ctx.window_avg("sum_sales", partition_by=partition).alias("avg_monthly_sales")) + deviation = (col("sum_sales") - col("avg_monthly_sales")).abs() / col("avg_monthly_sales") + return ( + # SQL keeps rows where |sum-avg|/avg > 0.1 with avg <> 0 (else NULL, filtered out). + with_avg.filter((col("avg_monthly_sales") != lit(0)) & (deviation > lit(0.1))) + .with_columns((col("sum_sales") - col("avg_monthly_sales")).alias("diff")) + .sort(["diff", "s_store_name"], nulls_last=True) + .select( + "i_category", + "i_class", + "i_brand", + "s_store_name", + "s_company_name", + "d_moy", + "sum_sales", + "avg_monthly_sales", + ) + .limit(100) + ) + + +def q89_pandas_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q89: Store Item Sales Profit (Pandas Family).""" + params = get_parameters(89) + year = params.get("year", 2000) + (categories_a, classes_a), (categories_b, classes_b) = _q89_groups(params) + + item, store_sales, date_dim, store = _tables(ctx, "item", "store_sales", "date_dim", "store") + merged = item.merge(store_sales, left_on="i_item_sk", right_on="ss_item_sk") + merged = merged.merge(date_dim, left_on="ss_sold_date_sk", right_on="d_date_sk") + merged = merged.merge(store, left_on="ss_store_sk", right_on="s_store_sk") + in_a = (merged["i_category"].isin(categories_a)) & (merged["i_class"].isin(classes_a)) + in_b = (merged["i_category"].isin(categories_b)) & (merged["i_class"].isin(classes_b)) + filtered = merged[(merged["d_year"] == year) & (in_a | in_b)] + grouped = filtered.groupby( + ["i_category", "i_class", "i_brand", "s_store_name", "s_company_name", "d_moy"], + as_index=False, + dropna=False, + ).agg(sum_sales=("ss_sales_price", "sum")) + partition = ["i_category", "i_brand", "s_store_name", "s_company_name"] + grouped["avg_monthly_sales"] = grouped.groupby(partition, dropna=False)["sum_sales"].transform("mean") + deviation = (grouped["sum_sales"] - grouped["avg_monthly_sales"]).abs() / grouped["avg_monthly_sales"] + kept = grouped[(grouped["avg_monthly_sales"] != 0) & (deviation > 0.1)].copy() + kept["diff"] = kept["sum_sales"] - kept["avg_monthly_sales"] + cols = [ + "i_category", + "i_class", + "i_brand", + "s_store_name", + "s_company_name", + "d_moy", + "sum_sales", + "avg_monthly_sales", + ] + return kept.sort_values(["diff", "s_store_name"])[cols].head(100) + + +# ============================================================================= +# Moderate Queries - CTEs, subqueries, and more complex patterns +# ============================================================================= + + +def q1_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q1: Customer Returns Analysis (Expression Family). + + Customers whose per-store returns exceed 1.2x their store's average, + restricted to stores in a given state. + + Tables: store_returns, date_dim, store, customer + Pattern: CTE -> correlated per-store average -> filter -> order by + """ + params = get_parameters(1) + year = params.get("year", 2000) + state = params.get("state", "TN") + agg_field = params.get("agg_field") + + store_returns, date_dim, store, customer = _tables(ctx, "store_returns", "date_dim", "store", "customer") + col = ctx.col + lit = ctx.lit + + # CTE: returns per customer and store (the template draws which return column is summed). + customer_total = ( + store_returns.join(date_dim, left_on="sr_returned_date_sk", right_on="d_date_sk") + .filter(col("d_year") == lit(year)) + .group_by(col("sr_customer_sk").alias("ctr_customer_sk"), col("sr_store_sk").alias("ctr_store_sk")) + .agg( + ctx.when(col(agg_field).count() > lit(0)) + .then(col(agg_field).sum()) + .otherwise(lit(None)) + .alias("ctr_total_return") + ) + ) + + # Correlated per-store average: average over the same store only. + store_avg = customer_total.group_by("ctr_store_sk").agg(col("ctr_total_return").mean().alias("store_avg")) + + # Main result: above-average customers at stores in the target state. + return ( + customer_total.join(store_avg, on="ctr_store_sk") + .filter(col("ctr_total_return") > col("store_avg") * lit(1.2)) + .join(store.filter(col("s_state") == lit(state)), left_on="ctr_store_sk", right_on="s_store_sk") + .join(customer, left_on="ctr_customer_sk", right_on="c_customer_sk") + .select("c_customer_id") + .sort("c_customer_id") + .limit(100) + ) + + +def q1_pandas_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q1: Customer Returns Analysis (Pandas Family).""" + params = get_parameters(1) + year = params.get("year", 2000) + state = params.get("state", "TN") + agg_field = params.get("agg_field") + + store_returns, date_dim, store, customer = _tables(ctx, "store_returns", "date_dim", "store", "customer") + + # CTE: returns per customer and store (the template draws which return column is summed). + merged = store_returns.merge(date_dim[["d_date_sk", "d_year"]], left_on="sr_returned_date_sk", right_on="d_date_sk") + merged = merged[merged["d_year"] == year] + customer_total = merged.groupby(["sr_customer_sk", "sr_store_sk"], as_index=False, dropna=False).agg( + ctr_total_return=(agg_field, "sum"), priced=(agg_field, "count") + ) + # SQL SUM over only NULLs is NULL; pandas sums them to 0 (a lambda aggregation is not available on Dask). + customer_total["ctr_total_return"] = customer_total["ctr_total_return"].where(customer_total["priced"] > 0) + customer_total = customer_total.drop(columns=["priced"]) + customer_total = customer_total.rename(columns={"sr_customer_sk": "ctr_customer_sk", "sr_store_sk": "ctr_store_sk"}) + + # Correlated per-store average: average over the same store only. + store_avg = customer_total.groupby("ctr_store_sk", as_index=False, dropna=False).agg( + store_avg=("ctr_total_return", "mean") + ) + + # Main result: above-average customers at stores in the target state. + result = customer_total.merge(store_avg, on="ctr_store_sk") + result = result[result["ctr_total_return"] > result["store_avg"] * 1.2] + result = result.merge(store[store["s_state"] == state], left_on="ctr_store_sk", right_on="s_store_sk") + result = result.merge(customer, left_on="ctr_customer_sk", right_on="c_customer_sk") + return result[["c_customer_id"]].sort_values("c_customer_id").head(100) + + +def q6_expression_impl(ctx: DataFrameContext) -> Any: + """TPC-DS Q6: Customer State/Item Analysis (Expression Family). + + Counts qualifying sales by customer state for items priced above 120% of + their category average. + + Tables: customer_address, customer, store_sales, date_dim, item + Pattern: category aggregate -> join -> filter -> group by -> having + """ + params = get_parameters(6) + year = params.get("year", 2001) month = params.get("month", 1) customer_address, customer, store_sales, date_dim, item = _tables( @@ -1653,26 +2661,26 @@ def q6_expression_impl(ctx: DataFrameContext) -> Any: col = ctx.col lit = ctx.lit - # Compute average item price as scalar first (aggregates not allowed in WHERE) - avg_price = item.select(col("i_current_price").mean().alias("avg_price")).scalar(0, 0) - price_threshold = 1.2 * avg_price + category_avg = item.group_by("i_category").agg(col("i_current_price").mean().alias("category_avg_price")) + item_with_avg = item.join(category_avg, on="i_category") - return ( + return _sort_null_largest_expression( + ctx, customer_address.join(customer, left_on="ca_address_sk", right_on="c_current_addr_sk") .join(store_sales, left_on="c_customer_sk", right_on="ss_customer_sk") .join(date_dim, left_on="ss_sold_date_sk", right_on="d_date_sk") - .join(item, left_on="ss_item_sk", right_on="i_item_sk") + .join(item_with_avg, left_on="ss_item_sk", right_on="i_item_sk") .filter( (col("d_year") == lit(year)) & (col("d_moy") == lit(month)) - & (col("i_current_price") > lit(price_threshold)) + & (col("i_current_price") > lit(1.2) * col("category_avg_price")) ) - .group_by("i_item_id", "ca_state") + .group_by("ca_state") .agg(col("ss_sold_date_sk").count().alias("cnt")) .filter(col("cnt") >= lit(10)) - .sort("cnt", "i_item_id") - .limit(100) - ) + .select(col("ca_state").alias("state"), col("cnt")), + ["cnt", "state"], + ).limit(100) def q6_pandas_impl(ctx: DataFrameContext) -> Any: @@ -1685,22 +2693,31 @@ def q6_pandas_impl(ctx: DataFrameContext) -> Any: ctx, "customer_address", "customer", "store_sales", "date_dim", "item" ) + category_avg = ( + item.groupby("i_category", as_index=False)["i_current_price"] + .mean() + .rename(columns={"i_current_price": "category_avg_price"}) + ) + item_with_avg = item.merge(category_avg, on="i_category") + # Build joins merged = customer_address.merge(customer, left_on="ca_address_sk", right_on="c_current_addr_sk") merged = merged.merge(store_sales, left_on="c_customer_sk", right_on="ss_customer_sk") merged = merged.merge(date_dim, left_on="ss_sold_date_sk", right_on="d_date_sk") - merged = merged.merge(item, left_on="ss_item_sk", right_on="i_item_sk") + merged = merged.merge(item_with_avg, left_on="ss_item_sk", right_on="i_item_sk") # Filter by date and price threshold - avg_price = item["i_current_price"].mean() filtered = merged[ - (merged["d_year"] == year) & (merged["d_moy"] == month) & (merged["i_current_price"] > 1.2 * avg_price) + (merged["d_year"] == year) + & (merged["d_moy"] == month) + & (merged["i_current_price"] > 1.2 * merged["category_avg_price"]) ] # Group and apply having clause - grouped = filtered.groupby(["i_item_id", "ca_state"], as_index=False).agg(cnt=("ss_sold_date_sk", "count")) + grouped = filtered.groupby("ca_state", as_index=False, dropna=False).agg(cnt=("ss_sold_date_sk", "count")) - return grouped[grouped["cnt"] >= 10].sort_values(["cnt", "i_item_id"]).head(100) + result = grouped[grouped["cnt"] >= 10].rename(columns={"ca_state": "state"}).sort_values(["cnt", "state"]).head(100) + return _none_for_null(result, list(result.columns)) # ============================================================================= @@ -1725,9 +2742,9 @@ def q72_expression_impl(ctx: DataFrameContext) -> Any: to ~17.5M rows. """ params = get_parameters(72) - year = params.get("year", 1999) - buy_potential = params.get("buy_potential", ">10000") - marital_status = params.get("marital_status", "D") + year = params.get("year", 2001) + buy_potential = params.get("buy_potential", "1001-5000") + marital_status = params.get("marital_status", "M") catalog_sales, inventory, date_dim, item, warehouse, customer_demographics, household_demographics, promotion = ( _tables( @@ -1769,7 +2786,7 @@ def q72_expression_impl(ctx: DataFrameContext) -> Any: ) # Step 3: Join on BOTH item_sk AND week_seq to prevent cartesian explosion - return ( + grouped = ( cs_with_d1.join( inv_with_d2, left_on=["cs_item_sk", "cs_week_seq"], @@ -1794,7 +2811,7 @@ def q72_expression_impl(ctx: DataFrameContext) -> Any: # Apply remaining filter conditions .filter( (col("inv_quantity_on_hand") < col("cs_quantity")) - & (col("d3_date") > col("d1_date")) # Ship date after sold date + & (col("d3_date") > ctx.date_add(col("d1_date"), 5)) # Ship date after sold date + 5 days & (col("hd_buy_potential") == lit(buy_potential)) & (col("cd_marital_status") == lit(marital_status)) ) @@ -1805,9 +2822,10 @@ def q72_expression_impl(ctx: DataFrameContext) -> Any: ctx.when(col("p_promo_id").is_not_null()).then(1).otherwise(0).sum().alias("promo"), ctx.len().alias("total_cnt"), ) - .sort(["total_cnt", "i_item_desc", "w_warehouse_name", "cs_week_seq"], descending=[True, False, False, False]) - .limit(100) ) + return _sort_null_largest_expression( + ctx, grouped, ["total_cnt", "i_item_desc", "w_warehouse_name", "cs_week_seq"], [True, False, False, False] + ).limit(100) def q72_pandas_impl(ctx: DataFrameContext) -> Any: @@ -1816,10 +2834,12 @@ def q72_pandas_impl(ctx: DataFrameContext) -> Any: See q72_expression_impl for detailed documentation. CRITICAL: Must join on (item_sk, week_seq) to prevent cartesian explosion. """ + import pandas as pd + params = get_parameters(72) - year = params.get("year", 1999) - buy_potential = params.get("buy_potential", ">10000") - marital_status = params.get("marital_status", "D") + year = params.get("year", 2001) + buy_potential = params.get("buy_potential", "1001-5000") + marital_status = params.get("marital_status", "M") catalog_sales, inventory, date_dim, item, warehouse, customer_demographics, household_demographics, promotion = ( _tables( @@ -1864,10 +2884,14 @@ def q72_pandas_impl(ctx: DataFrameContext) -> Any: merged = merged.merge(household_demographics, left_on="cs_bill_hdemo_sk", right_on="hd_demo_sk") merged = merged.merge(promotion, left_on="cs_promo_sk", right_on="p_promo_sk", how="left") - # Filter + # Filter (ship date after sold date + 5 days). The date_dim date columns + # may be Arrow date32, which cannot mix with Timedelta arithmetic: + # normalize both sides to python dates first. + d1 = pd.to_datetime(merged["d1_date"]).dt.date + d3 = pd.to_datetime(merged["d3_date"]).dt.date filtered = merged[ (merged["inv_quantity_on_hand"] < merged["cs_quantity"]) - & (merged["d3_date"] > merged["d1_date"]) + & (d3 > d1 + pd.to_timedelta(5, unit="D")) & (merged["hd_buy_potential"] == buy_potential) & (merged["cd_marital_status"] == marital_status) ] @@ -1878,14 +2902,13 @@ def q72_pandas_impl(ctx: DataFrameContext) -> Any: filtered["promo"] = filtered["p_promo_id"].notna().astype(int) # Group and aggregate - return ( - filtered.groupby(["i_item_desc", "w_warehouse_name", "cs_week_seq"], as_index=False) - .agg(no_promo=("no_promo", "sum"), promo=("promo", "sum"), total_cnt=("i_item_desc", "count")) - .sort_values( - ["total_cnt", "i_item_desc", "w_warehouse_name", "cs_week_seq"], ascending=[False, True, True, True] - ) - .head(100) + grouped = filtered.groupby(["i_item_desc", "w_warehouse_name", "cs_week_seq"], as_index=False, dropna=False).agg( + no_promo=("no_promo", "sum"), promo=("promo", "sum"), total_cnt=("i_item_desc", "size") ) + result = _sort_null_largest_pandas( + grouped, ["total_cnt", "i_item_desc", "w_warehouse_name", "cs_week_seq"], [True, False, False, False] + ).head(100) + return _none_for_null(result, list(result.columns)) # ============================================================================= @@ -1903,7 +2926,7 @@ def q62_expression_impl(ctx: DataFrameContext) -> Any: Pattern: Multi-join -> filter by month_seq -> CASE WHEN aggregation -> group by -> order by """ params = get_parameters(62) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) web_sales, warehouse, ship_mode, web_site, date_dim = _tables( ctx, "web_sales", "warehouse", "ship_mode", "web_site", "date_dim" @@ -1945,7 +2968,7 @@ def q62_expression_impl(ctx: DataFrameContext) -> Any: .alias("91_120_days"), ctx.when(col("delivery_days") > 120).then(1).otherwise(0).sum().alias("gt_120_days"), ) - .sort("warehouse_name", "sm_type", "web_name") + .sort("warehouse_name", "sm_type", "web_name", nulls_last=True) .limit(100) ) @@ -1953,7 +2976,7 @@ def q62_expression_impl(ctx: DataFrameContext) -> Any: def q62_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q62: Web Sales Delivery Analysis (Pandas Family).""" params = get_parameters(62) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) web_sales, warehouse, ship_mode, web_site, date_dim = _tables( ctx, "web_sales", "warehouse", "ship_mode", "web_site", "date_dim" @@ -1981,8 +3004,8 @@ def q62_pandas_impl(ctx: DataFrameContext) -> Any: filtered["gt_120_days"] = (filtered["delivery_days"] > 120).astype(int) # Group and aggregate - return ( - filtered.groupby(["warehouse_name", "sm_type", "web_name"], as_index=False) + result = ( + filtered.groupby(["warehouse_name", "sm_type", "web_name"], as_index=False, dropna=False) .agg( { "30_days": "sum", @@ -1992,9 +3015,10 @@ def q62_pandas_impl(ctx: DataFrameContext) -> Any: "gt_120_days": "sum", } ) - .sort_values(["warehouse_name", "sm_type", "web_name"]) + .sort_values(["warehouse_name", "sm_type", "web_name"], na_position="last") .head(100) ) + return _none_for_null(result, ["warehouse_name", "sm_type", "web_name"]) def q99_expression_impl(ctx: DataFrameContext) -> Any: @@ -2007,7 +3031,7 @@ def q99_expression_impl(ctx: DataFrameContext) -> Any: Pattern: Multi-join -> filter by month_seq -> CASE WHEN aggregation -> group by -> order by """ params = get_parameters(99) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) catalog_sales, warehouse, ship_mode, call_center, date_dim = _tables( ctx, "catalog_sales", "warehouse", "ship_mode", "call_center", "date_dim" @@ -2049,7 +3073,7 @@ def q99_expression_impl(ctx: DataFrameContext) -> Any: .alias("91_120_days"), ctx.when(col("delivery_days") > 120).then(1).otherwise(0).sum().alias("gt_120_days"), ) - .sort("warehouse_name", "sm_type", "cc_name") + .sort("warehouse_name", "sm_type", "cc_name", nulls_last=True) .limit(100) ) @@ -2057,7 +3081,7 @@ def q99_expression_impl(ctx: DataFrameContext) -> Any: def q99_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q99: Catalog Sales Delivery Analysis (Pandas Family).""" params = get_parameters(99) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) catalog_sales, warehouse, ship_mode, call_center, date_dim = _tables( ctx, "catalog_sales", "warehouse", "ship_mode", "call_center", "date_dim" @@ -2085,8 +3109,8 @@ def q99_pandas_impl(ctx: DataFrameContext) -> Any: filtered["gt_120_days"] = (filtered["delivery_days"] > 120).astype(int) # Group and aggregate - return ( - filtered.groupby(["warehouse_name", "sm_type", "cc_name"], as_index=False) + result = ( + filtered.groupby(["warehouse_name", "sm_type", "cc_name"], as_index=False, dropna=False) .agg( { "30_days": "sum", @@ -2096,9 +3120,10 @@ def q99_pandas_impl(ctx: DataFrameContext) -> Any: "gt_120_days": "sum", } ) - .sort_values(["warehouse_name", "sm_type", "cc_name"]) + .sort_values(["warehouse_name", "sm_type", "cc_name"], na_position="last") .head(100) ) + return _none_for_null(result, ["warehouse_name", "sm_type", "cc_name"]) def q13_expression_impl(ctx: DataFrameContext) -> Any: @@ -2112,6 +3137,15 @@ def q13_expression_impl(ctx: DataFrameContext) -> Any: """ params = get_parameters(13) year = params.get("year", 2001) + demo1_marital = params.get("demo1_marital", "D") + demo1_education = params.get("demo1_education", "2 yr Degree") + demo2_marital = params.get("demo2_marital", "S") + demo2_education = params.get("demo2_education", "Secondary") + demo3_marital = params.get("demo3_marital", "W") + demo3_education = params.get("demo3_education", "Advanced Degree") + states1 = params.get("states1", ["CO", "IL", "MN"]) + states2 = params.get("states2", ["OH", "MT", "NM"]) + states3 = params.get("states3", ["TX", "MO", "MI"]) store_sales, store, customer_demographics, household_demographics, customer_address, date_dim = _tables( ctx, "store_sales", "store", "customer_demographics", "household_demographics", "customer_address", "date_dim" @@ -2120,69 +3154,87 @@ def q13_expression_impl(ctx: DataFrameContext) -> Any: lit = ctx.lit # Join tables - return ( + joined = ( store_sales.join(store, left_on="ss_store_sk", right_on="s_store_sk") .join(customer_demographics, left_on="ss_cdemo_sk", right_on="cd_demo_sk") .join(household_demographics, left_on="ss_hdemo_sk", right_on="hd_demo_sk") .join(customer_address, left_on="ss_addr_sk", right_on="ca_address_sk") .join(date_dim, left_on="ss_sold_date_sk", right_on="d_date_sk") - .filter( - (col("d_year") == lit(year)) - & ( - # Demographics condition 1 - ( - (col("cd_marital_status") == lit("M")) - & (col("cd_education_status") == lit("Advanced Degree")) - & col("ss_sales_price").is_between(100.0, 150.0) - & (col("hd_dep_count") == lit(3)) - ) - | - # Demographics condition 2 - ( - (col("cd_marital_status") == lit("S")) - & (col("cd_education_status") == lit("College")) - & col("ss_sales_price").is_between(50.0, 100.0) - & (col("hd_dep_count") == lit(1)) - ) - | - # Demographics condition 3 - ( - (col("cd_marital_status") == lit("W")) - & (col("cd_education_status") == lit("2 yr Degree")) - & col("ss_sales_price").is_between(150.0, 200.0) - & (col("hd_dep_count") == lit(1)) - ) + ) + predicate = (col("d_year") == lit(year)) & ( + ( + # Demographics condition 1 + ( + (col("cd_marital_status") == lit(demo1_marital)) + & (col("cd_education_status") == lit(demo1_education)) + & col("ss_sales_price").is_between(100.0, 150.0) + & (col("hd_dep_count") == lit(3)) ) - & ( - # Address condition 1 - ( - (col("ca_country") == lit("United States")) - & col("ca_state").is_in(["TX", "OH", "TX"]) - & col("ss_net_profit").is_between(100, 200) - ) - | - # Address condition 2 - ( - (col("ca_country") == lit("United States")) - & col("ca_state").is_in(["OR", "NM", "KY"]) - & col("ss_net_profit").is_between(150, 300) - ) - | - # Address condition 3 - ( - (col("ca_country") == lit("United States")) - & col("ca_state").is_in(["VA", "TX", "MS"]) - & col("ss_net_profit").is_between(50, 250) - ) + | + # Demographics condition 2 + ( + (col("cd_marital_status") == lit(demo2_marital)) + & (col("cd_education_status") == lit(demo2_education)) + & col("ss_sales_price").is_between(50.0, 100.0) + & (col("hd_dep_count") == lit(1)) + ) + | + # Demographics condition 3 + ( + (col("cd_marital_status") == lit(demo3_marital)) + & (col("cd_education_status") == lit(demo3_education)) + & col("ss_sales_price").is_between(150.0, 200.0) + & (col("hd_dep_count") == lit(1)) ) ) - .select( - col("ss_quantity").mean().alias("avg_ss_quantity"), - col("ss_ext_sales_price").mean().alias("avg_ss_ext_sales_price"), - col("ss_ext_wholesale_cost").mean().alias("avg_ss_ext_wholesale_cost"), - col("ss_ext_wholesale_cost").sum().alias("sum_ss_ext_wholesale_cost"), + & ( + # Address condition 1 + ( + (col("ca_country") == lit("United States")) + & col("ca_state").is_in(states1) + & col("ss_net_profit").is_between(100, 200) + ) + | + # Address condition 2 + ( + (col("ca_country") == lit("United States")) + & col("ca_state").is_in(states2) + & col("ss_net_profit").is_between(150, 300) + ) + | + # Address condition 3 + ( + (col("ca_country") == lit("United States")) + & col("ca_state").is_in(states3) + & col("ss_net_profit").is_between(50, 250) + ) ) ) + filtered = joined.filter(predicate) + # SQL aggregates over an empty set yield NULL (not 0.0/NaN): guard each + # output with the non-null input count. + tallied = filtered.select( + col("ss_quantity").mean().alias("avg_ss_quantity"), + col("ss_ext_sales_price").mean().alias("avg_ss_ext_sales_price"), + col("ss_ext_wholesale_cost").mean().alias("avg_ss_ext_wholesale_cost"), + col("ss_ext_wholesale_cost").sum().alias("sum_ss_ext_wholesale_cost"), + col("ss_quantity").count().alias("n"), + ) + return tallied.select( + ctx.when(col("n") > lit(0)).then(col("avg_ss_quantity")).otherwise(lit(None)).alias("avg_ss_quantity"), + ctx.when(col("n") > lit(0)) + .then(col("avg_ss_ext_sales_price")) + .otherwise(lit(None)) + .alias("avg_ss_ext_sales_price"), + ctx.when(col("n") > lit(0)) + .then(col("avg_ss_ext_wholesale_cost")) + .otherwise(lit(None)) + .alias("avg_ss_ext_wholesale_cost"), + ctx.when(col("n") > lit(0)) + .then(col("sum_ss_ext_wholesale_cost")) + .otherwise(lit(None)) + .alias("sum_ss_ext_wholesale_cost"), + ) def q13_pandas_impl(ctx: DataFrameContext) -> Any: @@ -2191,6 +3243,15 @@ def q13_pandas_impl(ctx: DataFrameContext) -> Any: params = get_parameters(13) year = params.get("year", 2001) + demo1_marital = params.get("demo1_marital", "D") + demo1_education = params.get("demo1_education", "2 yr Degree") + demo2_marital = params.get("demo2_marital", "S") + demo2_education = params.get("demo2_education", "Secondary") + demo3_marital = params.get("demo3_marital", "W") + demo3_education = params.get("demo3_education", "Advanced Degree") + states1 = params.get("states1", ["CO", "IL", "MN"]) + states2 = params.get("states2", ["OH", "MT", "NM"]) + states3 = params.get("states3", ["TX", "MO", "MI"]) store_sales, store, customer_demographics, household_demographics, customer_address, date_dim = _tables( ctx, "store_sales", "store", "customer_demographics", "household_demographics", "customer_address", "date_dim" @@ -2205,22 +3266,22 @@ def q13_pandas_impl(ctx: DataFrameContext) -> Any: # Demographics conditions demo_cond1 = ( - (merged["cd_marital_status"] == "M") - & (merged["cd_education_status"] == "Advanced Degree") + (merged["cd_marital_status"] == demo1_marital) + & (merged["cd_education_status"] == demo1_education) & (merged["ss_sales_price"] >= 100.0) & (merged["ss_sales_price"] <= 150.0) & (merged["hd_dep_count"] == 3) ) demo_cond2 = ( - (merged["cd_marital_status"] == "S") - & (merged["cd_education_status"] == "College") + (merged["cd_marital_status"] == demo2_marital) + & (merged["cd_education_status"] == demo2_education) & (merged["ss_sales_price"] >= 50.0) & (merged["ss_sales_price"] <= 100.0) & (merged["hd_dep_count"] == 1) ) demo_cond3 = ( - (merged["cd_marital_status"] == "W") - & (merged["cd_education_status"] == "2 yr Degree") + (merged["cd_marital_status"] == demo3_marital) + & (merged["cd_education_status"] == demo3_education) & (merged["ss_sales_price"] >= 150.0) & (merged["ss_sales_price"] <= 200.0) & (merged["hd_dep_count"] == 1) @@ -2229,19 +3290,19 @@ def q13_pandas_impl(ctx: DataFrameContext) -> Any: # Address conditions addr_cond1 = ( (merged["ca_country"] == "United States") - & merged["ca_state"].isin(["TX", "OH", "TX"]) + & merged["ca_state"].isin(states1) & (merged["ss_net_profit"] >= 100) & (merged["ss_net_profit"] <= 200) ) addr_cond2 = ( (merged["ca_country"] == "United States") - & merged["ca_state"].isin(["OR", "NM", "KY"]) + & merged["ca_state"].isin(states2) & (merged["ss_net_profit"] >= 150) & (merged["ss_net_profit"] <= 300) ) addr_cond3 = ( (merged["ca_country"] == "United States") - & merged["ca_state"].isin(["VA", "TX", "MS"]) + & merged["ca_state"].isin(states3) & (merged["ss_net_profit"] >= 50) & (merged["ss_net_profit"] <= 250) ) @@ -2251,6 +3312,16 @@ def q13_pandas_impl(ctx: DataFrameContext) -> Any: (merged["d_year"] == year) & (demo_cond1 | demo_cond2 | demo_cond3) & (addr_cond1 | addr_cond2 | addr_cond3) ] + # SQL aggregates over an empty set yield NULL (not NaN/0.0). + if len(filtered) == 0: + return pd.DataFrame( + { + "avg_ss_quantity": [None], + "avg_ss_ext_sales_price": [None], + "avg_ss_ext_wholesale_cost": [None], + "sum_ss_ext_wholesale_cost": [None], + } + ) # Aggregate (single row result) return pd.DataFrame( { @@ -2272,7 +3343,16 @@ def q48_expression_impl(ctx: DataFrameContext) -> Any: Pattern: Multi-join -> complex OR filter -> aggregate (no GROUP BY) """ params = get_parameters(48) - year = params.get("year", 2000) + year = params.get("year", 1998) + demo1_marital = params.get("demo1_marital", "M") + demo1_education = params.get("demo1_education", "4 yr Degree") + demo2_marital = params.get("demo2_marital", "D") + demo2_education = params.get("demo2_education", "Primary") + demo3_marital = params.get("demo3_marital", "U") + demo3_education = params.get("demo3_education", "Advanced Degree") + states1 = params.get("states1", ["KY", "GA", "NM"]) + states2 = params.get("states2", ["MT", "OR", "IN"]) + states3 = params.get("states3", ["WI", "MO", "WV"]) store_sales, store, customer_demographics, customer_address, date_dim = _tables( ctx, "store_sales", "store", "customer_demographics", "customer_address", "date_dim" @@ -2291,22 +3371,22 @@ def q48_expression_impl(ctx: DataFrameContext) -> Any: & ( # Demographics condition 1 ( - (col("cd_marital_status") == lit("M")) - & (col("cd_education_status") == lit("Advanced Degree")) + (col("cd_marital_status") == lit(demo1_marital)) + & (col("cd_education_status") == lit(demo1_education)) & col("ss_sales_price").is_between(100.0, 150.0) ) | # Demographics condition 2 ( - (col("cd_marital_status") == lit("S")) - & (col("cd_education_status") == lit("College")) + (col("cd_marital_status") == lit(demo2_marital)) + & (col("cd_education_status") == lit(demo2_education)) & col("ss_sales_price").is_between(50.0, 100.0) ) | # Demographics condition 3 ( - (col("cd_marital_status") == lit("W")) - & (col("cd_education_status") == lit("2 yr Degree")) + (col("cd_marital_status") == lit(demo3_marital)) + & (col("cd_education_status") == lit(demo3_education)) & col("ss_sales_price").is_between(150.0, 200.0) ) ) @@ -2314,21 +3394,21 @@ def q48_expression_impl(ctx: DataFrameContext) -> Any: # Address condition 1 ( (col("ca_country") == lit("United States")) - & col("ca_state").is_in(["TX", "OH", "TX"]) + & col("ca_state").is_in(states1) & col("ss_net_profit").is_between(0, 2000) ) | # Address condition 2 ( (col("ca_country") == lit("United States")) - & col("ca_state").is_in(["OR", "NM", "KY"]) + & col("ca_state").is_in(states2) & col("ss_net_profit").is_between(150, 3000) ) | # Address condition 3 ( (col("ca_country") == lit("United States")) - & col("ca_state").is_in(["VA", "TX", "MS"]) + & col("ca_state").is_in(states3) & col("ss_net_profit").is_between(50, 25000) ) ) @@ -2342,7 +3422,16 @@ def q48_pandas_impl(ctx: DataFrameContext) -> Any: import pandas as pd params = get_parameters(48) - year = params.get("year", 2000) + year = params.get("year", 1998) + demo1_marital = params.get("demo1_marital", "M") + demo1_education = params.get("demo1_education", "4 yr Degree") + demo2_marital = params.get("demo2_marital", "D") + demo2_education = params.get("demo2_education", "Primary") + demo3_marital = params.get("demo3_marital", "U") + demo3_education = params.get("demo3_education", "Advanced Degree") + states1 = params.get("states1", ["KY", "GA", "NM"]) + states2 = params.get("states2", ["MT", "OR", "IN"]) + states3 = params.get("states3", ["WI", "MO", "WV"]) store_sales, store, customer_demographics, customer_address, date_dim = _tables( ctx, "store_sales", "store", "customer_demographics", "customer_address", "date_dim" @@ -2356,20 +3445,20 @@ def q48_pandas_impl(ctx: DataFrameContext) -> Any: # Demographics conditions demo_cond1 = ( - (merged["cd_marital_status"] == "M") - & (merged["cd_education_status"] == "Advanced Degree") + (merged["cd_marital_status"] == demo1_marital) + & (merged["cd_education_status"] == demo1_education) & (merged["ss_sales_price"] >= 100.0) & (merged["ss_sales_price"] <= 150.0) ) demo_cond2 = ( - (merged["cd_marital_status"] == "S") - & (merged["cd_education_status"] == "College") + (merged["cd_marital_status"] == demo2_marital) + & (merged["cd_education_status"] == demo2_education) & (merged["ss_sales_price"] >= 50.0) & (merged["ss_sales_price"] <= 100.0) ) demo_cond3 = ( - (merged["cd_marital_status"] == "W") - & (merged["cd_education_status"] == "2 yr Degree") + (merged["cd_marital_status"] == demo3_marital) + & (merged["cd_education_status"] == demo3_education) & (merged["ss_sales_price"] >= 150.0) & (merged["ss_sales_price"] <= 200.0) ) @@ -2377,19 +3466,19 @@ def q48_pandas_impl(ctx: DataFrameContext) -> Any: # Address conditions addr_cond1 = ( (merged["ca_country"] == "United States") - & merged["ca_state"].isin(["TX", "OH", "TX"]) + & merged["ca_state"].isin(states1) & (merged["ss_net_profit"] >= 0) & (merged["ss_net_profit"] <= 2000) ) addr_cond2 = ( (merged["ca_country"] == "United States") - & merged["ca_state"].isin(["OR", "NM", "KY"]) + & merged["ca_state"].isin(states2) & (merged["ss_net_profit"] >= 150) & (merged["ss_net_profit"] <= 3000) ) addr_cond3 = ( (merged["ca_country"] == "United States") - & merged["ca_state"].isin(["VA", "TX", "MS"]) + & merged["ca_state"].isin(states3) & (merged["ss_net_profit"] >= 50) & (merged["ss_net_profit"] <= 25000) ) @@ -2427,8 +3516,8 @@ def q34_expression_impl(ctx: DataFrameContext) -> Any: "Williamson County", ], ) - buy_potential_1 = params.get("buy_potential_1", "1001-5000") - buy_potential_2 = params.get("buy_potential_2", "0-500") + buy_potential_1 = params.get("buy_potential_1", ">10000") + buy_potential_2 = params.get("buy_potential_2", "Unknown") store_sales, date_dim, store, household_demographics, customer = _tables( ctx, "store_sales", "date_dim", "store", "household_demographics", "customer" @@ -2458,20 +3547,19 @@ def q34_expression_impl(ctx: DataFrameContext) -> Any: ) # Join with customer - return ( - ticket_agg.join(customer, left_on="ss_customer_sk", right_on="c_customer_sk") - .select( - col("c_last_name"), - col("c_first_name"), - col("c_salutation"), - col("c_preferred_cust_flag"), - col("ss_ticket_number"), - col("cnt"), - ) - .sort( - ["c_last_name", "c_first_name", "c_salutation", "c_preferred_cust_flag", "ss_ticket_number"], - descending=[False, False, False, True, False], - ) + result = ticket_agg.join(customer, left_on="ss_customer_sk", right_on="c_customer_sk").select( + col("c_last_name"), + col("c_first_name"), + col("c_salutation"), + col("c_preferred_cust_flag"), + col("ss_ticket_number"), + col("cnt"), + ) + return _sort_null_largest_expression( + ctx, + result, + ["c_last_name", "c_first_name", "c_salutation", "c_preferred_cust_flag", "ss_ticket_number"], + [False, False, False, True, False], ) @@ -2492,8 +3580,8 @@ def q34_pandas_impl(ctx: DataFrameContext) -> Any: "Williamson County", ], ) - buy_potential_1 = params.get("buy_potential_1", "1001-5000") - buy_potential_2 = params.get("buy_potential_2", "0-500") + buy_potential_1 = params.get("buy_potential_1", ">10000") + buy_potential_2 = params.get("buy_potential_2", "Unknown") store_sales, date_dim, store, household_demographics, customer = _tables( ctx, "store_sales", "date_dim", "store", "household_demographics", "customer" @@ -2521,16 +3609,23 @@ def q34_pandas_impl(ctx: DataFrameContext) -> Any: # Join with customer result = ticket_filtered.merge(customer, left_on="ss_customer_sk", right_on="c_customer_sk") + for column in ("c_last_name", "c_first_name", "c_salutation", "c_preferred_cust_flag"): + result[column] = result[column].astype(object).where(~result[column].isna(), None) # Select and sort - return result[ - ["c_last_name", "c_first_name", "c_salutation", "c_preferred_cust_flag", "ss_ticket_number", "cnt"] - ].sort_values( + result = result[["c_last_name", "c_first_name", "c_salutation", "c_preferred_cust_flag", "ss_ticket_number", "cnt"]] + return _sort_null_largest_pandas( + result, ["c_last_name", "c_first_name", "c_salutation", "c_preferred_cust_flag", "ss_ticket_number"], - ascending=[True, True, True, False, True], + [False, False, False, True, False], ) +# The template fixes these lists; only the year, quarter and group-by column are drawn. +_Q45_ZIP_CODES = ["85669", "86197", "88274", "83405", "86475", "85392", "85460", "80348", "81792"] +_Q45_ITEM_SKS = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] + + def q45_expression_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q45: Web Sales Customer Zip Analysis (Expression Family). @@ -2541,10 +3636,11 @@ def q45_expression_impl(ctx: DataFrameContext) -> Any: Pattern: Multi-join -> OR filter (zip OR item_id) -> group by -> aggregate """ params = get_parameters(45) - year = params.get("year", 2001) + year = params.get("year", 2000) qoy = params.get("qoy", 2) - zip_codes = params.get("zip_codes", ["85669", "86197", "88274", "83405", "86475"]) - item_sks = params.get("item_sks", [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]) + group_column = params.get("gbobc", "ca_county") + zip_codes = _Q45_ZIP_CODES + item_sks = _Q45_ITEM_SKS web_sales, customer, customer_address, date_dim, item = _tables( ctx, "web_sales", "customer", "customer_address", "date_dim", "item" @@ -2558,7 +3654,7 @@ def q45_expression_impl(ctx: DataFrameContext) -> Any: ) # Join tables and filter - return ( + grouped = ( web_sales.join(customer, left_on="ws_bill_customer_sk", right_on="c_customer_sk") .join(customer_address, left_on="c_current_addr_sk", right_on="ca_address_sk") .join(item, left_on="ws_item_sk", right_on="i_item_sk") @@ -2568,20 +3664,21 @@ def q45_expression_impl(ctx: DataFrameContext) -> Any: & (col("d_year") == lit(year)) & (col("ca_zip").cast_string().str.slice(0, 5).is_in(zip_codes) | col("i_item_id").is_in(item_ids_list)) ) - .group_by(col("ca_zip"), col("ca_city")) + .group_by(col("ca_zip"), col(group_column)) .agg(col("ws_sales_price").sum().alias("sum_ws_sales_price")) - .sort("ca_zip", "ca_city") - .limit(100) ) + # The grouping columns can be NULL (ca_city, ca_state, ca_zip); NULL sorts last in the SQL. + return _sort_null_largest_expression(ctx, grouped, ["ca_zip", group_column], [False, False]).limit(100) def q45_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q45: Web Sales Customer Zip Analysis (Pandas Family).""" params = get_parameters(45) - year = params.get("year", 2001) + year = params.get("year", 2000) qoy = params.get("qoy", 2) - zip_codes = params.get("zip_codes", ["85669", "86197", "88274", "83405", "86475"]) - item_sks = params.get("item_sks", [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]) + group_column = params.get("gbobc", "ca_county") + zip_codes = _Q45_ZIP_CODES + item_sks = _Q45_ITEM_SKS web_sales, customer, customer_address, date_dim, item = _tables( ctx, "web_sales", "customer", "customer_address", "date_dim", "item" @@ -2603,23 +3700,23 @@ def q45_pandas_impl(ctx: DataFrameContext) -> Any: & (merged["ca_zip"].str[:5].isin(zip_codes) | merged["i_item_id"].isin(item_ids)) ] - # Group and aggregate - return ( - filtered.groupby(["ca_zip", "ca_city"], as_index=False) - .agg(sum_ws_sales_price=("ws_sales_price", "sum")) - .sort_values(["ca_zip", "ca_city"]) - .head(100) + # Group and aggregate. SQL GROUP BY keeps a NULL key as its own group, which pandas drops by default. + grouped = filtered.groupby(["ca_zip", group_column], as_index=False, dropna=False).agg( + sum_ws_sales_price=("ws_sales_price", "sum") ) + ordered = _sort_null_largest_pandas(grouped, ["ca_zip", group_column], [False, False]).head(100) + return _none_for_null(ordered, ["ca_zip", group_column]) def _q90_params() -> tuple[int, int, int, int, int]: params = get_parameters(90) + # The template fixes the web page character-count range; only the hours and dependent count are drawn. return ( params.get("hour_am", 8), params.get("hour_pm", 19), - params.get("dep_count", 6), - params.get("char_count_min", 5000), - params.get("char_count_max", 5200), + params.get("dep_count", 8), + 5000, + 5200, ) @@ -2666,7 +3763,7 @@ def q83_expression_impl(ctx: DataFrameContext) -> Any: for items returned during specific date ranges. """ params = get_parameters(83) - return_dates = params.get("return_dates", ["2000-06-30", "2000-09-27", "2000-11-17"]) + return_dates = params.get("dates", ["1998-01-02", "1998-10-15", "1998-11-10"]) col = ctx.col lit = ctx.lit @@ -2742,7 +3839,7 @@ def q83_expression_impl(ctx: DataFrameContext) -> Any: def q83_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q83: Cross-Channel Returns Analysis (Pandas Family).""" params = get_parameters(83) - return_dates = params.get("return_dates", ["2000-06-30", "2000-09-27", "2000-11-17"]) + return_dates = params.get("dates", ["1998-01-02", "1998-10-15", "1998-11-10"]) # Get tables store_returns, catalog_returns, web_returns, item, date_dim = _tables( @@ -2792,59 +3889,56 @@ def q83_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= -def q41_expression_impl(ctx: DataFrameContext) -> Any: - """TPC-DS Q41: Item Dimension Analysis (Expression Family). +_Q41_GROUPS = ( + ("Women", 0, 0, 0), + ("Women", 2, 2, 2), + ("Men", 4, 4, 4), + ("Men", 6, 6, 0), + ("Women", 8, 8, 0), + ("Women", 10, 10, 2), + ("Men", 12, 12, 4), + ("Men", 14, 14, 0), +) - Finds distinct product names from items matching manufacturer and attribute criteria. - This query only uses the item dimension table (no fact tables). - """ + +def _q41_parameters(params: Any) -> tuple[int, list[str], list[str], list[str]]: + manufact = params.get("manufact", 742) + colors = list(params.get("colors", [])) + units = list(params.get("units", [])) + sizes = list(params.get("sizes", [])) + if len(colors) != 16 or len(units) != 16 or len(sizes) != 6: + raise ValueError(f"Q41 needs 16 colors, 16 units and 6 sizes, got {len(colors)}, {len(units)} and {len(sizes)}") + return manufact, colors, units, sizes + + +def q41_expression_impl(ctx: DataFrameContext) -> Any: params = get_parameters(41) - manufact_start = params.get("manufact_start", 738) + manufact, colors, units, sizes = _q41_parameters(params) col = ctx.col lit = ctx.lit item = ctx.get_table("item") - # Filter i1: items within the manufact_id range - i1 = item.filter((col("i_manufact_id") >= lit(manufact_start)) & (col("i_manufact_id") <= lit(manufact_start + 40))) - - # Build the complex filter for correlated subquery - # The subquery counts items with matching i_manufact and specific attribute combinations - # We implement this as a self-join pattern - - # Define the 8 OR conditions (simplified version - actual TPC-DS has parameterized colors/units/sizes) - # For the DataFrame implementation, we use representative default values + i1 = item.filter((col("i_manufact_id") >= lit(manufact)) & (col("i_manufact_id") <= lit(manufact + 40))) - # Get all item data for the correlation - all_items = item.select( - col("i_manufact"), - col("i_category"), - col("i_color"), - col("i_units"), - col("i_size"), - ) - - # Build filter conditions matching the SQL pattern - # Using OR of 8 conditions grouped into two sets of 4 - condition = ( - # Group 1: Women + various color/unit/size combos - ((col("i_category") == lit("Women")) & col("i_color").is_in(["powder", "khaki"])) - | ((col("i_category") == lit("Women")) & col("i_color").is_in(["brown", "honeydew"])) - # Group 2: Men + various color/unit/size combos - | ((col("i_category") == lit("Men")) & col("i_color").is_in(["floral", "deep"])) - | ((col("i_category") == lit("Men")) & col("i_color").is_in(["light", "cornflower"])) - ) + condition = None + for category, color, unit, size in _Q41_GROUPS: + group = ( + (col("i_category") == lit(category)) + & col("i_color").is_in(colors[color : color + 2]) + & col("i_units").is_in(units[unit : unit + 2]) + & col("i_size").is_in(sizes[size : size + 2]) + ) + condition = group if condition is None else condition | group - # Get manufacturers that have matching items - matching_manufacts = all_items.filter(condition).select(col("i_manufact")).unique() + matching_manufacts = item.filter(condition & col("i_manufact").is_not_null()).select("i_manufact").unique() - # Join back to i1 to find items whose manufacturer has matching items return ( i1.join(matching_manufacts, on="i_manufact") .select(col("i_product_name")) .unique() - .sort("i_product_name") + .sort("i_product_name", nulls_last=True) .head(100) ) @@ -2852,30 +3946,24 @@ def q41_expression_impl(ctx: DataFrameContext) -> Any: def q41_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q41: Item Dimension Analysis (Pandas Family).""" params = get_parameters(41) - manufact_start = params.get("manufact_start", 738) + manufact, colors, units, sizes = _q41_parameters(params) item = ctx.get_table("item") - # Filter i1: items within the manufact_id range - i1 = item[(item["i_manufact_id"] >= manufact_start) & (item["i_manufact_id"] <= manufact_start + 40)] + i1 = item[(item["i_manufact_id"] >= manufact) & (item["i_manufact_id"] <= manufact + 40)] - # Build the complex filter for correlated subquery - # The subquery counts items with matching i_manufact and specific attribute combinations - - # Build filter conditions matching the SQL pattern - condition = ( - # Group 1: Women + various color combos - ((item["i_category"] == "Women") & item["i_color"].isin(["powder", "khaki"])) - | ((item["i_category"] == "Women") & item["i_color"].isin(["brown", "honeydew"])) - # Group 2: Men + various color combos - | ((item["i_category"] == "Men") & item["i_color"].isin(["floral", "deep"])) - | ((item["i_category"] == "Men") & item["i_color"].isin(["light", "cornflower"])) - ) + condition = None + for category, color, unit, size in _Q41_GROUPS: + group = ( + (item["i_category"] == category) + & item["i_color"].isin(colors[color : color + 2]) + & item["i_units"].isin(units[unit : unit + 2]) + & item["i_size"].isin(sizes[size : size + 2]) + ) + condition = group if condition is None else condition | group - # Get manufacturers that have matching items - matching_manufacts = _to_list(item[condition]["i_manufact"].unique()) + matching_manufacts = _to_list(item[condition & item["i_manufact"].notna()]["i_manufact"].unique()) - # Filter i1 to items whose manufacturer has matching items result = i1[i1["i_manufact"].isin(matching_manufacts)][["i_product_name"]].drop_duplicates() return result.sort_values("i_product_name").head(100) @@ -2885,6 +3973,18 @@ def q41_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +def _sql_rank_expression(ctx: DataFrameContext, column: str, partition_by: list[str], *, descending: bool) -> Any: + value = ctx.col(column) + rank = value.rank(method="min", descending=descending).over(partition_by) + if ctx.platform != "Polars": + return rank + if descending: + null_count = value.is_null().sum().over(partition_by) + return ctx.when(value.is_null()).then(1).otherwise(rank + null_count) + nonnull_count = value.count().over(partition_by) + return ctx.when(value.is_null()).then(nonnull_count + 1).otherwise(rank) + + def q86_expression_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q86: Web Sales ROLLUP with Rank (Expression Family). @@ -2897,7 +3997,7 @@ def q86_expression_impl(ctx: DataFrameContext) -> Any: ) params = get_parameters(86) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) col = ctx.col lit = ctx.lit @@ -2921,6 +4021,7 @@ def q86_expression_impl(ctx: DataFrameContext) -> Any: group_cols=["i_category", "i_class"], agg_exprs=agg_exprs, ctx=ctx, + count_sources={"total_sum": "ws_net_paid"}, ) # Add lochierarchy = GROUPING(i_category) + GROUPING(i_class) @@ -2939,36 +4040,20 @@ def q86_expression_impl(ctx: DataFrameContext) -> Any: # Compute rank within partition result = result.with_columns( - col("total_sum") - .rank(method="min", descending=True) - .over(["lochierarchy", "partition_key"]) - .alias("rank_within_parent") - ) - - # Select and sort - return ( - result.select( - col("total_sum"), - col("i_category"), - col("i_class"), - col("lochierarchy"), - col("rank_within_parent"), - ) - .sort( - [col("lochierarchy"), col("i_category"), col("rank_within_parent")], - descending=[True, False, False], - nulls_last=True, + _sql_rank_expression(ctx, "total_sum", ["lochierarchy", "partition_key"], descending=True).alias( + "rank_within_parent" ) - .head(100) ) + return _sort_rollup_hierarchy_expression(ctx, result, "total_sum") + def q86_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q86: Web Sales ROLLUP with Rank (Pandas Family).""" from benchbox.core.tpcds.dataframe_queries.rollup_helper import expand_rollup_pandas params = get_parameters(86) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) # Get tables web_sales, date_dim, item = _tables(ctx, "web_sales", "date_dim", "item") @@ -2985,6 +4070,7 @@ def q86_pandas_impl(ctx: DataFrameContext) -> Any: group_cols=["i_category", "i_class"], agg_dict=agg_dict, ctx=ctx, + count_sources={"total_sum": "ws_net_paid"}, ) # Add lochierarchy @@ -2998,15 +4084,44 @@ def q86_pandas_impl(ctx: DataFrameContext) -> Any: # Compute rank within partition rollup_result["rank_within_parent"] = rollup_result.groupby(["lochierarchy", "partition_key"], dropna=False)[ "total_sum" - ].rank(method="min", ascending=False) + ].rank(method="min", ascending=False, na_option="top") - # Select and sort - result = rollup_result[["total_sum", "i_category", "i_class", "lochierarchy", "rank_within_parent"]] - return result.sort_values( - ["lochierarchy", "i_category", "rank_within_parent"], - ascending=[False, True, True], - na_position="last", - ).head(100) + return _sort_rollup_hierarchy_pandas(rollup_result, "total_sum") + + +def _sort_rollup_hierarchy_expression(ctx: DataFrameContext, result: Any, measure: str) -> Any: + """Order Q36/Q86 rows as SQL does: by CASE WHEN lochierarchy = 0 THEN i_category END. + + Subtotal and grand-total rows have a NULL category key, so they follow their rank + rather than their category name. + """ + col = ctx.col + ordered = result.with_columns( + ctx.when(col("lochierarchy") == 0).then(col("i_category")).otherwise(ctx.lit(None)).alias("order_category") + ) + return ( + _sort_null_largest_expression( + ctx, ordered, ["lochierarchy", "order_category", "rank_within_parent"], [True, False, False] + ) + .select(col(measure), col("i_category"), col("i_class"), col("lochierarchy"), col("rank_within_parent")) + .head(100) + ) + + +def _sort_rollup_hierarchy_pandas(rollup_result: Any, measure: str) -> Any: + """Pandas form of :func:`_sort_rollup_hierarchy_expression`. + + One stable sentinel stands in for the NULL key of subtotal rows, as in Q70, so + ``na_position`` cannot move those rows across hierarchy levels. + """ + columns = [measure, "i_category", "i_class", "lochierarchy", "rank_within_parent"] + result = rollup_result[columns].assign( + order_category=rollup_result["i_category"].where(rollup_result["lochierarchy"] == 0, "") + ) + ordered = _sort_null_largest_pandas( + result, ["lochierarchy", "order_category", "rank_within_parent"], [True, False, False] + ) + return ordered.drop(columns=["order_category"]).head(100) # ============================================================================= @@ -3026,8 +4141,8 @@ def q36_expression_impl(ctx: DataFrameContext) -> Any: ) params = get_parameters(36) - year = params.get("year", 2001) - states = params.get("states", ["TN", "SD", "AL", "NC", "OK", "MS", "WI", "IN"]) + year = params.get("year", 2000) + states = params.get("states", ["TN"]) col = ctx.col lit = ctx.lit @@ -3055,10 +4170,26 @@ def q36_expression_impl(ctx: DataFrameContext) -> Any: group_cols=["i_category", "i_class"], agg_exprs=agg_exprs, ctx=ctx, + count_sources={"sum_profit": "ss_net_profit", "sum_sales": "ss_ext_sales_price"}, ) # Compute gross_margin - result = rollup_result.with_columns((col("sum_profit") / col("sum_sales")).alias("gross_margin")) + profit, sales = col("sum_profit"), col("sum_sales") + zero_sales_margin = ( + ctx.when(profit == lit(0)) + .then(lit(float("nan"))) + .when(profit > lit(0)) + .then(lit(float("inf"))) + .otherwise(lit(float("-inf"))) + ) + margin = ( + ctx.when(profit.is_null() | sales.is_null()) + .then(lit(None)) + .when(sales == lit(0)) + .then(zero_sales_margin) + .otherwise(profit / sales) + ) + result = rollup_result.with_columns(margin.alias("gross_margin")) # Add lochierarchy lochierarchy_expr = lochierarchy_expression("grouping_id", 2, ctx=ctx) @@ -3071,37 +4202,21 @@ def q36_expression_impl(ctx: DataFrameContext) -> Any: # Compute rank within partition (ASC for gross_margin) result = result.with_columns( - col("gross_margin") - .rank(method="min", descending=False) - .over(["lochierarchy", "partition_key"]) - .alias("rank_within_parent") - ) - - # Select and sort - return ( - result.select( - col("gross_margin"), - col("i_category"), - col("i_class"), - col("lochierarchy"), - col("rank_within_parent"), + _sql_rank_expression(ctx, "gross_margin", ["lochierarchy", "partition_key"], descending=False).alias( + "rank_within_parent" ) - .sort( - [col("lochierarchy"), col("i_category"), col("rank_within_parent")], - descending=[True, False, False], - nulls_last=True, - ) - .head(100) ) + return _sort_rollup_hierarchy_expression(ctx, result, "gross_margin") + def q36_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q36: Gross Margin ROLLUP with Rank (Pandas Family).""" from benchbox.core.tpcds.dataframe_queries.rollup_helper import expand_rollup_pandas params = get_parameters(36) - year = params.get("year", 2001) - states = params.get("states", ["TN", "SD", "AL", "NC", "OK", "MS", "WI", "IN"]) + year = params.get("year", 2000) + states = params.get("states", ["TN"]) # Get tables store_sales, date_dim, item, store = _tables(ctx, "store_sales", "date_dim", "item", "store") @@ -3122,10 +4237,14 @@ def q36_pandas_impl(ctx: DataFrameContext) -> Any: group_cols=["i_category", "i_class"], agg_dict=agg_dict, ctx=ctx, + count_sources={"sum_profit": "ss_net_profit", "sum_sales": "ss_ext_sales_price"}, ) # Compute gross_margin - rollup_result["gross_margin"] = rollup_result["sum_profit"] / rollup_result["sum_sales"] + margin = rollup_result["sum_profit"].astype(float) / rollup_result["sum_sales"].astype(float) + valid_sums = rollup_result["sum_profit"].notna() & rollup_result["sum_sales"].notna() + rollup_result["gross_margin"] = margin.astype(object).where(valid_sums, None) + rollup_result["nan_margin"] = margin.isna() & valid_sums # Add lochierarchy rollup_result["lochierarchy"] = rollup_result["grouping_id"].apply(lambda x: bin(x).count("1")) @@ -3138,15 +4257,15 @@ def q36_pandas_impl(ctx: DataFrameContext) -> Any: # Compute rank within partition rollup_result["rank_within_parent"] = rollup_result.groupby(["lochierarchy", "partition_key"], dropna=False)[ "gross_margin" - ].rank(method="min", ascending=True) + ].rank(method="min", ascending=True, na_option="bottom") + null_offset = ( + rollup_result.groupby(["lochierarchy", "partition_key"], dropna=False)["nan_margin"] + .transform("sum") + .where(~valid_sums, 0) + ) + rollup_result["rank_within_parent"] += null_offset - # Select and sort - result = rollup_result[["gross_margin", "i_category", "i_class", "lochierarchy", "rank_within_parent"]] - return result.sort_values( - ["lochierarchy", "i_category", "rank_within_parent"], - ascending=[False, True, True], - na_position="last", - ).head(100) + return _sort_rollup_hierarchy_pandas(rollup_result, "gross_margin") # ============================================================================= @@ -3154,6 +4273,38 @@ def q36_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +def _q51_running_total(ctx: DataFrameContext, item_key: str) -> Any: + """SQL's running SUM over the day sums of one item: a day whose sum is NULL repeats the total so far. + + The total stays NULL until the item has had a day with a value, which is what the SQL window gives. + """ + col, lit = ctx.col, ctx.lit + seen = ctx.when(col("daily_sales").is_not_null()).then(1).otherwise(0).cum_sum().over([item_key], order_by="d_date") + total = col("daily_sales").fill_null(0).cum_sum().over([item_key], order_by="d_date") + return ctx.when(seen > lit(0)).then(total).otherwise(lit(None)) + + +def _q51_merge_channels(ctx: DataFrameContext, web: Any, store: Any) -> Any: + col, lit = ctx.col, ctx.lit + + def channel(frame: Any, name: str, null_date_channel: int) -> Any: + other_name = "store_sales" if name == "web_sales" else "web_sales" + return frame.select( + col("item_sk").alias("item_sk_final"), + col("d_date").alias("d_date_final"), + col("cume_sales").alias(name), + ctx.when(lit(False)).then(col("cume_sales")).otherwise(lit(None)).alias(other_name), + ctx.when(col("d_date").is_null()).then(null_date_channel).otherwise(0).alias("null_date_channel"), + ).select("item_sk_final", "d_date_final", "web_sales", "store_sales", "null_date_channel") + + return ( + ctx.concat([channel(web, "web_sales", 1), channel(store, "store_sales", 2)]) + .group_by(["item_sk_final", "d_date_final", "null_date_channel"]) + .agg(col("web_sales").max().alias("web_sales"), col("store_sales").max().alias("store_sales")) + .drop("null_date_channel") + ) + + def q51_expression_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q51: Cumulative Web/Store Sales (Expression Family). @@ -3161,9 +4312,10 @@ def q51_expression_impl(ctx: DataFrameContext) -> Any: finds where web cumulative exceeds store cumulative. """ params = get_parameters(51) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) col = ctx.col + lit = ctx.lit # Get tables web_sales, store_sales, date_dim = _tables(ctx, "web_sales", "store_sales", "date_dim") @@ -3176,16 +4328,24 @@ def q51_expression_impl(ctx: DataFrameContext) -> Any: web_sales.filter(col("ws_item_sk").is_not_null()) .join(dates, left_on="ws_sold_date_sk", right_on="d_date_sk") .group_by(["ws_item_sk", "d_date"]) - .agg(col("ws_sales_price").sum().alias("daily_sales")) + .agg( + col("ws_sales_price").count().alias("daily_count"), + col("ws_sales_price").sum().alias("daily_sales"), + ) + .with_columns( + ctx.when(col("daily_count") > lit(0)).then(col("daily_sales")).otherwise(lit(None)).alias("daily_sales") + ) ) # Add cumulative sum - web_v1 = web_base.with_columns( - col("daily_sales").cum_sum().over(["ws_item_sk"], order_by="d_date").alias("cume_sales") - ).select( - col("ws_item_sk").alias("item_sk"), - col("d_date"), - col("cume_sales"), + web_v1 = ( + web_base.sort(["ws_item_sk", "d_date"]) + .with_columns(_q51_running_total(ctx, "ws_item_sk").alias("cume_sales")) + .select( + col("ws_item_sk").alias("item_sk"), + col("d_date"), + col("cume_sales"), + ) ) # Store CTE: cumulative sum per item/date @@ -3193,44 +4353,40 @@ def q51_expression_impl(ctx: DataFrameContext) -> Any: store_sales.filter(col("ss_item_sk").is_not_null()) .join(dates, left_on="ss_sold_date_sk", right_on="d_date_sk") .group_by(["ss_item_sk", "d_date"]) - .agg(col("ss_sales_price").sum().alias("daily_sales")) + .agg( + col("ss_sales_price").count().alias("daily_count"), + col("ss_sales_price").sum().alias("daily_sales"), + ) + .with_columns( + ctx.when(col("daily_count") > lit(0)).then(col("daily_sales")).otherwise(lit(None)).alias("daily_sales") + ) ) - store_v1 = store_base.with_columns( - col("daily_sales").cum_sum().over(["ss_item_sk"], order_by="d_date").alias("cume_sales") - ).select( - col("ss_item_sk").alias("item_sk"), - col("d_date"), - col("cume_sales"), + store_v1 = ( + store_base.sort(["ss_item_sk", "d_date"]) + .with_columns(_q51_running_total(ctx, "ss_item_sk").alias("cume_sales")) + .select( + col("ss_item_sk").alias("item_sk"), + col("d_date"), + col("cume_sales"), + ) ) - # Full outer join - coalesce item_sk and d_date - joined = web_v1.join( - store_v1, - on=["item_sk", "d_date"], - how="full", - suffix="_store", - ) + joined = _q51_merge_channels(ctx, web_v1, store_v1).sort(["item_sk_final", "d_date_final"]) - # Coalesce columns and rename - joined = joined.with_columns( - ctx.coalesce(col("item_sk"), col("item_sk_store")).alias("item_sk_final"), - ctx.coalesce(col("d_date"), col("d_date_store")).alias("d_date_final"), - col("cume_sales").alias("web_sales"), - col("cume_sales_store").alias("store_sales"), + # SQL MAX windows ignore NULLs while retaining the prior maximum. Use a + # sentinel during the window and restore NULL when a group has no value yet. + web_cumulative = ( + col("web_sales").fill_null(float("-inf")).cum_max().over(["item_sk_final"], order_by="d_date_final") + ) + store_cumulative = ( + col("store_sales").fill_null(float("-inf")).cum_max().over(["item_sk_final"], order_by="d_date_final") ) - - # Compute cumulative max for both result = joined.with_columns( - col("web_sales") - .fill_null(0) - .cum_max() - .over(["item_sk_final"], order_by="d_date_final") - .alias("web_cumulative"), - col("store_sales") - .fill_null(0) - .cum_max() - .over(["item_sk_final"], order_by="d_date_final") + ctx.when(web_cumulative == lit(float("-inf"))).then(None).otherwise(web_cumulative).alias("web_cumulative"), + ctx.when(store_cumulative == lit(float("-inf"))) + .then(None) + .otherwise(store_cumulative) .alias("store_cumulative"), ) @@ -3250,10 +4406,15 @@ def q51_expression_impl(ctx: DataFrameContext) -> Any: ) +def _q51_running_total_pandas(base: Any, item_key: str) -> Any: + """SQL's running SUM over the day sums of one item (see ``_q51_running_total``).""" + return base.groupby(item_key)["daily_sales"].cumsum().groupby(base[item_key], sort=False).ffill() + + def q51_pandas_impl(ctx: DataFrameContext) -> Any: """TPC-DS Q51: Cumulative Web/Store Sales (Pandas Family).""" params = get_parameters(51) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) # Get tables web_sales, store_sales, date_dim = _tables(ctx, "web_sales", "store_sales", "date_dim") @@ -3265,9 +4426,11 @@ def q51_pandas_impl(ctx: DataFrameContext) -> Any: web_merged = web_sales[web_sales["ws_item_sk"].notna()].merge( dates[["d_date_sk", "d_date"]], left_on="ws_sold_date_sk", right_on="d_date_sk" ) - web_base = web_merged.groupby(["ws_item_sk", "d_date"], as_index=False).agg(daily_sales=("ws_sales_price", "sum")) + web_base = web_merged.groupby(["ws_item_sk", "d_date"], as_index=False).agg( + daily_sales=("ws_sales_price", lambda values: values.sum(min_count=1)) + ) web_base = web_base.sort_values(["ws_item_sk", "d_date"]) - web_base["cume_sales"] = web_base.groupby("ws_item_sk")["daily_sales"].cumsum() + web_base["cume_sales"] = _q51_running_total_pandas(web_base, "ws_item_sk") web_v1 = web_base[["ws_item_sk", "d_date", "cume_sales"]].rename(columns={"ws_item_sk": "item_sk"}) # Store CTE @@ -3275,26 +4438,30 @@ def q51_pandas_impl(ctx: DataFrameContext) -> Any: dates[["d_date_sk", "d_date"]], left_on="ss_sold_date_sk", right_on="d_date_sk" ) store_base = store_merged.groupby(["ss_item_sk", "d_date"], as_index=False).agg( - daily_sales=("ss_sales_price", "sum") + daily_sales=("ss_sales_price", lambda values: values.sum(min_count=1)) ) store_base = store_base.sort_values(["ss_item_sk", "d_date"]) - store_base["cume_sales"] = store_base.groupby("ss_item_sk")["daily_sales"].cumsum() + store_base["cume_sales"] = _q51_running_total_pandas(store_base, "ss_item_sk") store_v1 = store_base[["ss_item_sk", "d_date", "cume_sales"]].rename(columns={"ss_item_sk": "item_sk"}) # Full outer join joined = web_v1.merge(store_v1, on=["item_sk", "d_date"], how="outer", suffixes=("_web", "_store")) - joined["web_sales"] = joined["cume_sales_web"].fillna(0) - joined["store_sales"] = joined["cume_sales_store"].fillna(0) + joined["web_sales"] = joined["cume_sales_web"] + joined["store_sales"] = joined["cume_sales_store"] # Compute cumulative max joined = joined.sort_values(["item_sk", "d_date"]) - joined["web_cumulative"] = joined.groupby("item_sk")["web_sales"].cummax() - joined["store_cumulative"] = joined.groupby("item_sk")["store_sales"].cummax() + joined["web_cumulative"] = ( + joined.groupby("item_sk")["web_sales"].cummax().groupby(joined["item_sk"], sort=False).ffill() + ) + joined["store_cumulative"] = ( + joined.groupby("item_sk")["store_sales"].cummax().groupby(joined["item_sk"], sort=False).ffill() + ) # Filter where web > store result = joined[joined["web_cumulative"] > joined["store_cumulative"]] result = result[["item_sk", "d_date", "web_sales", "store_sales", "web_cumulative", "store_cumulative"]] - return result.sort_values(["item_sk", "d_date"]).head(100) + return result.sort_values(["item_sk", "d_date"]).head(100).astype(object).where(lambda frame: frame.notna(), None) # ============================================================================= @@ -3302,6 +4469,33 @@ def q51_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +_ROLLING_MEASURES = ("avg_monthly_sales", "sum_sales", "psum", "nsum") +_ROLLING_PERIOD_COLUMNS = ("d_year", "d_moy") + + +def _rolling_select_list(text: str, allowed: tuple[str, ...], what: str) -> list[str]: + columns = [part.strip().removeprefix("v1.") for part in text.split(",") if part.strip()] + unknown = [column for column in columns if column not in allowed] + if unknown or not columns: + raise ValueError(f"{what} must name columns among {list(allowed)}, got {text!r}") + return columns + + +def _rolling_average_parameters( + params: Any, partition_keys: list[str], select_one_default: str +) -> tuple[int, list[str], str]: + year = params.get("year", 2000) + select_one = _rolling_select_list(params.get("select_one", select_one_default), tuple(partition_keys), "SELECTONE") + select_two = _rolling_select_list( + params.get("select_two", ",v1.d_year, v1.d_moy"), _ROLLING_PERIOD_COLUMNS, "SELECTTWO" + ) + order_by = params.get("order_by", "nsum") + order_columns = [*partition_keys, *_ROLLING_MEASURES] + if order_by not in order_columns: + raise ValueError(f"ORDERBY must be one of {order_columns}, got {order_by!r}") + return year, [*select_one, *select_two, *_ROLLING_MEASURES], order_by + + def _rolling_average_expression_impl( ctx: DataFrameContext, *, @@ -3314,9 +4508,12 @@ def _rolling_average_expression_impl( channel_join_key_left: str, channel_join_key_right: str, channel_cols: list[str], + select_one_default: str, ) -> Any: - params = get_parameters(query_id) - year = params.get("year", 1999) + partition_keys = ["i_category", "i_brand", *channel_cols] + year, output_cols, order_by = _rolling_average_parameters( + get_parameters(query_id), partition_keys, select_one_default + ) col = ctx.col lit = ctx.lit sales = ctx.get_table(sales_table) @@ -3327,13 +4524,12 @@ def _rolling_average_expression_impl( | ((col("d_year") == lit(year - 1)) & (col("d_moy") == 12)) | ((col("d_year") == lit(year + 1)) & (col("d_moy") == 1)) ) - partition_keys = ["i_category", "i_brand", *channel_cols] base = ( sales.join(dates, left_on=date_sk_col, right_on="d_date_sk") .join(item, left_on=item_sk_col, right_on="i_item_sk") .join(channel, left_on=channel_join_key_left, right_on=channel_join_key_right) .group_by([*partition_keys, "d_year", "d_moy"]) - .agg(col(sales_price_col).sum().alias("sum_sales")) + .agg(_sum_or_null_expression(ctx, col(sales_price_col)).alias("sum_sales")) ) v1 = base.with_columns( col("sum_sales").mean().over([*partition_keys, "d_year"]).alias("avg_monthly_sales"), @@ -3353,19 +4549,23 @@ def _rolling_average_expression_impl( ) ) return ( - result.select( - [col(key) for key in partition_keys] - + [ - col("d_year"), - col("d_moy"), - col("avg_monthly_sales"), - col("sum_sales"), - col("psum"), - col("nsum"), - ] + _sort_null_largest_expression( + ctx, + result.select( + [col(key) for key in partition_keys] + + [ + col("d_year"), + col("d_moy"), + col("avg_monthly_sales"), + col("sum_sales"), + col("psum"), + col("nsum"), + ] + ).with_columns((col("sum_sales") - col("avg_monthly_sales")).alias("diff")), + # SQL orders by the deviation then the lead-month sales. + ["diff", order_by], ) - .with_columns((col("sum_sales") - col("avg_monthly_sales")).alias("diff")) - .sort(["diff", "avg_monthly_sales"]) + .select([col(name) for name in output_cols]) .head(100) ) @@ -3383,6 +4583,7 @@ def q47_expression_impl(ctx: DataFrameContext) -> Any: channel_join_key_left="ss_store_sk", channel_join_key_right="s_store_sk", channel_cols=["s_store_name", "s_company_name"], + select_one_default="v1.i_category, v1.i_brand", ) @@ -3398,14 +4599,17 @@ def _rolling_average_pandas_impl( channel_join_key_left: str, channel_join_key_right: str, channel_cols: list[str], + select_one_default: str, ) -> Any: """Shared rolling average implementation for Q47/Q57 (Pandas Family). The TPC-DS spec mirrors Q47 (store channel) and Q57 (catalog channel) with the same algorithm but different dimension tables and column names. """ - params = get_parameters(query_id) - year = params.get("year", 1999) + partition_keys = ["i_category", "i_brand", *channel_cols] + year, output_cols, order_by = _rolling_average_parameters( + get_parameters(query_id), partition_keys, select_one_default + ) # Get tables sales = ctx.get_table(sales_table) @@ -3425,10 +4629,9 @@ def _rolling_average_pandas_impl( base = sales.merge(dates, left_on=date_sk_col, right_on="d_date_sk") base = base.merge(item, left_on=item_sk_col, right_on="i_item_sk") base = base.merge(channel, left_on=channel_join_key_left, right_on=channel_join_key_right) - base = base.groupby(group_keys, as_index=False).agg(sum_sales=(sales_price_col, "sum")) + base = _grouped_pandas_aggregates(base, group_keys, {"sum_sales": (sales_price_col, "sum")}) # Add year_month for ordering - partition_keys = ["i_category", "i_brand", *channel_cols] base["year_month"] = base["d_year"] * 100 + base["d_moy"] base = base.sort_values([*partition_keys, "year_month"]) @@ -3468,11 +4671,10 @@ def _rolling_average_pandas_impl( & (abs(result["sum_sales"] - result["avg_monthly_sales"]) / result["avg_monthly_sales"] > 0.1) ] - # Select and sort result["diff"] = result["sum_sales"] - result["avg_monthly_sales"] - output_cols = [*partition_keys, "d_year", "d_moy", "avg_monthly_sales", "sum_sales", "psum", "nsum"] + result = result.sort_values(["diff", order_by]).head(100) result = result[output_cols] - return result.sort_values(["diff", "avg_monthly_sales"]).head(100) + return _none_for_null(result, list(result.columns)) def q47_pandas_impl(ctx: DataFrameContext) -> Any: @@ -3488,6 +4690,7 @@ def q47_pandas_impl(ctx: DataFrameContext) -> Any: channel_join_key_left="ss_store_sk", channel_join_key_right="s_store_sk", channel_cols=["s_store_name", "s_company_name"], + select_one_default="v1.i_category, v1.i_brand", ) @@ -3509,6 +4712,7 @@ def q57_expression_impl(ctx: DataFrameContext) -> Any: channel_join_key_left="cs_call_center_sk", channel_join_key_right="cc_call_center_sk", channel_cols=["cc_name"], + select_one_default="v1.cc_name", ) @@ -3525,6 +4729,7 @@ def q57_pandas_impl(ctx: DataFrameContext) -> Any: channel_join_key_left="cs_call_center_sk", channel_join_key_right="cc_call_center_sk", channel_cols=["cc_name"], + select_one_default="v1.cc_name", ) @@ -3541,7 +4746,7 @@ def q67_expression_impl(ctx: DataFrameContext) -> Any: from benchbox.core.tpcds.dataframe_queries.rollup_helper import expand_rollup_expression params = get_parameters(67) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) col = ctx.col lit = ctx.lit @@ -3555,7 +4760,11 @@ def q67_expression_impl(ctx: DataFrameContext) -> Any: .join(item, left_on="ss_item_sk", right_on="i_item_sk") .join(store, left_on="ss_store_sk", right_on="s_store_sk") .filter(col("d_month_seq").is_between(dms, dms + 11)) - .with_columns((ctx.coalesce(col("ss_sales_price") * col("ss_quantity"), lit(0))).alias("sales_amount")) + .with_columns( + ctx.coalesce((col("ss_sales_price") * lit(100)).round(0).cast_int64() * col("ss_quantity"), lit(0)).alias( + "sales_amount_cents" + ) + ) ) # 8-column ROLLUP @@ -3570,14 +4779,16 @@ def q67_expression_impl(ctx: DataFrameContext) -> Any: "s_store_id", ] - agg_exprs = [col("sales_amount").sum().alias("sumsales")] + agg_exprs = [col("sales_amount_cents").sum().alias("sumsales_cents")] - rollup_result = expand_rollup_expression(base, group_cols, agg_exprs, ctx) + rollup_result = expand_rollup_expression( + base, group_cols, agg_exprs, ctx, count_sources={"sumsales_cents": "sales_amount_cents"} + ) # Add rank within i_category result = rollup_result.with_columns( - col("sumsales").rank(method="min", descending=True).over(["i_category"]).alias("rk") - ) + _sql_rank_expression(ctx, "sumsales_cents", ["i_category"], descending=True).alias("rk") + ).with_columns((col("sumsales_cents").cast_float64() / lit(100)).alias("sumsales")) # Filter rk <= 100 return ( @@ -3606,7 +4817,8 @@ def q67_expression_impl(ctx: DataFrameContext) -> Any: "s_store_id", "sumsales", "rk", - ] + ], + nulls_last=True, ) .head(100) ) @@ -3617,7 +4829,7 @@ def q67_pandas_impl(ctx: DataFrameContext) -> Any: from benchbox.core.tpcds.dataframe_queries.rollup_helper import expand_rollup_pandas params = get_parameters(67) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) # Get tables store_sales, date_dim, item, store = _tables(ctx, "store_sales", "date_dim", "item", "store") @@ -3627,7 +4839,9 @@ def q67_pandas_impl(ctx: DataFrameContext) -> Any: base = base.merge(item, left_on="ss_item_sk", right_on="i_item_sk") base = base.merge(store, left_on="ss_store_sk", right_on="s_store_sk") base = base[(base["d_month_seq"] >= dms) & (base["d_month_seq"] <= dms + 11)] - base["sales_amount"] = (base["ss_sales_price"] * base["ss_quantity"]).fillna(0) + base["sales_amount_cents"] = ( + (base["ss_sales_price"] * 100).round().astype("Int64") * base["ss_quantity"].astype("Int64") + ).fillna(0) # 8-column ROLLUP group_cols = [ @@ -3640,14 +4854,18 @@ def q67_pandas_impl(ctx: DataFrameContext) -> Any: "d_moy", "s_store_id", ] - agg_dict = {"sumsales": ("sales_amount", "sum")} - rollup_result = expand_rollup_pandas(base, group_cols, agg_dict, ctx) + agg_dict = {"sumsales_cents": ("sales_amount_cents", "sum")} + rollup_result = expand_rollup_pandas( + base, group_cols, agg_dict, ctx, count_sources={"sumsales_cents": "sales_amount_cents"} + ) # Add rank within i_category - rollup_result["rk"] = rollup_result.groupby("i_category", dropna=False)["sumsales"].rank( - method="min", ascending=False + rollup_result["rk"] = rollup_result.groupby("i_category", dropna=False)["sumsales_cents"].rank( + method="min", ascending=False, na_option="top" ) + rollup_result["sumsales"] = rollup_result["sumsales_cents"].astype("Float64") / 100 + # Filter rk <= 100 result = rollup_result[rollup_result["rk"] <= 100] result = result[ @@ -3686,17 +4904,13 @@ def q67_pandas_impl(ctx: DataFrameContext) -> Any: def q70_expression_impl(ctx: DataFrameContext) -> Any: - """TPC-DS Q70: Store Sales ROLLUP with Subquery Filter (Expression Family). - - ROLLUP on (s_state, s_county) with subquery filter for top 5 states. - """ from benchbox.core.tpcds.dataframe_queries.rollup_helper import ( expand_rollup_expression, lochierarchy_expression, ) params = get_parameters(70) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) col = ctx.col lit = ctx.lit @@ -3707,27 +4921,21 @@ def q70_expression_impl(ctx: DataFrameContext) -> Any: # Date filter dates = date_dim.filter(col("d_month_seq").is_between(dms, dms + 11)) - # First, find top 5 states by profit (subquery) - state_profit = ( - store_sales.join(dates, left_on="ss_sold_date_sk", right_on="d_date_sk") - .join(store, left_on="ss_store_sk", right_on="s_store_sk") - .group_by("s_state") - .agg(col("ss_net_profit").sum().alias("state_profit")) - .with_columns(col("state_profit").rank(method="ordinal", descending=True).alias("ranking")) - .filter(col("ranking") <= 5) - .select(col("s_state")) - ) - - # Main query base - filter by top 5 states base = ( store_sales.join(dates, left_on="ss_sold_date_sk", right_on="d_date_sk") .join(store, left_on="ss_store_sk", right_on="s_store_sk") - .join(state_profit, on="s_state") # Semi-join filter + .filter(col("s_state").is_not_null()) ) # ROLLUP on (s_state, s_county) agg_exprs = [col("ss_net_profit").sum().alias("total_sum")] - rollup_result = expand_rollup_expression(base, ["s_state", "s_county"], agg_exprs, ctx) + rollup_result = expand_rollup_expression( + base, ["s_state", "s_county"], agg_exprs, ctx, count_sources={"total_sum": "ss_net_profit"} + ) + # ss_net_profit is a two-decimal DECIMAL in the SQL surface. Round the + # float-backed aggregate back to that source scale so subtotal and + # grand-total rows keep the same equality and sort position as the SQL. + rollup_result = rollup_result.with_columns(col("total_sum").round(2).alias("total_sum")) # Add lochierarchy lochierarchy_expr = lochierarchy_expression("grouping_id", 2, ctx=ctx) @@ -3740,26 +4948,26 @@ def q70_expression_impl(ctx: DataFrameContext) -> Any: # Compute rank within partition (DESC for total_sum) result = result.with_columns( - col("total_sum") - .rank(method="min", descending=True) - .over(["lochierarchy", "partition_key"]) - .alias("rank_within_parent") + _sql_rank_expression(ctx, "total_sum", ["lochierarchy", "partition_key"], descending=True).alias( + "rank_within_parent" + ) + ) + + result = result.with_columns( + ctx.when(col("lochierarchy") == 0).then(col("s_state")).otherwise(lit(None)).alias("order_state") ) - # Select and sort return ( - result.select( + _sort_null_largest_expression( + ctx, result, ["lochierarchy", "order_state", "rank_within_parent"], [True, False, False] + ) + .select( col("total_sum"), col("s_state"), col("s_county"), col("lochierarchy"), col("rank_within_parent"), ) - .sort( - [col("lochierarchy"), col("s_state"), col("rank_within_parent")], - descending=[True, False, False], - nulls_last=True, - ) .head(100) ) @@ -3769,7 +4977,7 @@ def q70_pandas_impl(ctx: DataFrameContext) -> Any: from benchbox.core.tpcds.dataframe_queries.rollup_helper import expand_rollup_pandas params = get_parameters(70) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) # Get tables store_sales, date_dim, store = _tables(ctx, "store_sales", "date_dim", "store") @@ -3777,19 +4985,21 @@ def q70_pandas_impl(ctx: DataFrameContext) -> Any: # Date filter dates = date_dim[(date_dim["d_month_seq"] >= dms) & (date_dim["d_month_seq"] <= dms + 11)] - # First, find top 5 states by profit (subquery) merged = store_sales.merge(dates[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") merged = merged.merge(store[["s_store_sk", "s_state", "s_county"]], left_on="ss_store_sk", right_on="s_store_sk") - state_profit = merged.groupby("s_state", as_index=False).agg(state_profit=("ss_net_profit", "sum")) - state_profit["ranking"] = state_profit["state_profit"].rank(method="first", ascending=False) - top_states = state_profit[state_profit["ranking"] <= 5]["s_state"].tolist() - # Main query - filter by top 5 states - base = merged[merged["s_state"].isin(top_states)] + base = merged[merged["s_state"].notna()] # ROLLUP on (s_state, s_county) agg_dict = {"total_sum": ("ss_net_profit", "sum")} - rollup_result = expand_rollup_pandas(base, ["s_state", "s_county"], agg_dict, ctx) + rollup_result = expand_rollup_pandas( + base, ["s_state", "s_county"], agg_dict, ctx, count_sources={"total_sum": "ss_net_profit"} + ) + # ss_net_profit is a two-decimal DECIMAL in the SQL surface. Round the + # float-backed pandas aggregate back to that source scale so subtotal and + # grand-total rows retain the same equality and sort position as DuckDB. + totals = rollup_result["total_sum"] + rollup_result["total_sum"] = totals.astype(float).round(2).astype(object).where(totals.notna(), None) # Add lochierarchy rollup_result["lochierarchy"] = rollup_result["grouping_id"].apply(lambda x: bin(x).count("1")) @@ -3802,15 +5012,20 @@ def q70_pandas_impl(ctx: DataFrameContext) -> Any: # Compute rank within partition rollup_result["rank_within_parent"] = rollup_result.groupby(["lochierarchy", "partition_key"], dropna=False)[ "total_sum" - ].rank(method="min", ascending=False) + ].rank(method="min", ascending=False, na_option="top") + + # SQL orders by CASE WHEN lochierarchy = 0 THEN s_state END, not by the + # projected state value for subtotal and grand-total rows. Use one stable + # sentinel for SQL NULL order keys so pandas does not move NULL keys across + # hierarchy groups while applying ``na_position``. + rollup_result["order_state"] = rollup_result["s_state"].where(rollup_result["lochierarchy"] == 0, "") # Select and sort - result = rollup_result[["total_sum", "s_state", "s_county", "lochierarchy", "rank_within_parent"]] - return result.sort_values( - ["lochierarchy", "s_state", "rank_within_parent"], - ascending=[False, True, True], - na_position="last", - ).head(100) + result = rollup_result[["total_sum", "s_state", "s_county", "lochierarchy", "rank_within_parent", "order_state"]] + ordered = _sort_null_largest_pandas( + result, ["lochierarchy", "order_state", "rank_within_parent"], [True, False, False] + ) + return ordered.drop(columns=["order_state"]).head(100) # ============================================================================= @@ -3825,6 +5040,7 @@ def q2_expression_impl(ctx: DataFrameContext) -> Any: compare weekly sales between current year and next year. """ col = ctx.col + lit = ctx.lit # Parameters params = get_parameters(2) @@ -3869,11 +5085,31 @@ def q2_expression_impl(ctx: DataFrameContext) -> Any: ctx.when(col("d_day_name") == "Thursday").then(col("sales_price")).otherwise(None).sum().alias("thu_sales"), ctx.when(col("d_day_name") == "Friday").then(col("sales_price")).otherwise(None).sum().alias("fri_sales"), ctx.when(col("d_day_name") == "Saturday").then(col("sales_price")).otherwise(None).sum().alias("sat_sales"), + # Companion non-null counts: SQL SUM() over all-NULL inputs is NULL. + *[ + ctx.when(col("d_day_name") == day).then(col("sales_price")).otherwise(None).count().alias(f"n_{alias}") + for day, alias in [ + ("Sunday", "sun_sales"), + ("Monday", "mon_sales"), + ("Tuesday", "tue_sales"), + ("Wednesday", "wed_sales"), + ("Thursday", "thu_sales"), + ("Friday", "fri_sales"), + ("Saturday", "sat_sales"), + ] + ], ] ) + # Restore NULL day sums whose day has zero rows in the week. + for _alias in ["sun_sales", "mon_sales", "tue_sales", "wed_sales", "thu_sales", "fri_sales", "sat_sales"]: + wswscs = wswscs.with_columns( + ctx.when(col(f"n_{_alias}") > lit(0)).then(col(_alias)).otherwise(None).alias(_alias) + ) - # Get year's weeks - join with date_dim to filter by year - date_weeks = date_dim.filter(col("d_year") == year).select(["d_week_seq"]).unique() + # Get year's weeks - join with date_dim to filter by year. SQL joins + # wswscs to date_dim on week_seq WITHOUT deduplication, so each week fans + # out over its dates: replicate that fan-out instead of filtering weeks. + date_weeks = date_dim.filter(col("d_year") == year).select(["d_week_seq"]) y1 = wswscs.join(date_weeks, on="d_week_seq", how="inner").select( [ col("d_week_seq").alias("d_week_seq1"), @@ -3888,7 +5124,7 @@ def q2_expression_impl(ctx: DataFrameContext) -> Any: ) # Get next year's weeks - date_weeks_next = date_dim.filter(col("d_year") == year + 1).select(["d_week_seq"]).unique() + date_weeks_next = date_dim.filter(col("d_year") == year + 1).select(["d_week_seq"]) y2 = wswscs.join(date_weeks_next, on="d_week_seq", how="inner").select( [ col("d_week_seq").alias("d_week_seq2"), @@ -3947,44 +5183,58 @@ def q2_pandas_impl(ctx: DataFrameContext) -> Any: # Join with date_dim joined = wscs.merge(date_dim, left_on="sold_date_sk", right_on="d_date_sk", how="inner") - # Pivot by day of week and aggregate by week_seq - # Use vectorized operations instead of .apply() for Dask compatibility + # Pivot by day of week and aggregate by week_seq. + # SQL SUM() over all-NULL inputs is NULL (not 0.0): pivot with NaN (not 0) + # and restore NaN for day-weeks with zero rows via mask counts. days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] day_cols = ["sun_sales", "mon_sales", "tue_sales", "wed_sales", "thu_sales", "fri_sales", "sat_sales"] - # Create conditional columns using vectorized where + # Create conditional columns using vectorized where (NaN where the day differs) for day, day_col in zip(days, day_cols): # Use Dask-compatible conditional assignment mask = joined["d_day_name"] == day - joined[day_col] = joined["sales_price"].where(mask, 0) + joined[day_col] = joined["sales_price"].where(mask) + joined[f"n_{day_col}"] = mask.astype(int) agg_dict = dict.fromkeys(day_cols, "sum") + agg_dict.update({f"n_{day_col}": "sum" for day_col in day_cols}) wswscs = ctx.groupby_agg(joined, "d_week_seq", agg_dict, as_index=False) + for day_col in day_cols: + wswscs[day_col] = wswscs[day_col].where(wswscs[f"n_{day_col}"] > 0) + wswscs = wswscs.drop(columns=[f"n_{day_col}" for day_col in day_cols]) - # Get weeks for each year (compute to set for Dask .isin() compatibility) - year_weeks = date_dim[date_dim["d_year"] == year]["d_week_seq"] - next_year_weeks = date_dim[date_dim["d_year"] == year + 1]["d_week_seq"] - year_weeks_set = ctx.to_set(year_weeks) - next_year_weeks_set = ctx.to_set(next_year_weeks) + # Join (not filter) with the year's date_dim weeks so each week fans out + # over its dates exactly like the SQL reference. + year_weeks = date_dim[date_dim["d_year"] == year][["d_week_seq"]] + next_year_weeks = date_dim[date_dim["d_year"] == year + 1][["d_week_seq"]] - y1 = wswscs[wswscs["d_week_seq"].isin(year_weeks_set)].copy() + y1 = wswscs.merge(year_weeks, on="d_week_seq", how="inner").copy() y1.columns = ["d_week_seq1"] + [f"{c}1" for c in day_cols] - y2 = wswscs[wswscs["d_week_seq"].isin(next_year_weeks_set)].copy() + y2 = wswscs.merge(next_year_weeks, on="d_week_seq", how="inner").copy() y2.columns = ["d_week_seq2"] + [f"{c}2" for c in day_cols] # Join where week_seq1 = week_seq2 - 53 y2["join_key"] = y2["d_week_seq2"] - 53 result = y1.merge(y2, left_on="d_week_seq1", right_on="join_key", how="inner") - # Calculate ratios + # Calculate ratios (NaN propagates like SQL NULL) for day_col in day_cols: result[f"{day_col[:3]}_ratio"] = (result[f"{day_col}1"] / result[f"{day_col}2"]).round(2) result = result[ ["d_week_seq1", "sun_ratio", "mon_ratio", "tue_ratio", "wed_ratio", "thu_ratio", "fri_ratio", "sat_ratio"] ] - return result.sort_values("d_week_seq1") + result = result.sort_values("d_week_seq1") + # SQL NULL ratios arrive as NaN: map back to None in object columns so + # NULLs compare equal (assigning None into float64 would coerce to NaN). + import pandas as _pd + + for column in ["sun_ratio", "mon_ratio", "tue_ratio", "wed_ratio", "thu_ratio", "fri_ratio", "sat_ratio"]: + result[column] = _pd.Series( + [None if value != value else value for value in result[column].tolist()], dtype=object + ) + return result # ============================================================================= @@ -3992,6 +5242,24 @@ def q2_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +_Q31_ORDER_COLUMNS = ( + "ca_county", + "d_year", + "web_q1_q2_increase", + "store_q1_q2_increase", + "web_q2_q3_increase", + "store_q2_q3_increase", +) + + +def _q31_order_column(order_by: str) -> str: + """The output column for the template's ``ORDER BY [AGG]`` (a column, optionally qualified as ``ss1.``).""" + column = order_by.rpartition(".")[2] + if column not in _Q31_ORDER_COLUMNS: + raise ValueError(f"Q31 ORDER BY must be one of {list(_Q31_ORDER_COLUMNS)}, got {order_by!r}") + return column + + def q31_expression_impl(ctx: DataFrameContext) -> Any: """Q31: Store/Web quarterly sales growth by county (Polars). @@ -4004,6 +5272,7 @@ def q31_expression_impl(ctx: DataFrameContext) -> Any: # Parameters params = get_parameters(31) year = params.get("year", 2000) + order_by = _q31_order_column(params.get("order_by", "ss1.ca_county")) # Get tables store_sales, web_sales, date_dim, customer_address = _tables( @@ -4097,7 +5366,7 @@ def q31_expression_impl(ctx: DataFrameContext) -> Any: col("web_q2_q3_increase"), col("store_q2_q3_increase"), ] - ).sort("ca_county") + ).sort(order_by) def q31_pandas_impl(ctx: DataFrameContext) -> Any: @@ -4105,6 +5374,7 @@ def q31_pandas_impl(ctx: DataFrameContext) -> Any: """Q31: Store/Web quarterly sales growth by county (Pandas).""" params = get_parameters(31) year = params.get("year", 2000) + order_by = _q31_order_column(params.get("order_by", "ss1.ca_county")) # Get tables store_sales, web_sales, date_dim, customer_address = _tables( @@ -4164,19 +5434,21 @@ def q31_pandas_impl(ctx: DataFrameContext) -> Any: result["web_q2_q3_increase"] = result["ws3_sales"] / result["ws2_sales"] result["store_q2_q3_increase"] = result["ss3_sales"] / result["ss2_sales"] - # Filter: web growth > store growth for both periods - def safe_ratio(num, denom): - return num / denom if denom > 0 else None - - result = result[ - result.apply( - lambda r: ( - (safe_ratio(r["ws2_sales"], r["ws1_sales"]) or 0) > (safe_ratio(r["ss2_sales"], r["ss1_sales"]) or 0) - and (safe_ratio(r["ws3_sales"], r["ws2_sales"]) or 0) - > (safe_ratio(r["ss3_sales"], r["ss2_sales"]) or 0) - ), - axis=1, - ) + # Filter: SQL CASE returns NULL for non-positive denominators, and NULL > x + # is false. Build the ratios as masked Series so an empty result remains a + # DataFrame with its projected columns instead of being interpreted as a + # column-selection operation by ``DataFrame.apply``. + result["web_q1_q2_increase"] = result["ws2_sales"].div(result["ws1_sales"]).where(result["ws1_sales"] > 0) + result["store_q1_q2_increase"] = result["ss2_sales"].div(result["ss1_sales"]).where(result["ss1_sales"] > 0) + result["web_q2_q3_increase"] = result["ws3_sales"].div(result["ws2_sales"]).where(result["ws2_sales"] > 0) + result["store_q2_q3_increase"] = result["ss3_sales"].div(result["ss2_sales"]).where(result["ss2_sales"] > 0) + result = result.loc[ + (result["ws1_sales"] > 0) + & (result["ss1_sales"] > 0) + & (result["ws2_sales"] > 0) + & (result["ss2_sales"] > 0) + & (result["web_q1_q2_increase"] > result["store_q1_q2_increase"]) + & (result["web_q2_q3_increase"] > result["store_q2_q3_increase"]) ] return result[ @@ -4188,7 +5460,7 @@ def safe_ratio(num, denom): "web_q2_q3_increase", "store_q2_q3_increase", ] - ].sort_values("ca_county") + ].sort_values(order_by) # ============================================================================= @@ -4206,8 +5478,8 @@ def q33_expression_impl(ctx: DataFrameContext) -> Any: # Parameters params = get_parameters(33) - year = params.get("year", 1998) - month = params.get("month", 1) + year = params.get("year", 1999) + month = params.get("month", 3) gmt_offset = params.get("gmt_offset", -5) category = params.get("category", "Books") @@ -4225,7 +5497,7 @@ def channel(table: str, item_key: str, date_key: str, addr_key: str, value_col: .join(addr_filter, left_on=addr_key, right_on="ca_address_sk", how="inner") .join(mfg_ids, on="i_manufact_id", how="semi") .group_by("i_manufact_id") - .agg(col(value_col).sum().alias("total_sales")) + .agg(_sum_or_null_expression(ctx, col(value_col)).alias("total_sales")) ) combined = ctx.concat( @@ -4235,20 +5507,19 @@ def channel(table: str, item_key: str, date_key: str, addr_key: str, value_col: channel("web_sales", "ws_item_sk", "ws_sold_date_sk", "ws_bill_addr_sk", "ws_ext_sales_price"), ] ) - return ( - combined.group_by("i_manufact_id") - .agg(col("total_sales").sum().alias("total_sales")) - .sort("total_sales") - .head(100) - ) + return _sort_null_largest_expression( + ctx, + combined.group_by("i_manufact_id").agg(_sum_or_null_expression(ctx, col("total_sales")).alias("total_sales")), + ["total_sales"], + ).head(100) def q33_pandas_impl(ctx: DataFrameContext) -> Any: # Parameters """Q33: Three-channel sales by manufacturer with category filter (Pandas).""" params = get_parameters(33) - year = params.get("year", 1998) - month = params.get("month", 1) + year = params.get("year", 1999) + month = params.get("month", 3) gmt_offset = params.get("gmt_offset", -5) category = params.get("category", "Books") @@ -4259,13 +5530,13 @@ def q33_pandas_impl(ctx: DataFrameContext) -> Any: addr_filter = customer_address[customer_address["ca_gmt_offset"] == gmt_offset] def channel(table: str, item_key: str, date_key: str, addr_key: str, value_col: str) -> Any: - return ( + return _grouped_pandas_aggregates( ctx.get_table(table) .merge(item[item["i_manufact_id"].isin(mfg_ids)], left_on=item_key, right_on="i_item_sk", how="inner") .merge(date_filter, left_on=date_key, right_on="d_date_sk", how="inner") - .merge(addr_filter, left_on=addr_key, right_on="ca_address_sk", how="inner") - .groupby("i_manufact_id", as_index=False) - .agg(total_sales=(value_col, "sum")) + .merge(addr_filter, left_on=addr_key, right_on="ca_address_sk", how="inner"), + "i_manufact_id", + {"total_sales": (value_col, "sum")}, ) combined = ctx.concat( @@ -4275,12 +5546,12 @@ def channel(table: str, item_key: str, date_key: str, addr_key: str, value_col: channel("web_sales", "ws_item_sk", "ws_sold_date_sk", "ws_bill_addr_sk", "ws_ext_sales_price"), ] ) - return ( - combined.groupby("i_manufact_id", as_index=False) - .agg(total_sales=("total_sales", "sum")) + result = ( + _grouped_pandas_aggregates(combined, "i_manufact_id", {"total_sales": ("total_sales", "sum")}) .sort_values("total_sales") .head(100) ) + return _none_for_null(result, list(result.columns)) # ============================================================================= @@ -4298,8 +5569,8 @@ def q71_expression_impl(ctx: DataFrameContext) -> Any: # Parameters params = get_parameters(71) - year = params.get("year", 1999) - month = params.get("month", 11) + year = params.get("year", 2000) + month = params.get("month", 12) date_dim, item, time_dim = _tables(ctx, "date_dim", "item", "time_dim") @@ -4325,7 +5596,7 @@ def channel(table: str, date_key: str, price_col: str, item_key: str, time_key: channel("store_sales", "ss_sold_date_sk", "ss_ext_sales_price", "ss_item_sk", "ss_sold_time_sk"), ] ) - return ( + grouped = ( combined.join(item.filter(col("i_manager_id") == 1), left_on="sold_item_sk", right_on="i_item_sk", how="inner") .join( time_dim.filter(col("t_meal_time").is_in(["breakfast", "dinner"])), @@ -4334,17 +5605,22 @@ def channel(table: str, date_key: str, price_col: str, item_key: str, time_key: how="inner", ) .group_by(["i_brand_id", "i_brand", "t_hour", "t_minute"]) - .agg(col("ext_price").sum().alias("ext_price")) - .sort(["ext_price", "i_brand_id"], descending=[True, False]) + .agg( + ctx.when(col("ext_price").count() > ctx.lit(0)) + .then(col("ext_price").sum()) + .otherwise(ctx.lit(None)) + .alias("ext_price") + ) ) + return _sort_null_largest_expression(ctx, grouped, ["ext_price", "i_brand_id"], [True, False]) def q71_pandas_impl(ctx: DataFrameContext) -> Any: # Parameters """Q71: Three-channel sales by brand and meal time (Pandas).""" params = get_parameters(71) - year = params.get("year", 1999) - month = params.get("month", 11) + year = params.get("year", 2000) + month = params.get("month", 12) date_dim, item, time_dim = _tables(ctx, "date_dim", "item", "time_dim") @@ -4367,13 +5643,17 @@ def channel(table: str, date_key: str, price_col: str, item_key: str, time_key: item_filter = item[item["i_manager_id"] == 1] time_filter = time_dim[time_dim["t_meal_time"].isin(["breakfast", "dinner"])] - return ( + grouped = ( combined.merge(item_filter, left_on="sold_item_sk", right_on="i_item_sk", how="inner") .merge(time_filter, left_on="time_sk", right_on="t_time_sk", how="inner") .groupby(["i_brand_id", "i_brand", "t_hour", "t_minute"], as_index=False) - .agg(ext_price=("ext_price", "sum")) - .sort_values(["ext_price", "i_brand_id"], ascending=[False, True]) + .agg(ext_price=("ext_price", "sum"), priced=("ext_price", "count")) ) + # SQL SUM of only NULLs is NULL; pandas sums them to 0. Dask has no lambda aggregation, so count the non-NULLs. + grouped["ext_price"] = grouped["ext_price"].where(grouped["priced"] > 0) + grouped = grouped.drop(columns=["priced"]) + result = _sort_null_largest_pandas(grouped, ["ext_price", "i_brand_id"], [True, False]) + return _none_for_null(result, ["ext_price"]) # ============================================================================= @@ -4381,6 +5661,26 @@ def channel(table: str, date_key: str, price_col: str, item_key: str, time_key: # ============================================================================= +_Q74_AGGREGATES = {"sum": "sum", "min": "min", "max": "max", "avg": "mean", "stddev_samp": "std"} +_Q74_OUTPUT = ("c_customer_id", "c_first_name", "c_last_name") + + +def _q74_parameters(params: Any) -> tuple[int, str, list[str]]: + year = params.get("year", 2001) + aggregate = params.get("aggone", "max") + if aggregate not in _Q74_AGGREGATES: + raise ValueError(f"Q74 aggone must be one of {sorted(_Q74_AGGREGATES)}, got {aggregate!r}") + positions = params.get("order_by", [2, 1, 3]) + if ( + not isinstance(positions, (list, tuple)) + or len(positions) != 3 + or any(str(position) not in {"1", "2", "3"} for position in positions) + ): + raise ValueError(f"Q74 order_by must contain three positions from 1, 2 and 3, got {positions!r}") + order_by = [int(position) for position in positions] + return year, _Q74_AGGREGATES[aggregate], [_Q74_OUTPUT[position - 1] for position in order_by] + + def q74_expression_impl(ctx: DataFrameContext) -> Any: """Q74: Store/Web customer year-over-year growth comparison (Polars). @@ -4388,98 +5688,35 @@ def q74_expression_impl(ctx: DataFrameContext) -> Any: between consecutive years. """ col = ctx.col - lit = ctx.lit - - # Parameters - params = get_parameters(74) - year = params.get("year", 1998) - - # Get tables - customer, store_sales, web_sales, date_dim = _tables(ctx, "customer", "store_sales", "web_sales", "date_dim") - - # Filter date for both years - date_year1 = date_dim.filter(col("d_year") == year) - date_year2 = date_dim.filter(col("d_year") == year + 1) - - # Store sales year 1 - ss_y1 = ( - store_sales.join(customer, left_on="ss_customer_sk", right_on="c_customer_sk", how="inner") - .join(date_year1, left_on="ss_sold_date_sk", right_on="d_date_sk", how="inner") - .group_by(["c_customer_id", "c_first_name", "c_last_name"]) - .agg(col("ss_net_paid").sum().alias("year_total")) - .with_columns( - [ - lit(year).alias("year"), - lit("s").alias("sale_type"), - ] - ) - ) - - # Store sales year 2 - ss_y2 = ( - store_sales.join(customer, left_on="ss_customer_sk", right_on="c_customer_sk", how="inner") - .join(date_year2, left_on="ss_sold_date_sk", right_on="d_date_sk", how="inner") - .group_by(["c_customer_id", "c_first_name", "c_last_name"]) - .agg(col("ss_net_paid").sum().alias("year_total")) - .with_columns( - [ - lit(year + 1).alias("year"), - lit("s").alias("sale_type"), - ] - ) - ) + lit = ctx.lit - # Web sales year 1 - ws_y1 = ( - web_sales.join(customer, left_on="ws_bill_customer_sk", right_on="c_customer_sk", how="inner") - .join(date_year1, left_on="ws_sold_date_sk", right_on="d_date_sk", how="inner") - .group_by(["c_customer_id", "c_first_name", "c_last_name"]) - .agg(col("ws_net_paid").sum().alias("year_total")) - .with_columns( - [ - lit(year).alias("year"), - lit("w").alias("sale_type"), - ] - ) - ) + params = get_parameters(74) + year, aggregate, order_by = _q74_parameters(params) - # Web sales year 2 - ws_y2 = ( - web_sales.join(customer, left_on="ws_bill_customer_sk", right_on="c_customer_sk", how="inner") - .join(date_year2, left_on="ws_sold_date_sk", right_on="d_date_sk", how="inner") - .group_by(["c_customer_id", "c_first_name", "c_last_name"]) - .agg(col("ws_net_paid").sum().alias("year_total")) - .with_columns( - [ - lit(year + 1).alias("year"), - lit("w").alias("sale_type"), - ] + customer, store_sales, web_sales, date_dim = _tables(ctx, "customer", "store_sales", "web_sales", "date_dim") + + def year_total(sales: Any, customer_key: str, date_key: str, amount: str, the_year: int, alias: str) -> Any: + totals = ( + sales.join(customer, left_on=customer_key, right_on="c_customer_sk", how="inner") + .join(date_dim.filter(col("d_year") == lit(the_year)), left_on=date_key, right_on="d_date_sk", how="inner") + .group_by(list(_Q74_OUTPUT)) + .agg( + getattr(col(amount), aggregate)().alias(alias), + col(amount).count().alias("_values"), + ) ) - ) + total = ctx.when(col("_values") > lit(0)).then(col(alias)).otherwise(lit(None)) + return totals.with_columns(total.alias(alias)).drop("_values") + + ss_y1 = year_total(store_sales, "ss_customer_sk", "ss_sold_date_sk", "ss_net_paid", year, "ss_y1_total") + ss_y2 = year_total(store_sales, "ss_customer_sk", "ss_sold_date_sk", "ss_net_paid", year + 1, "ss_y2_total") + ws_y1 = year_total(web_sales, "ws_bill_customer_sk", "ws_sold_date_sk", "ws_net_paid", year, "ws_y1_total") + ws_y2 = year_total(web_sales, "ws_bill_customer_sk", "ws_sold_date_sk", "ws_net_paid", year + 1, "ws_y2_total") - # Join all four: s_y1, s_y2, w_y1, w_y2 result = ( - ss_y1.select([col("c_customer_id"), col("year_total").alias("ss_y1_total")]) - .join( - ss_y2.select([col("c_customer_id"), col("year_total").alias("ss_y2_total")]), - on="c_customer_id", - how="inner", - ) - .join( - ws_y1.select([col("c_customer_id"), col("year_total").alias("ws_y1_total")]), - on="c_customer_id", - how="inner", - ) - .join( - ws_y2.select([col("c_customer_id"), col("year_total").alias("ws_y2_total")]), - on="c_customer_id", - how="inner", - ) - .join( - ss_y2.select(["c_customer_id", "c_first_name", "c_last_name"]), - on="c_customer_id", - how="inner", - ) + ss_y2.join(ss_y1.select("c_customer_id", "ss_y1_total"), on="c_customer_id", how="inner") + .join(ws_y1.select("c_customer_id", "ws_y1_total"), on="c_customer_id", how="inner") + .join(ws_y2.select("c_customer_id", "ws_y2_total"), on="c_customer_id", how="inner") ) # Filter where both first year totals > 0 and web growth > store growth @@ -4492,78 +5729,43 @@ def q74_expression_impl(ctx: DataFrameContext) -> Any: ) ) - return result.select(["c_customer_id", "c_first_name", "c_last_name"]).head(100) + return result.select(list(_Q74_OUTPUT)).sort(order_by, nulls_last=True).head(100) def q74_pandas_impl(ctx: DataFrameContext) -> Any: - # Parameters """Q74: Store/Web customer year-over-year growth comparison (Pandas).""" params = get_parameters(74) - year = params.get("year", 1998) + year, aggregate, order_by = _q74_parameters(params) - # Get tables customer, store_sales, web_sales, date_dim = _tables(ctx, "customer", "store_sales", "web_sales", "date_dim") - # Filter dates - date_year1 = date_dim[date_dim["d_year"] == year] - date_year2 = date_dim[date_dim["d_year"] == year + 1] - - # Store sales year 1 - ss_y1 = ( - store_sales.merge(customer, left_on="ss_customer_sk", right_on="c_customer_sk", how="inner") - .merge(date_year1, left_on="ss_sold_date_sk", right_on="d_date_sk", how="inner") - .groupby(["c_customer_id", "c_first_name", "c_last_name"], as_index=False) - .agg(ss_y1_total=("ss_net_paid", "sum")) - ) - - # Store sales year 2 - ss_y2 = ( - store_sales.merge(customer, left_on="ss_customer_sk", right_on="c_customer_sk", how="inner") - .merge(date_year2, left_on="ss_sold_date_sk", right_on="d_date_sk", how="inner") - .groupby(["c_customer_id"], as_index=False) - .agg(ss_y2_total=("ss_net_paid", "sum")) - ) - - # Web sales year 1 - ws_y1 = ( - web_sales.merge(customer, left_on="ws_bill_customer_sk", right_on="c_customer_sk", how="inner") - .merge(date_year1, left_on="ws_sold_date_sk", right_on="d_date_sk", how="inner") - .groupby(["c_customer_id"], as_index=False) - .agg(ws_y1_total=("ws_net_paid", "sum")) - ) + def year_total(sales: Any, customer_key: str, date_key: str, amount: str, the_year: int, alias: str) -> Any: + dates = date_dim[date_dim["d_year"] == the_year][["d_date_sk"]] + joined = sales.merge(customer, left_on=customer_key, right_on="c_customer_sk", how="inner").merge( + dates, left_on=date_key, right_on="d_date_sk", how="inner" + ) + method = (lambda values: values.sum(min_count=1)) if aggregate == "sum" else aggregate + totals = joined.groupby(list(_Q74_OUTPUT), as_index=False, dropna=False).agg(**{alias: (amount, method)}) + totals[alias] = totals[alias].astype("float64") + return totals - # Web sales year 2 - ws_y2 = ( - web_sales.merge(customer, left_on="ws_bill_customer_sk", right_on="c_customer_sk", how="inner") - .merge(date_year2, left_on="ws_sold_date_sk", right_on="d_date_sk", how="inner") - .groupby(["c_customer_id"], as_index=False) - .agg(ws_y2_total=("ws_net_paid", "sum")) - ) + ss_y1 = year_total(store_sales, "ss_customer_sk", "ss_sold_date_sk", "ss_net_paid", year, "ss_y1_total") + ss_y2 = year_total(store_sales, "ss_customer_sk", "ss_sold_date_sk", "ss_net_paid", year + 1, "ss_y2_total") + ws_y1 = year_total(web_sales, "ws_bill_customer_sk", "ws_sold_date_sk", "ws_net_paid", year, "ws_y1_total") + ws_y2 = year_total(web_sales, "ws_bill_customer_sk", "ws_sold_date_sk", "ws_net_paid", year + 1, "ws_y2_total") - # Join all result = ( - ss_y1.merge(ss_y2, on="c_customer_id", how="inner") - .merge(ws_y1, on="c_customer_id", how="inner") - .merge(ws_y2, on="c_customer_id", how="inner") + ss_y2.merge(ss_y1[["c_customer_id", "ss_y1_total"]], on="c_customer_id", how="inner") + .merge(ws_y1[["c_customer_id", "ws_y1_total"]], on="c_customer_id", how="inner") + .merge(ws_y2[["c_customer_id", "ws_y2_total"]], on="c_customer_id", how="inner") ) - # Filter: first year totals > 0 and web growth > store growth - def safe_ratio(num, denom): - return num / denom if denom > 0 else None + web_growth = (result["ws_y2_total"] / result["ws_y1_total"]).where(result["ws_y1_total"] > 0) + store_growth = (result["ss_y2_total"] / result["ss_y1_total"]).where(result["ss_y1_total"] > 0) + result = result[(result["ss_y1_total"] > 0) & (result["ws_y1_total"] > 0) & (web_growth > store_growth)] - result = result[ - (result["ss_y1_total"] > 0) - & (result["ws_y1_total"] > 0) - & result.apply( - lambda r: ( - (safe_ratio(r["ws_y2_total"], r["ws_y1_total"]) or 0) - > (safe_ratio(r["ss_y2_total"], r["ss_y1_total"]) or 0) - ), - axis=1, - ) - ] - - return result[["c_customer_id", "c_first_name", "c_last_name"]].head(100) + result = result[list(_Q74_OUTPUT)].sort_values(order_by, na_position="last").head(100) + return _none_for_null(result, list(result.columns)) # ============================================================================= @@ -4614,17 +5816,18 @@ def channel(name: str, table: str, null_col: str, date_key: str, item_key: str, ] ) - return ( - combined.group_by(["channel", "col_name", "d_year", "d_qoy", "i_category"]) - .agg( - [ - ctx.count().alias("sales_cnt"), - col("ext_sales_price").sum().alias("sales_amt"), - ] - ) - .sort(["channel", "col_name", "d_year", "d_qoy", "i_category"]) - .head(100) + grouped = combined.group_by(["channel", "col_name", "d_year", "d_qoy", "i_category"]).agg( + [ + ctx.count().alias("sales_cnt"), + ctx.when(col("ext_sales_price").count() > lit(0)) + .then(col("ext_sales_price").sum()) + .otherwise(lit(None)) + .alias("sales_amt"), + ] ) + return _sort_null_largest_expression( + ctx, grouped, ["channel", "col_name", "d_year", "d_qoy", "i_category"], [False] * 5 + ).head(100) def q76_pandas_impl(ctx: DataFrameContext) -> Any: @@ -4638,8 +5841,9 @@ def q76_pandas_impl(ctx: DataFrameContext) -> Any: date_dim, item = _tables(ctx, "date_dim", "item") def channel(name: str, table: str, null_col: str, date_key: str, item_key: str, value_col: str) -> Any: + sales = ctx.get_table(table) result = ( - ctx.get_table(table)[lambda frame: frame[null_col].isna()] + sales[sales[null_col].isna()] .merge(date_dim, left_on=date_key, right_on="d_date_sk", how="inner") .merge(item, left_on=item_key, right_on="i_item_sk", how="inner") ) @@ -4657,12 +5861,19 @@ def channel(name: str, table: str, null_col: str, date_key: str, item_key: str, ] ) - return ( - combined.groupby(["channel", "col_name", "d_year", "d_qoy", "i_category"], as_index=False) - .agg(sales_cnt=("ext_sales_price", "count"), sales_amt=("ext_sales_price", "sum")) - .sort_values(["channel", "col_name", "d_year", "d_qoy", "i_category"]) + result = ( + combined.groupby(["channel", "col_name", "d_year", "d_qoy", "i_category"], as_index=False, dropna=False) + .agg( + sales_cnt=("ext_sales_price", "size"), + sales_amt=("ext_sales_price", "sum"), + priced=("ext_sales_price", "count"), + ) + .sort_values(["channel", "col_name", "d_year", "d_qoy", "i_category"], na_position="last") .head(100) ) + # SQL SUM of only NULLs is NULL; pandas sums them to 0. Dask has no lambda aggregation, so count the non-NULLs. + result["sales_amt"] = result["sales_amt"].where(result["priced"] > 0) + return _none_for_null(result.drop(columns=["priced"]), ["i_category", "sales_amt"]) # ============================================================================= @@ -4682,7 +5893,7 @@ def q97_expression_impl(ctx: DataFrameContext) -> Any: Tables: store_sales, catalog_sales, date_dim """ params = get_parameters(97) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) store_sales, catalog_sales, date_dim = _tables(ctx, "store_sales", "catalog_sales", "date_dim") col = ctx.col @@ -4752,7 +5963,7 @@ def q97_pandas_impl(ctx: DataFrameContext) -> Any: import pandas as pd params = get_parameters(97) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) store_sales, catalog_sales, date_dim = _tables(ctx, "store_sales", "catalog_sales", "date_dim") @@ -4870,7 +6081,10 @@ def channel( col("i_category_id"), col("i_manufact_id"), (col(quantity_col) - col(return_qty_col).fill_null(0)).alias("sales_cnt"), - (col(amount_col) - col(return_amount_col).fill_null(0.0)).alias("sales_amt"), + ( + (col(amount_col) * lit(100)).round(0).cast_int64() + - (col(return_amount_col).fill_null(0.0) * lit(100)).round(0).cast_int64() + ).alias("sales_amt_cents"), ] ) ) @@ -4882,19 +6096,20 @@ def channel( ("web_sales", "web_returns", "ws_item_sk", "ws_sold_date_sk", "ws_order_number", "wr_order_number", "wr_item_sk", "ws_quantity", "wr_return_quantity", "ws_ext_sales_price", "wr_return_amt"), ) # fmt: on - combined = ctx.concat([channel(*spec) for spec in channel_specs]) + combined = ctx.concat([channel(*spec) for spec in channel_specs]).unique() all_sales = combined.group_by(["d_year", "i_brand_id", "i_class_id", "i_category_id", "i_manufact_id"]).agg( [ - col("sales_cnt").sum().alias("sales_cnt"), - col("sales_amt").sum().alias("sales_amt"), + _sum_or_null_expression(ctx, col("sales_cnt")).alias("sales_cnt"), + _sum_or_null_expression(ctx, col("sales_amt_cents")).alias("sales_amt_cents"), ] ) curr_yr = all_sales.filter(col("d_year") == lit(year)) prev_yr = all_sales.filter(col("d_year") == lit(year - 1)) - return ( + return _sort_null_largest_expression( + ctx, curr_yr.join( prev_yr, on=["i_brand_id", "i_class_id", "i_category_id", "i_manufact_id"], @@ -4912,12 +6127,13 @@ def channel( col("sales_cnt_prev").alias("prev_yr_cnt"), col("sales_cnt").alias("curr_yr_cnt"), (col("sales_cnt") - col("sales_cnt_prev")).alias("sales_cnt_diff"), - (col("sales_amt") - col("sales_amt_prev")).alias("sales_amt_diff"), + ((col("sales_amt_cents") - col("sales_amt_cents_prev")).cast_float64() / lit(100)).alias( + "sales_amt_diff" + ), ] - ) - .sort(["sales_cnt_diff", "sales_amt_diff"]) - .limit(100) - ) + ), + ["sales_cnt_diff", "sales_amt_diff"], + ).limit(100) def q75_pandas_impl(ctx: DataFrameContext) -> Any: @@ -4952,9 +6168,11 @@ def channel( how="left", ) joined["sales_cnt"] = joined[quantity_col] - joined[return_qty_col].fillna(0) - joined["sales_amt"] = joined[amount_col] - joined[return_amount_col].fillna(0.0) + joined["sales_amt_cents"] = (joined[amount_col] * 100).round().astype("Int64") - ( + joined[return_amount_col].fillna(0.0) * 100 + ).round().astype("Int64") return joined[ - ["d_year", "i_brand_id", "i_class_id", "i_category_id", "i_manufact_id", "sales_cnt", "sales_amt"] + ["d_year", "i_brand_id", "i_class_id", "i_category_id", "i_manufact_id", "sales_cnt", "sales_amt_cents"] ] # fmt: off @@ -4964,11 +6182,13 @@ def channel( ("web_sales", "web_returns", "ws_item_sk", "ws_sold_date_sk", "ws_order_number", "wr_order_number", "wr_item_sk", "ws_quantity", "wr_return_quantity", "ws_ext_sales_price", "wr_return_amt"), ) # fmt: on - combined = ctx.concat([channel(*spec) for spec in channel_specs]) + combined = ctx.concat([channel(*spec) for spec in channel_specs]).drop_duplicates() - all_sales = combined.groupby( - ["d_year", "i_brand_id", "i_class_id", "i_category_id", "i_manufact_id"], as_index=False - ).agg(sales_cnt=("sales_cnt", "sum"), sales_amt=("sales_amt", "sum")) + all_sales = _grouped_pandas_aggregates( + combined, + ["d_year", "i_brand_id", "i_class_id", "i_category_id", "i_manufact_id"], + {"sales_cnt": ("sales_cnt", "sum"), "sales_amt_cents": ("sales_amt_cents", "sum")}, + ) curr_yr = all_sales[all_sales["d_year"] == year] prev_yr = all_sales[all_sales["d_year"] == year - 1] @@ -4987,7 +6207,7 @@ def channel( prev_yr_cnt=joined["sales_cnt_prev"], curr_yr_cnt=joined["sales_cnt"], sales_cnt_diff=joined["sales_cnt"] - joined["sales_cnt_prev"], - sales_amt_diff=joined["sales_amt"] - joined["sales_amt_prev"], + sales_amt_diff=(joined["sales_amt_cents"] - joined["sales_amt_cents_prev"]) / 100, )[ [ "prev_year", @@ -5003,7 +6223,8 @@ def channel( ] ] - return result.sort_values(["sales_cnt_diff", "sales_amt_diff"]).head(100) + result = result.sort_values(["sales_cnt_diff", "sales_amt_diff"]).head(100) + return _none_for_null(result, list(result.columns)) # ============================================================================= @@ -5011,6 +6232,34 @@ def channel( # ============================================================================= +_Q78_KEY_COLUMNS = ("ss_sold_year", "ss_item_sk", "ss_customer_sk") + + +def _q78_select_columns(params: Any) -> list[str]: + columns = [name.strip() for name in str(params.get("select_columns", ", ".join(_Q78_KEY_COLUMNS))).split(",")] + unknown = [name for name in columns if name not in _Q78_KEY_COLUMNS] + if not columns or unknown: + raise ValueError(f"Q78 select_columns must be drawn from {list(_Q78_KEY_COLUMNS)}, got {columns!r}") + return columns + + +_Q78_ORDER_TAIL = ( + ("store_qty", True), + ("store_wholesale_cost", True), + ("store_sales_price", True), + ("other_chan_qty", False), + ("other_chan_wholesale_cost", False), + ("other_chan_sales_price", False), + ("ratio", False), +) + + +def _q78_order(select_columns: list[str]) -> tuple[list[str], list[bool]]: + order_by = [*select_columns, *(name for name, _ in _Q78_ORDER_TAIL)] + descending = [False] * len(select_columns) + [flag for _, flag in _Q78_ORDER_TAIL] + return order_by, descending + + def q78_expression_impl(ctx: DataFrameContext) -> Any: """Q78: Three-channel sales comparison excluding returned items (Polars). @@ -5022,6 +6271,7 @@ def q78_expression_impl(ctx: DataFrameContext) -> Any: """ params = get_parameters(78) year = params.get("year", 2000) + select_columns = _q78_select_columns(params) date_dim = ctx.get_table("date_dim") col = ctx.col @@ -5042,15 +6292,30 @@ def channel( ) -> Any: return ( ctx.get_table(sales_table) - .join(ctx.get_table(returns_table), left_on=left_on, right_on=right_on, how="left") + # SQL tests the returns key for NULL. A join drops that key from the result, so keep a copy. + .join( + ctx.get_table(returns_table).with_columns(col(return_null_col).alias("_returned")), + left_on=left_on, + right_on=right_on, + how="left", + ) .join(date_dim, left_on=date_key, right_on="d_date_sk") - .filter(col(return_null_col).is_null()) + .filter(col("_returned").is_null()) .group_by(group_cols) .agg( [ - col(quantity_col).sum().alias(aliases["qty"]), - col(wholesale_col).sum().alias(aliases["wc"]), - col(sales_price_col).sum().alias(aliases["sp"]), + ctx.when(col(quantity_col).count() > lit(0)) + .then(col(quantity_col).sum()) + .otherwise(lit(None)) + .alias(aliases["qty"]), + ctx.when(col(wholesale_col).count() > lit(0)) + .then(col(wholesale_col).sum()) + .otherwise(lit(None)) + .alias(aliases["wc"]), + ctx.when(col(sales_price_col).count() > lit(0)) + .then(col(sales_price_col).sum()) + .otherwise(lit(None)) + .alias(aliases["sp"]), ] ) .rename({key: value for key, value in aliases.items() if key not in {"qty", "wc", "sp"}}) @@ -5062,7 +6327,7 @@ def channel( ["ss_ticket_number", "ss_item_sk"], ["sr_ticket_number", "sr_item_sk"], "ss_sold_date_sk", - "sr_return_amt", + "sr_ticket_number", ["d_year", "ss_item_sk", "ss_customer_sk"], "ss_quantity", "ss_wholesale_cost", @@ -5075,7 +6340,7 @@ def channel( ["cs_order_number", "cs_item_sk"], ["cr_order_number", "cr_item_sk"], "cs_sold_date_sk", - "cr_return_amount", + "cr_order_number", ["d_year", "cs_item_sk", "cs_bill_customer_sk"], "cs_quantity", "cs_wholesale_cost", @@ -5094,7 +6359,7 @@ def channel( ["ws_order_number", "ws_item_sk"], ["wr_order_number", "wr_item_sk"], "ws_sold_date_sk", - "wr_return_amt", + "wr_order_number", ["d_year", "ws_item_sk", "ws_bill_customer_sk"], "ws_quantity", "ws_wholesale_cost", @@ -5108,7 +6373,7 @@ def channel( }, ) - return ( + selected = ( ss.join( ws, left_on=["ss_sold_year", "ss_item_sk", "ss_customer_sk"], @@ -5127,12 +6392,11 @@ def channel( ) .with_columns( [ - ( + _round_half_up_expression( col("ss_qty").cast_float64() - / (col("ws_qty").fill_null(0) + col("cs_qty").fill_null(0)).cast_float64() - ) - .round(2) - .alias("ratio"), + / (col("ws_qty").fill_null(0) + col("cs_qty").fill_null(0)).cast_float64(), + 2, + ).alias("ratio"), (col("ws_qty").fill_null(0) + col("cs_qty").fill_null(0)).alias("other_chan_qty"), (col("ws_wc").fill_null(0) + col("cs_wc").fill_null(0)).alias("other_chan_wholesale_cost"), (col("ws_sp").fill_null(0) + col("cs_sp").fill_null(0)).alias("other_chan_sales_price"), @@ -5140,9 +6404,7 @@ def channel( ) .select( [ - "ss_sold_year", - "ss_item_sk", - "ss_customer_sk", + *select_columns, "ratio", col("ss_qty").alias("store_qty"), col("ss_wc").alias("store_wholesale_cost"), @@ -5152,29 +6414,16 @@ def channel( "other_chan_sales_price", ] ) - .sort( - [ - "ss_sold_year", - "ss_item_sk", - "ss_customer_sk", - "store_qty", - "store_wholesale_cost", - "store_sales_price", - "other_chan_qty", - "other_chan_wholesale_cost", - "other_chan_sales_price", - "ratio", - ], - descending=[False, False, False, True, True, True, False, False, False, False], - ) - .limit(100) ) + order_by, descending = _q78_order(select_columns) + return _sort_null_largest_expression(ctx, selected, order_by, descending).limit(100) def q78_pandas_impl(ctx: DataFrameContext) -> Any: """Q78: Three-channel sales comparison excluding returned items (Pandas).""" params = get_parameters(78) year = params.get("year", 2000) + select_columns = _q78_select_columns(params) date_dim = ctx.get_table("date_dim") @@ -5186,19 +6435,25 @@ def channel( date_key: str, return_null_col: str, group_cols: list[str], - agg_spec: dict[str, tuple[str, str]], + value_cols: dict[str, str], aliases: dict[str, str], ) -> Any: joined = ctx.get_table(sales_table).merge( ctx.get_table(returns_table), left_on=left_on, right_on=right_on, how="left" ) joined = joined.merge(date_dim, left_on=date_key, right_on="d_date_sk") - return ( - joined[joined[return_null_col].isna()] - .groupby(group_cols, as_index=False) - .agg(**agg_spec) - .rename(columns=aliases) - ) + # SQL SUM() over inputs that are all NULL is NULL. A groupby lambda with min_count=1 gives that + # but runs Python code per group; the built-in sum and count are vectorized and masked instead. + agg_spec = {} + for alias, source in value_cols.items(): + agg_spec[alias] = (source, "sum") + agg_spec[f"{alias}_n"] = (source, "count") + grouped = ( + joined[joined[return_null_col].isna()].groupby(group_cols, as_index=False, dropna=False).agg(**agg_spec) + ) + for alias in value_cols: + grouped[alias] = grouped[alias].where(grouped[f"{alias}_n"] > 0) + return grouped.drop(columns=[f"{alias}_n" for alias in value_cols]).rename(columns=aliases) ss_agg = channel( "store_sales", @@ -5206,9 +6461,13 @@ def channel( ["ss_ticket_number", "ss_item_sk"], ["sr_ticket_number", "sr_item_sk"], "ss_sold_date_sk", - "sr_return_amt", + "sr_ticket_number", ["d_year", "ss_item_sk", "ss_customer_sk"], - {"ss_qty": ("ss_quantity", "sum"), "ss_wc": ("ss_wholesale_cost", "sum"), "ss_sp": ("ss_sales_price", "sum")}, + { + "ss_qty": "ss_quantity", + "ss_wc": "ss_wholesale_cost", + "ss_sp": "ss_sales_price", + }, {"d_year": "ss_sold_year"}, ) cs_agg = channel( @@ -5217,9 +6476,13 @@ def channel( ["cs_order_number", "cs_item_sk"], ["cr_order_number", "cr_item_sk"], "cs_sold_date_sk", - "cr_return_amount", + "cr_order_number", ["d_year", "cs_item_sk", "cs_bill_customer_sk"], - {"cs_qty": ("cs_quantity", "sum"), "cs_wc": ("cs_wholesale_cost", "sum"), "cs_sp": ("cs_sales_price", "sum")}, + { + "cs_qty": "cs_quantity", + "cs_wc": "cs_wholesale_cost", + "cs_sp": "cs_sales_price", + }, {"d_year": "cs_sold_year", "cs_bill_customer_sk": "cs_customer_sk"}, ) ws_agg = channel( @@ -5228,20 +6491,28 @@ def channel( ["ws_order_number", "ws_item_sk"], ["wr_order_number", "wr_item_sk"], "ws_sold_date_sk", - "wr_return_amt", + "wr_order_number", ["d_year", "ws_item_sk", "ws_bill_customer_sk"], - {"ws_qty": ("ws_quantity", "sum"), "ws_wc": ("ws_wholesale_cost", "sum"), "ws_sp": ("ws_sales_price", "sum")}, + { + "ws_qty": "ws_quantity", + "ws_wc": "ws_wholesale_cost", + "ws_sp": "ws_sales_price", + }, {"d_year": "ws_sold_year", "ws_bill_customer_sk": "ws_customer_sk"}, ) + # SQL equality joins do not match NULL keys. Pandas merge does, so remove + # null-key rows from the right-hand aggregates before each left join. + ws_join = ws_agg.dropna(subset=["ws_sold_year", "ws_item_sk", "ws_customer_sk"]) + cs_join = cs_agg.dropna(subset=["cs_sold_year", "cs_item_sk", "cs_customer_sk"]) result = ss_agg.merge( - ws_agg, + ws_join, left_on=["ss_sold_year", "ss_item_sk", "ss_customer_sk"], right_on=["ws_sold_year", "ws_item_sk", "ws_customer_sk"], how="left", ) result = result.merge( - cs_agg, + cs_join, left_on=["ss_sold_year", "ss_item_sk", "ss_customer_sk"], right_on=["cs_sold_year", "cs_item_sk", "cs_customer_sk"], how="left", @@ -5254,7 +6525,7 @@ def channel( result["other_chan_qty"] = result["ws_qty"].fillna(0) + result["cs_qty"].fillna(0) result["other_chan_wholesale_cost"] = result["ws_wc"].fillna(0) + result["cs_wc"].fillna(0) result["other_chan_sales_price"] = result["ws_sp"].fillna(0) + result["cs_sp"].fillna(0) - result["ratio"] = (result["ss_qty"] / result["other_chan_qty"]).round(2) + result["ratio"] = _round_half_up_pandas(result["ss_qty"] / result["other_chan_qty"], 2) result = result.rename( columns={ @@ -5266,9 +6537,7 @@ def channel( result = result[ [ - "ss_sold_year", - "ss_item_sk", - "ss_customer_sk", + *select_columns, "ratio", "store_qty", "store_wholesale_cost", @@ -5279,29 +6548,32 @@ def channel( ] ] - return result.sort_values( - [ - "ss_sold_year", - "ss_item_sk", - "ss_customer_sk", - "store_qty", - "store_wholesale_cost", - "store_sales_price", - "other_chan_qty", - "other_chan_wholesale_cost", - "other_chan_sales_price", - "ratio", - ], - ascending=[True, True, True, False, False, False, True, True, True, True], - ).head(100) + order_by, descending = _q78_order(select_columns) + return ( + _sort_null_largest_pandas(result, order_by, descending) + .head(100) + .astype(object) + .where(lambda frame: frame.notna(), None) + ) # fmt: off _CUSTOMER_YEAR_COLS = ["c_customer_id", "c_first_name", "c_last_name", "c_preferred_cust_flag", "c_birth_country", "c_login", "c_email_address"] +_SELECT_ONE_COLUMNS = {"t_s_secyear.customer_preferred_cust_flag": "c_preferred_cust_flag", "t_s_secyear.customer_birth_country": "c_birth_country", "t_s_secyear.customer_login": "c_login", "t_s_secyear.customer_email_address": "c_email_address"} +_SELECT_ONE_DEFAULT = "t_s_secyear.customer_preferred_cust_flag" _CHANNEL_YEAR_SPECS = {"s": ("store_sales", "ss_customer_sk", "ss_sold_date_sk", "ss"), "c": ("catalog_sales", "cs_bill_customer_sk", "cs_sold_date_sk", "cs"), "w": ("web_sales", "ws_bill_customer_sk", "ws_sold_date_sk", "ws")} # fmt: on +def _select_one_column(select_one: str) -> str: + try: + return _SELECT_ONE_COLUMNS[select_one] + except KeyError: + raise ValueError( + f"Unsupported SELECTONE value {select_one!r}; expected one of {sorted(_SELECT_ONE_COLUMNS)}" + ) from None + + def _channel_year_total_expression(ctx: DataFrameContext, channel: str, formula: str) -> Any: table_name, customer_key, date_key, prefix = _CHANNEL_YEAR_SPECS[channel] col = ctx.col @@ -5338,16 +6610,25 @@ def _channel_year_total_pandas(ctx: DataFrameContext, channel: str, formula: str ) / 2 else: frame["year_total"] = frame[f"{prefix}_ext_list_price"] - frame[f"{prefix}_ext_discount_amt"] - result = frame.groupby(_CUSTOMER_YEAR_COLS + ["d_year"], as_index=False).agg(year_total=("year_total", "sum")) + result = frame.groupby(_CUSTOMER_YEAR_COLS + ["d_year"], as_index=False, dropna=False).agg( + year_total=("year_total", "sum") + ) result["sale_type"] = channel return result def _year_growth_expression( - ctx: DataFrameContext, year: int, channels: tuple[str, ...], formula: str, winner: str, competitors: tuple[str, ...] + ctx: DataFrameContext, + year: int, + select_one: str, + channels: tuple[str, ...], + formula: str, + winner: str, + competitors: tuple[str, ...], ) -> Any: col = ctx.col lit = ctx.lit + select_column = _select_one_column(select_one) year_total = ctx.concat([_channel_year_total_expression(ctx, channel, formula) for channel in channels]) result = None for channel in channels: @@ -5358,7 +6639,7 @@ def _year_growth_expression( ) second_cols = [col("c_customer_id"), col("year_total").alias(f"{channel}_sec_total")] if result is None: - second_cols[1:1] = [col("c_first_name"), col("c_last_name"), col("c_preferred_cust_flag")] + second_cols[1:1] = [col("c_first_name"), col("c_last_name"), col(select_column)] second = year_total.filter((col("sale_type") == lit(channel)) & (col("d_year") == lit(year + 1))).select( second_cols ) @@ -5377,17 +6658,24 @@ def _year_growth_expression( for channel in competitors: clause = col(f"{winner}_growth") > col(f"{channel}_growth") predicate = clause if predicate is None else predicate & clause - return ( - result.filter(predicate) - .select([col("c_customer_id"), col("c_first_name"), col("c_last_name"), col("c_preferred_cust_flag")]) - .sort(["c_customer_id", "c_first_name", "c_last_name", "c_preferred_cust_flag"]) - .limit(100) + selected = result.filter(predicate).select( + [col("c_customer_id"), col("c_first_name"), col("c_last_name"), col(select_column)] ) + return _sort_null_largest_expression( + ctx, selected, ["c_customer_id", "c_first_name", "c_last_name", select_column], [False] * 4 + ).limit(100) def _year_growth_pandas( - ctx: DataFrameContext, year: int, channels: tuple[str, ...], formula: str, winner: str, competitors: tuple[str, ...] + ctx: DataFrameContext, + year: int, + select_one: str, + channels: tuple[str, ...], + formula: str, + winner: str, + competitors: tuple[str, ...], ) -> Any: + select_column = _select_one_column(select_one) year_total = ctx.concat([_channel_year_total_pandas(ctx, channel, formula) for channel in channels]) result = None for channel in channels: @@ -5396,7 +6684,7 @@ def _year_growth_pandas( ][["c_customer_id", "year_total"]].rename(columns={"year_total": f"{channel}_first_total"}) second_cols = ["c_customer_id", "year_total"] if result is None: - second_cols[1:1] = ["c_first_name", "c_last_name", "c_preferred_cust_flag"] + second_cols[1:1] = ["c_first_name", "c_last_name", select_column] second = year_total[(year_total["sale_type"] == channel) & (year_total["d_year"] == year + 1)][ second_cols ].rename(columns={"year_total": f"{channel}_sec_total"}) @@ -5409,29 +6697,36 @@ def _year_growth_pandas( result[f"{channel}_growth"] = result[f"{channel}_sec_total"] / result[f"{channel}_first_total"] for channel in competitors: result = result[result[f"{winner}_growth"] > result[f"{channel}_growth"]] - return ( - result[["c_customer_id", "c_first_name", "c_last_name", "c_preferred_cust_flag"]] - .sort_values(["c_customer_id", "c_first_name", "c_last_name", "c_preferred_cust_flag"]) - .head(100) - ) + columns = ["c_customer_id", "c_first_name", "c_last_name", select_column] + top = _sort_null_largest_pandas(result[columns], columns, [False] * 4).head(100) + return top.astype(object).where(top.notna(), None) + + +_YEAR_GROWTH_SHAPES: dict[int, tuple[tuple[str, ...], str, str, tuple[str, ...]]] = { + 4: (("s", "c", "w"), "profit_mix", "c", ("s", "w")), + 11: (("s", "w"), "net_discount", "w", ("s",)), +} + + +def _year_growth_args(query_id: int) -> tuple[Any, ...]: + params = get_parameters(query_id) + return (params.get("year", 2001), params.get("select_one", _SELECT_ONE_DEFAULT), *_YEAR_GROWTH_SHAPES[query_id]) def q4_expression_impl(ctx: DataFrameContext) -> Any: - return _year_growth_expression( - ctx, get_parameters(4).get("year", 2001), ("s", "c", "w"), "profit_mix", "c", ("s", "w") - ) + return _year_growth_expression(ctx, *_year_growth_args(4)) def q4_pandas_impl(ctx: DataFrameContext) -> Any: - return _year_growth_pandas(ctx, get_parameters(4).get("year", 2001), ("s", "c", "w"), "profit_mix", "c", ("s", "w")) + return _year_growth_pandas(ctx, *_year_growth_args(4)) def q11_expression_impl(ctx: DataFrameContext) -> Any: - return _year_growth_expression(ctx, get_parameters(11).get("year", 2001), ("s", "w"), "net_discount", "w", ("s",)) + return _year_growth_expression(ctx, *_year_growth_args(11)) def q11_pandas_impl(ctx: DataFrameContext) -> Any: - return _year_growth_pandas(ctx, get_parameters(11).get("year", 2001), ("s", "w"), "net_discount", "w", ("s",)) + return _year_growth_pandas(ctx, *_year_growth_args(11)) # ============================================================================= @@ -5456,7 +6751,7 @@ def q5_expression_impl(ctx: DataFrameContext) -> Any: """ col = ctx.col lit = ctx.lit - date_filtered = _date_window_expression(ctx, 5, "2000-08-23", days=14) + date_filtered = _date_window_expression(ctx, 5, "1998-08-04", days=14) def channel( channel_name: str, @@ -5507,6 +6802,16 @@ def channel( col("profit").sum().alias("profit_sum"), col("return_amt").sum().alias("returns"), col("net_loss").sum().alias("profit_loss"), + col("sales_price").count().alias("sales_n"), + col("profit").count().alias("profit_sum_n"), + col("return_amt").count().alias("returns_n"), + col("net_loss").count().alias("profit_loss_n"), + ] + ) + .with_columns( + [ + ctx.when(col(f"{name}_n") > lit(0)).then(col(name)).otherwise(lit(None)).alias(name) + for name in ("sales", "profit_sum", "returns", "profit_loss") ] ) .with_columns( @@ -5532,7 +6837,7 @@ def channel( def q5_pandas_impl(ctx: DataFrameContext) -> Any: """Q5: Three-channel sales-returns with ROLLUP (Pandas).""" - date_filtered = _date_window_pandas(ctx, 5, "2000-08-23", days=14) + date_filtered = _date_window_pandas(ctx, 5, "1998-08-04", days=14) def part(frame: Any, columns: dict[str, str], sales: bool) -> Any: result = frame[list(columns)].rename(columns=columns).copy() @@ -5573,7 +6878,13 @@ def channel( profit_sum=("profit", "sum"), returns=("return_amt", "sum"), profit_loss=("net_loss", "sum"), + sales_n=("sales_price", "count"), + profit_sum_n=("profit", "count"), + returns_n=("return_amt", "count"), + profit_loss_n=("net_loss", "count"), ) + for name in ("sales", "profit_sum", "returns", "profit_loss"): + result[name] = result[name].where(result[f"{name}_n"] > 0) result["channel"] = channel_name result["id"] = id_prefix + result[dimension_id].astype(str) result["profit"] = result["profit_sum"] - result["profit_loss"] @@ -5688,14 +6999,28 @@ def channel(table_name: str, date_key: str, customer_key: str) -> Any: def _three_channel_customer_count_expression(ctx: DataFrameContext, query_id: int, mode: str) -> Any: params = get_parameters(query_id) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) col = ctx.col lit = ctx.lit date_filtered = ctx.get_table("date_dim").filter( (col("d_month_seq") >= lit(dms)) & (col("d_month_seq") <= lit(dms + 11)) ) store_customers, catalog_customers, web_customers = _customer_date_sets_expression(ctx, date_filtered) - keys = ["c_last_name", "c_first_name", "d_date"] + + # SQL INTERSECT and EXCEPT treat NULLs as equal, but a join does not match NULL keys. Key the + # names on a NULL indicator plus a filled value so NULL names compare equal to each other only. + def null_safe(frame: Any) -> Any: + return frame.with_columns( + col("c_last_name").is_null().alias("c_last_name_is_null"), + col("c_first_name").is_null().alias("c_first_name_is_null"), + col("c_last_name").fill_null(lit("")).alias("c_last_name"), + col("c_first_name").fill_null(lit("")).alias("c_first_name"), + ) + + keys = ["c_last_name", "c_first_name", "c_last_name_is_null", "c_first_name_is_null", "d_date"] + store_customers, catalog_customers, web_customers = ( + null_safe(frame) for frame in (store_customers, catalog_customers, web_customers) + ) if mode == "intersect": result = store_customers.join(catalog_customers, on=keys).join(web_customers, on=keys) else: @@ -5707,7 +7032,7 @@ def _three_channel_customer_count_pandas(ctx: DataFrameContext, query_id: int, m import pandas as pd params = get_parameters(query_id) - dms = params.get("dms", 1200) + dms = params.get("dms", 1212) date_filtered = ctx.get_table("date_dim") date_filtered = date_filtered[(date_filtered["d_month_seq"] >= dms) & (date_filtered["d_month_seq"] <= dms + 11)][ ["d_date_sk", "d_date"] @@ -5728,9 +7053,7 @@ def q10_expression_impl(ctx: DataFrameContext) -> Any: params = get_parameters(10) year = params.get("year", 2002) month = params.get("month", 2) - counties = params.get( - "counties", ["Walker County", "Richland County", "Gaines County", "Douglas County", "Dona Ana County"] - ) + counties = params.get("counties") col = ctx.col lit = ctx.lit date_filtered = ctx.get_table("date_dim").filter( @@ -5770,23 +7093,21 @@ def q10_expression_impl(ctx: DataFrameContext) -> Any: "cd_dep_college_count", "cnt6", ] - return ( + return _sort_null_largest_expression( + ctx, base.group_by(group_cols) .agg(ctx.count().alias("cnt")) .with_columns([col("cnt").alias(f"cnt{i}") for i in range(1, 7)]) - .select(select_cols) - .sort(group_cols) - .limit(100) - ) + .select(select_cols), + group_cols, + ).limit(100) def q10_pandas_impl(ctx: DataFrameContext) -> Any: params = get_parameters(10) year = params.get("year", 2002) month = params.get("month", 2) - counties = params.get( - "counties", ["Walker County", "Richland County", "Gaines County", "Douglas County", "Dona Ana County"] - ) + counties = params.get("counties") date_filtered = ctx.get_table("date_dim") date_filtered = date_filtered[ (date_filtered["d_year"] == year) & (date_filtered["d_moy"] >= month) & (date_filtered["d_moy"] <= month + 3) @@ -5845,9 +7166,19 @@ def q10_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +# The aggregate functions Q35's template can draw, as the expression method and pandas aggregation for each. +_Q35_EXPRESSION_AGGREGATES = {"sum": "sum", "min": "min", "max": "max", "avg": "mean", "stddev_samp": "std"} +_Q35_PANDAS_AGGREGATES = {"sum": "sum", "min": "min", "max": "max", "avg": "mean", "stddev_samp": "std"} + + +def _q35_aggregates(params: Any) -> tuple[str, str, str]: + return params.get("aggone", "avg"), params.get("aggtwo", "max"), params.get("aggthree", "sum") + + def q35_expression_impl(ctx: DataFrameContext) -> Any: params = get_parameters(35) year = params.get("year", 2002) + aggone, aggtwo, aggthree = (_Q35_EXPRESSION_AGGREGATES[name] for name in _q35_aggregates(params)) col = ctx.col lit = ctx.lit date_filtered = ctx.get_table("date_dim").filter((col("d_year") == lit(year)) & (col("d_qoy") < lit(4))) @@ -5871,24 +7202,41 @@ def q35_expression_impl(ctx: DataFrameContext) -> Any: .agg( [ ctx.count().alias("cnt1"), - *( - getattr(col(source), func)().alias(alias) - for source, prefix in ( - ("cd_dep_count", "dep_count"), - ("cd_dep_employed_count", "dep_employed"), - ("cd_dep_college_count", "dep_college"), - ) - for func, alias in ( - ("sum", f"sum_{prefix}"), - ("min", f"min_{prefix}"), - ("max", f"max_{prefix}"), - ("mean", f"avg_{prefix}"), - ) - ), + getattr(col("cd_dep_count"), aggone)().alias("aggone1"), + getattr(col("cd_dep_count"), aggtwo)().alias("aggtwo1"), + getattr(col("cd_dep_count"), aggthree)().alias("aggthree1"), + getattr(col("cd_dep_employed_count"), aggone)().alias("aggone2"), + getattr(col("cd_dep_employed_count"), aggtwo)().alias("aggtwo2"), + getattr(col("cd_dep_employed_count"), aggthree)().alias("aggthree2"), + getattr(col("cd_dep_college_count"), aggone)().alias("aggone3"), + getattr(col("cd_dep_college_count"), aggtwo)().alias("aggtwo3"), + getattr(col("cd_dep_college_count"), aggthree)().alias("aggthree3"), ] ) .with_columns([col("cnt1").alias("cnt2"), col("cnt1").alias("cnt3")]) - .sort(group_cols) + .select( + [ + "ca_state", + "cd_gender", + "cd_marital_status", + "cd_dep_count", + "cnt1", + "aggone1", + "aggtwo1", + "aggthree1", + "cd_dep_employed_count", + "cnt2", + "aggone2", + "aggtwo2", + "aggthree2", + "cd_dep_college_count", + "cnt3", + "aggone3", + "aggtwo3", + "aggthree3", + ] + ) + .sort(group_cols, nulls_last=True) .limit(100) ) @@ -5896,6 +7244,7 @@ def q35_expression_impl(ctx: DataFrameContext) -> Any: def q35_pandas_impl(ctx: DataFrameContext) -> Any: params = get_parameters(35) year = params.get("year", 2002) + aggone, aggtwo, aggthree = (_Q35_PANDAS_AGGREGATES[name] for name in _q35_aggregates(params)) date_filtered = ctx.get_table("date_dim") date_filtered = date_filtered[(date_filtered["d_year"] == year) & (date_filtered["d_qoy"] < 4)][["d_date_sk"]] customers = _store_and_other_channel_customers_pandas(ctx, date_filtered) @@ -5914,25 +7263,50 @@ def q35_pandas_impl(ctx: DataFrameContext) -> Any: ] agg_spec = { "cnt1": ("c_customer_sk", "count"), - **{ - alias: (source, func) - for source, prefix in ( - ("cd_dep_count", "dep_count"), - ("cd_dep_employed_count", "dep_employed"), - ("cd_dep_college_count", "dep_college"), - ) - for func, alias in ( - ("sum", f"sum_{prefix}"), - ("min", f"min_{prefix}"), - ("max", f"max_{prefix}"), - ("mean", f"avg_{prefix}"), - ) - }, + "aggone1": ("cd_dep_count", aggone), + "aggtwo1": ("cd_dep_count", aggtwo), + "aggthree1": ("cd_dep_count", aggthree), + "aggone2": ("cd_dep_employed_count", aggone), + "aggtwo2": ("cd_dep_employed_count", aggtwo), + "aggthree2": ("cd_dep_employed_count", aggthree), + "aggone3": ("cd_dep_college_count", aggone), + "aggtwo3": ("cd_dep_college_count", aggtwo), + "aggthree3": ("cd_dep_college_count", aggthree), } - result = ctx.groupby_agg(base, group_cols, agg_spec, as_index=False) + result = base.groupby(group_cols, as_index=False, dropna=False).agg(**agg_spec) + result["ca_state"] = result["ca_state"].astype(object).where(result["ca_state"].notna(), None) result["cnt2"] = result["cnt1"] result["cnt3"] = result["cnt1"] - return result.sort_values(group_cols).head(100) + # stddev_samp of a one-row group is NULL in SQL and NaN in pandas. + result = _none_for_null( + result, [f"agg{position}{number}" for position in ("one", "two", "three") for number in "123"] + ) + return ( + result[ + [ + "ca_state", + "cd_gender", + "cd_marital_status", + "cd_dep_count", + "cnt1", + "aggone1", + "aggtwo1", + "aggthree1", + "cd_dep_employed_count", + "cnt2", + "aggone2", + "aggtwo2", + "aggthree2", + "cd_dep_college_count", + "cnt3", + "aggone3", + "aggtwo3", + "aggthree3", + ] + ] + .sort_values(group_cols, na_position="last") + .head(100) + ) def q38_expression_impl(ctx: DataFrameContext) -> Any: @@ -5963,7 +7337,8 @@ def q69_expression_impl(ctx: DataFrameContext) -> Any: ) customers = _store_only_channel_customers_expression(ctx, date_filtered) group_cols = ["cd_gender", "cd_marital_status", "cd_education_status", "cd_purchase_estimate", "cd_credit_rating"] - return ( + return _sort_null_largest_expression( + ctx, ctx.get_table("customer") .join( ctx.get_table("customer_address").filter(col("ca_state").is_in(states)), @@ -5975,9 +7350,19 @@ def q69_expression_impl(ctx: DataFrameContext) -> Any: .group_by(group_cols) .agg(ctx.count().alias("cnt1")) .with_columns([col("cnt1").alias("cnt2"), col("cnt1").alias("cnt3")]) - .sort(group_cols) - .head(100) - ) + # Match the SQL projection order: key, count, key, count, ... + .select( + "cd_gender", + "cd_marital_status", + "cd_education_status", + "cnt1", + "cd_purchase_estimate", + "cnt2", + "cd_credit_rating", + "cnt3", + ), + group_cols, + ).head(100) def q69_pandas_impl(ctx: DataFrameContext) -> Any: @@ -6001,7 +7386,18 @@ def q69_pandas_impl(ctx: DataFrameContext) -> Any: result = base.groupby(group_cols, as_index=False).size().rename(columns={"size": "cnt1"}) result["cnt2"] = result["cnt1"] result["cnt3"] = result["cnt1"] - return result.sort_values(group_cols).head(100) + # Match the SQL projection order: key, count, key, count, ... + cols = [ + "cd_gender", + "cd_marital_status", + "cd_education_status", + "cnt1", + "cd_purchase_estimate", + "cnt2", + "cd_credit_rating", + "cnt3", + ] + return result[cols].sort_values(group_cols).head(100) # ============================================================================= @@ -6020,10 +7416,7 @@ def q40_expression_impl(ctx: DataFrameContext) -> Any: from datetime import datetime, timedelta params = get_parameters(40) - year = params.get("year", 2000) - # Midpoint date for before/after split - sales_date_str = f"{year}-02-01" - sales_date = datetime.strptime(sales_date_str, "%Y-%m-%d").date() + sales_date = datetime.strptime(params.get("sales_date", "1998-04-08"), "%Y-%m-%d").date() catalog_sales, catalog_returns, warehouse, item, date_dim = _tables( ctx, "catalog_sales", "catalog_returns", "warehouse", "item", "date_dim" @@ -6070,17 +7463,16 @@ def q40_expression_impl(ctx: DataFrameContext) -> Any: ) # Aggregate by warehouse state and item - return ( - result.group_by(["w_state", "i_item_id"]) - .agg( + return _sort_null_largest_expression( + ctx, + result.group_by(["w_state", "i_item_id"]).agg( [ ctx.sum("sales_before_val").alias("sales_before"), ctx.sum("sales_after_val").alias("sales_after"), ] - ) - .sort(["w_state", "i_item_id"]) - .head(100) - ) + ), + ["w_state", "i_item_id"], + ).head(100) def q40_pandas_impl(ctx: DataFrameContext) -> Any: @@ -6088,9 +7480,7 @@ def q40_pandas_impl(ctx: DataFrameContext) -> Any: from datetime import datetime, timedelta params = get_parameters(40) - year = params.get("year", 2000) - sales_date_str = f"{year}-02-01" - sales_date = datetime.strptime(sales_date_str, "%Y-%m-%d").date() + sales_date = datetime.strptime(params.get("sales_date", "1998-04-08"), "%Y-%m-%d").date() catalog_sales, catalog_returns, warehouse, item, date_dim = _tables( ctx, "catalog_sales", "catalog_returns", "warehouse", "item", "date_dim" @@ -6100,14 +7490,7 @@ def q40_pandas_impl(ctx: DataFrameContext) -> Any: start_date = sales_date - timedelta(days=30) end_date = sales_date + timedelta(days=30) - # Handle date comparison (might be datetime or date objects) - import pandas as pd - - start_date_pd = pd.Timestamp(start_date) - end_date_pd = pd.Timestamp(end_date) - sales_date_pd = pd.Timestamp(sales_date) - - date_filtered = date_dim[(date_dim["d_date"] >= start_date_pd) & (date_dim["d_date"] <= end_date_pd)][ + date_filtered = date_dim[(date_dim["d_date"] >= start_date) & (date_dim["d_date"] <= end_date)][ ["d_date_sk", "d_date"] ] @@ -6135,14 +7518,11 @@ def q40_pandas_impl(ctx: DataFrameContext) -> Any: result["cr_refunded_cash"] = result["cr_refunded_cash"].fillna(0) # Compute sales_before and sales_after - result["sales_before_val"] = result.apply( - lambda row: row["cs_sales_price"] - row["cr_refunded_cash"] if row["d_date"] < sales_date_pd else 0, - axis=1, - ) - result["sales_after_val"] = result.apply( - lambda row: row["cs_sales_price"] - row["cr_refunded_cash"] if row["d_date"] >= sales_date_pd else 0, - axis=1, - ) + # Column-wise rather than row-wise: a row-wise apply on an empty frame returns a frame, not a column. + net_sales = result["cs_sales_price"] - result["cr_refunded_cash"] + before = result["d_date"] < sales_date + result["sales_before_val"] = net_sales.where(before, 0) + result["sales_after_val"] = net_sales.where(~before, 0) # Aggregate by warehouse state and item result = result.groupby(["w_state", "i_item_id"], as_index=False).agg( @@ -6178,10 +7558,10 @@ def q16_expression_impl(ctx: DataFrameContext) -> Any: from datetime import datetime, timedelta params = get_parameters(16) - year = params.get("year", 2002) - months = params.get("months", [3]) - state = params.get("state", "TN") - county = params.get("county", "Williamson County") + year = params.get("year", 1999) + month = params.get("month", 2) + state = params.get("state", "IL") + counties = list(params.get("counties", ["Williamson County"] * 5)) catalog_sales, catalog_returns, date_dim, customer_address, call_center = _tables( ctx, "catalog_sales", "catalog_returns", "date_dim", "customer_address", "call_center" @@ -6190,7 +7570,6 @@ def q16_expression_impl(ctx: DataFrameContext) -> Any: lit = ctx.lit # Date filter: month start + 60 days - month = months[0] if months else 3 start_date = datetime(year, month, 1).date() end_date = start_date + timedelta(days=60) @@ -6200,18 +7579,18 @@ def q16_expression_impl(ctx: DataFrameContext) -> Any: ca_filtered = customer_address.filter(col("ca_state") == lit(state)) # Filter call_center by county - cc_filtered = call_center.filter(col("cc_county") == lit(county)) + cc_filtered = call_center.filter(col("cc_county").is_in(counties)) # Get orders that are NOT returned returned_orders = catalog_returns.select(["cr_order_number"]).unique() - # Find orders shipped from multiple warehouses # Platform-specific optimization: Polars is faster with n_unique, # DataFusion is faster with self-join pattern + shipped = catalog_sales.filter(col("cs_warehouse_sk").is_not_null()) platform = getattr(ctx, "platform", "polars") if platform == "datafusion": # DataFusion: self-join pattern is 5x faster - order_warehouses = catalog_sales.select(["cs_order_number", "cs_warehouse_sk"]).unique() + order_warehouses = shipped.select(["cs_order_number", "cs_warehouse_sk"]).unique() multi_warehouse_orders = ( order_warehouses.join( order_warehouses.rename({"cs_warehouse_sk": "cs_warehouse_sk_2"}), @@ -6224,7 +7603,7 @@ def q16_expression_impl(ctx: DataFrameContext) -> Any: else: # Polars/others: n_unique pattern is 7x faster multi_warehouse_orders = ( - catalog_sales.group_by("cs_order_number") + shipped.group_by("cs_order_number") .agg(col("cs_warehouse_sk").n_unique().alias("num_warehouses")) .filter(col("num_warehouses") > lit(1)) .select("cs_order_number") @@ -6232,7 +7611,7 @@ def q16_expression_impl(ctx: DataFrameContext) -> Any: # Main query: catalog_sales joined with dimensions result = ( - catalog_sales.join(date_filtered, left_on="cs_ship_date_sk", right_on="d_date_sk") + shipped.join(date_filtered, left_on="cs_ship_date_sk", right_on="d_date_sk") .join(ca_filtered, left_on="cs_ship_addr_sk", right_on="ca_address_sk") .join(cc_filtered, left_on="cs_call_center_sk", right_on="cc_call_center_sk") # Semi-join with multi-warehouse orders @@ -6241,12 +7620,21 @@ def q16_expression_impl(ctx: DataFrameContext) -> Any: .join(returned_orders, left_on="cs_order_number", right_on="cr_order_number", how="anti") ) - # Aggregate - return result.select( + # Aggregate. SQL SUM() over an empty set is NULL (not 0.0) while COUNT + # DISTINCT is 0: guard the sums with the row count. + tallied = result.select( [ col("cs_order_number").n_unique().alias("order count"), - ctx.sum("cs_ext_ship_cost").alias("total shipping cost"), - ctx.sum("cs_net_profit").alias("total net profit"), + ctx.sum("cs_ext_ship_cost").alias("ship_cost"), + ctx.sum("cs_net_profit").alias("net_profit"), + col("cs_order_number").count().alias("n"), + ] + ) + return tallied.select( + [ + col("order count"), + ctx.when(col("n") > lit(0)).then(col("ship_cost")).otherwise(lit(None)).alias("total shipping cost"), + ctx.when(col("n") > lit(0)).then(col("net_profit")).otherwise(lit(None)).alias("total net profit"), ] ) @@ -6258,10 +7646,10 @@ def q16_pandas_impl(ctx: DataFrameContext) -> Any: import pandas as pd params = get_parameters(16) - year = params.get("year", 2002) - months = params.get("months", [3]) - state = params.get("state", "TN") - county = params.get("county", "Williamson County") + year = params.get("year", 1999) + month = params.get("month", 2) + state = params.get("state", "IL") + counties = list(params.get("counties", ["Williamson County"] * 5)) catalog_sales, catalog_returns, date_dim, customer_address, call_center = _tables( ctx, "catalog_sales", "catalog_returns", "date_dim", "customer_address", "call_center" @@ -6269,7 +7657,6 @@ def q16_pandas_impl(ctx: DataFrameContext) -> Any: # Date filter # Note: Use datetime.date for comparisons as d_date column may contain date objects - month = months[0] if months else 3 start_date = datetime(year, month, 1).date() end_date = start_date + timedelta(days=60) @@ -6277,18 +7664,18 @@ def q16_pandas_impl(ctx: DataFrameContext) -> Any: # Filter dimensions ca_filtered = customer_address[customer_address["ca_state"] == state] - cc_filtered = call_center[call_center["cc_county"] == county] + cc_filtered = call_center[call_center["cc_county"].isin(counties)] # Get returned orders returned_orders = catalog_returns[["cr_order_number"]].drop_duplicates() - # Find orders with multiple warehouses (use ctx.groupby_size for Dask compatibility) - order_warehouses = catalog_sales[["cs_order_number", "cs_warehouse_sk"]].drop_duplicates() + shipped = catalog_sales[catalog_sales["cs_warehouse_sk"].notna()] + order_warehouses = shipped[["cs_order_number", "cs_warehouse_sk"]].drop_duplicates() ow_count = ctx.groupby_size(order_warehouses, "cs_order_number", name="wh_count") multi_warehouse_orders = ow_count[ow_count["wh_count"] > 1][["cs_order_number"]] # Main join - result = catalog_sales.merge(date_filtered, left_on="cs_ship_date_sk", right_on="d_date_sk") + result = shipped.merge(date_filtered, left_on="cs_ship_date_sk", right_on="d_date_sk") result = result.merge(ca_filtered[["ca_address_sk"]], left_on="cs_ship_addr_sk", right_on="ca_address_sk") result = result.merge(cc_filtered[["cc_call_center_sk"]], left_on="cs_call_center_sk", right_on="cc_call_center_sk") @@ -6301,8 +7688,11 @@ def q16_pandas_impl(ctx: DataFrameContext) -> Any: ) result = result[result["_merge"] == "left_only"] - # Aggregate + # Aggregate. SQL SUM() over an empty set is NULL (not 0.0) while COUNT + # DISTINCT is 0. order_count = result["cs_order_number"].nunique() + if len(result) == 0: + return pd.DataFrame({"order count": [order_count], "total shipping cost": [None], "total net profit": [None]}) total_shipping = result["cs_ext_ship_cost"].sum() total_profit = result["cs_net_profit"].sum() @@ -6330,7 +7720,7 @@ def q17_expression_impl(ctx: DataFrameContext) -> Any: Tables: store_sales, store_returns, catalog_sales, date_dim, store, item """ params = get_parameters(17) - year = params.get("year", 2001) + year = params.get("year", 1998) quarter = params.get("quarter", 1) store_sales, store_returns, catalog_sales, date_dim, store, item = _tables( @@ -6347,8 +7737,8 @@ def q17_expression_impl(ctx: DataFrameContext) -> Any: quarter_names_ret = [f"{year}Q{q}" for q in range(quarter, min(quarter + 3, 5))] d2 = date_dim.filter(col("d_quarter_name").is_in(quarter_names_ret)) - # Filter date_dim for catalog sales (years) - d3 = date_dim.filter(col("d_year").is_in([year, year + 1, year + 2])) + # Filter date_dim for catalog sales (same Q1-Q3 quarters as returns) + d3 = date_dim.filter(col("d_quarter_name").is_in(quarter_names_ret)) # Join store_sales with date, item, store # Select only needed columns from date_dim to avoid column conflicts in later joins @@ -6381,7 +7771,8 @@ def q17_expression_impl(ctx: DataFrameContext) -> Any: ) # Aggregate by item and state - return ( + return _sort_null_largest_expression( + ctx, result.group_by(["i_item_id", "i_item_desc", "s_state"]) .agg( [ @@ -6407,15 +7798,35 @@ def q17_expression_impl(ctx: DataFrameContext) -> Any: ), ] ) - .sort(["i_item_id", "i_item_desc", "s_state"]) - .head(100) - ) + # SQL interleaves each channel's cov after its stdev; with_columns + # appends at the end, so select the SQL column order explicitly. + .select( + [ + "i_item_id", + "i_item_desc", + "s_state", + "store_sales_quantitycount", + "store_sales_quantityave", + "store_sales_quantitystdev", + "store_sales_quantitycov", + "store_returns_quantitycount", + "store_returns_quantityave", + "store_returns_quantitystdev", + "store_returns_quantitycov", + "catalog_sales_quantitycount", + "catalog_sales_quantityave", + "catalog_sales_quantitystdev", + "catalog_sales_quantitycov", + ] + ), + ["i_item_id", "i_item_desc", "s_state"], + ).head(100) def q17_pandas_impl(ctx: DataFrameContext) -> Any: """Q17: Store sales/returns + catalog sales analysis with statistics (Pandas).""" params = get_parameters(17) - year = params.get("year", 2001) + year = params.get("year", 1998) quarter = params.get("quarter", 1) store_sales, store_returns, catalog_sales, date_dim, store, item = _tables( @@ -6430,8 +7841,8 @@ def q17_pandas_impl(ctx: DataFrameContext) -> Any: quarter_names_ret = [f"{year}Q{q}" for q in range(quarter, min(quarter + 3, 5))] d2 = date_dim[date_dim["d_quarter_name"].isin(quarter_names_ret)][["d_date_sk"]] - # Filter date_dim for catalog sales (years) - d3 = date_dim[date_dim["d_year"].isin([year, year + 1, year + 2])][["d_date_sk"]] + # Filter date_dim for catalog sales (same Q1-Q3 quarters as returns) + d3 = date_dim[date_dim["d_quarter_name"].isin(quarter_names_ret)][["d_date_sk"]] # Join store_sales with date, item, store ss_joined = store_sales.merge(d1, left_on="ss_sold_date_sk", right_on="d_date_sk") @@ -6467,29 +7878,59 @@ def q17_pandas_impl(ctx: DataFrameContext) -> Any: } ) - # Flatten column names - result.columns = [ + # Flatten column names + result.columns = [ + "i_item_id", + "i_item_desc", + "s_state", + "store_sales_quantitycount", + "store_sales_quantityave", + "store_sales_quantitystdev", + "store_returns_quantitycount", + "store_returns_quantityave", + "store_returns_quantitystdev", + "catalog_sales_quantitycount", + "catalog_sales_quantityave", + "catalog_sales_quantitystdev", + ] + + # Add coefficient of variation columns + result["store_sales_quantitycov"] = result["store_sales_quantitystdev"] / result["store_sales_quantityave"] + result["store_returns_quantitycov"] = result["store_returns_quantitystdev"] / result["store_returns_quantityave"] + result["catalog_sales_quantitycov"] = result["catalog_sales_quantitystdev"] / result["catalog_sales_quantityave"] + + # SQL interleaves each channel's cov after its stdev; select that order explicitly. + out_cols = [ "i_item_id", "i_item_desc", "s_state", "store_sales_quantitycount", "store_sales_quantityave", "store_sales_quantitystdev", + "store_sales_quantitycov", "store_returns_quantitycount", "store_returns_quantityave", "store_returns_quantitystdev", + "store_returns_quantitycov", "catalog_sales_quantitycount", "catalog_sales_quantityave", "catalog_sales_quantitystdev", + "catalog_sales_quantitycov", ] - - # Add coefficient of variation columns - result["store_sales_quantitycov"] = result["store_sales_quantitystdev"] / result["store_sales_quantityave"] - result["store_returns_quantitycov"] = result["store_returns_quantitystdev"] / result["store_returns_quantityave"] - result["catalog_sales_quantitycov"] = result["catalog_sales_quantitystdev"] / result["catalog_sales_quantityave"] - - # Sort and limit - return result.sort_values(["i_item_id", "i_item_desc", "s_state"]).head(100) + # SQL STDDEV_SAMP over a single row is NULL; pandas yields NaN. Map NaN + # back to None in object columns so NULLs compare equal (assigning None + # into float64 would coerce back to NaN). + import pandas as _pd + + result = result[out_cols].sort_values(["i_item_id", "i_item_desc", "s_state"]).head(100) + for column in result.columns: + if result[column].dtype == object: + result[column] = result[column].where(result[column].notna(), None) + elif result[column].dtype == float: + values = result[column].tolist() + if any(value != value for value in values): + result[column] = _pd.Series([None if value != value else value for value in values], dtype=object) + return result # ============================================================================= @@ -6510,9 +7951,10 @@ def q18_expression_impl(ctx: DataFrameContext) -> Any: params = get_parameters(18) year = params.get("year", 2001) - states = params.get("states", ["TX", "OH", "TX", "OR", "NM", "KY", "VA"]) + states = params.get("states", ["ND", "WI", "AL", "NC", "OK", "MS", "TN"]) cd_gender = params.get("cd_gender", "M") cd_education_status = params.get("cd_education_status", "College") + birth_months = params.get("birth_months", [9, 5, 12, 4, 1, 10]) catalog_sales, customer_demographics, customer, customer_address, date_dim, item = _tables( ctx, "catalog_sales", "customer_demographics", "customer", "customer_address", "date_dim", "item" @@ -6531,12 +7973,15 @@ def q18_expression_impl(ctx: DataFrameContext) -> Any: # Filter customer_address ca_filtered = customer_address.filter(col("ca_state").is_in(states)) + # Filter customers by birth month from the SQL template. + customer_filtered = customer.filter(col("c_birth_month").is_in(birth_months)) + # Join catalog_sales with dimensions cs_joined = ( catalog_sales.join(date_filtered, left_on="cs_sold_date_sk", right_on="d_date_sk") .join(item, left_on="cs_item_sk", right_on="i_item_sk") .join(cd1, left_on="cs_bill_cdemo_sk", right_on="cd_demo_sk") - .join(customer, left_on="cs_bill_customer_sk", right_on="c_customer_sk") + .join(customer_filtered, left_on="cs_bill_customer_sk", right_on="c_customer_sk") .join(ca_filtered, left_on="c_current_addr_sk", right_on="ca_address_sk") .join( customer_demographics.rename({"cd_demo_sk": "cd2_demo_sk", "cd_dep_count": "cd2_dep_count"}), @@ -6560,8 +8005,10 @@ def q18_expression_impl(ctx: DataFrameContext) -> Any: group_cols = ["i_item_id", "ca_country", "ca_state", "ca_county"] result = expand_rollup_expression(cs_joined, group_cols, agg_exprs, ctx) - # Sort and limit - return result.sort(["ca_country", "ca_state", "ca_county", "i_item_id"], nulls_last=True).head(100) + # The SQL projection has no GROUPING() column: drop the helper's grouping_id. + sort_cols = ["ca_country", "ca_state", "ca_county", "i_item_id"] + out_cols = [*group_cols, "agg1", "agg2", "agg3", "agg4", "agg5", "agg6", "agg7"] + return result.select(out_cols).sort(sort_cols, nulls_last=True).head(100) def q18_pandas_impl(ctx: DataFrameContext) -> Any: @@ -6570,9 +8017,10 @@ def q18_pandas_impl(ctx: DataFrameContext) -> Any: params = get_parameters(18) year = params.get("year", 2001) - states = params.get("states", ["TX", "OH", "TX", "OR", "NM", "KY", "VA"]) + states = params.get("states", ["ND", "WI", "AL", "NC", "OK", "MS", "TN"]) cd_gender = params.get("cd_gender", "M") cd_education_status = params.get("cd_education_status", "College") + birth_months = params.get("birth_months", [9, 5, 12, 4, 1, 10]) catalog_sales, customer_demographics, customer, customer_address, date_dim, item = _tables( ctx, "catalog_sales", "customer_demographics", "customer", "customer_address", "date_dim", "item" @@ -6590,12 +8038,15 @@ def q18_pandas_impl(ctx: DataFrameContext) -> Any: # Filter customer_address ca_filtered = customer_address[customer_address["ca_state"].isin(states)] + # Filter customers by birth month from the SQL template. + customer_filtered = customer[customer["c_birth_month"].isin(birth_months)] + # Join catalog_sales with dimensions cs_joined = catalog_sales.merge(date_filtered, left_on="cs_sold_date_sk", right_on="d_date_sk") cs_joined = cs_joined.merge(item[["i_item_sk", "i_item_id"]], left_on="cs_item_sk", right_on="i_item_sk") cs_joined = cs_joined.merge(cd1, left_on="cs_bill_cdemo_sk", right_on="cd_demo_sk") cs_joined = cs_joined.merge( - customer[["c_customer_sk", "c_current_cdemo_sk", "c_current_addr_sk", "c_birth_year"]], + customer_filtered[["c_customer_sk", "c_current_cdemo_sk", "c_current_addr_sk", "c_birth_year"]], left_on="cs_bill_customer_sk", right_on="c_customer_sk", ) @@ -6624,12 +8075,30 @@ def q18_pandas_impl(ctx: DataFrameContext) -> Any: } result = expand_rollup_pandas(cs_joined, ["i_item_id", "ca_country", "ca_state", "ca_county"], agg_dict, ctx) + result = _none_for_null(result, list(agg_dict)) - # Sort and limit - return result.sort_values( - ["ca_country", "ca_state", "ca_county", "i_item_id"], - na_position="last", - ).head(100) + # The SQL projection has no GROUPING() column: drop the helper's grouping_id. + out_cols = [ + "i_item_id", + "ca_country", + "ca_state", + "ca_county", + "agg1", + "agg2", + "agg3", + "agg4", + "agg5", + "agg6", + "agg7", + ] + return ( + result[out_cols] + .sort_values( + ["ca_country", "ca_state", "ca_county", "i_item_id"], + na_position="last", + ) + .head(100) + ) # ============================================================================= @@ -6637,6 +8106,16 @@ def q18_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +_Q29_AGGREGATES = {"sum": "sum", "min": "min", "max": "max", "avg": "mean", "stddev_samp": "std"} + + +def _q29_aggregate(name: str) -> str: + try: + return _Q29_AGGREGATES[name] + except KeyError: + raise ValueError(f"Q29 AGG must be one of {sorted(_Q29_AGGREGATES)}, got {name!r}") from None + + def q29_expression_impl(ctx: DataFrameContext) -> Any: """Q29: Store sales/returns + catalog sales aggregation (Polars). @@ -6647,7 +8126,8 @@ def q29_expression_impl(ctx: DataFrameContext) -> Any: """ params = get_parameters(29) year = params.get("year", 1999) - months = params.get("months", [1, 2, 3]) + month = params.get("month", 4) + agg = _q29_aggregate(params.get("agg", "sum")) store_sales, store_returns, catalog_sales, date_dim, store, item = _tables( ctx, "store_sales", "store_returns", "catalog_sales", "date_dim", "store", "item" @@ -6656,7 +8136,6 @@ def q29_expression_impl(ctx: DataFrameContext) -> Any: lit = ctx.lit # Filter date_dim for store sales - month = months[0] if months else 1 d1 = date_dim.filter((col("d_moy") == lit(month)) & (col("d_year") == lit(year))) # Filter date_dim for returns (range of months) @@ -6696,32 +8175,45 @@ def q29_expression_impl(ctx: DataFrameContext) -> Any: ) # Aggregate by item and store - return ( - result.group_by(["i_item_id", "i_item_desc", "s_store_id", "s_store_name"]) - .agg( + return _sort_null_largest_expression( + ctx, + result.group_by(["i_item_id", "i_item_desc", "s_store_id", "s_store_name"]).agg( [ - ctx.sum("ss_quantity").alias("store_sales_quantity"), - ctx.sum("sr_return_quantity").alias("store_returns_quantity"), - ctx.sum("cs_quantity").alias("catalog_sales_quantity"), + ( + ctx.when(col("ss_quantity").count() > lit(0)).then(col("ss_quantity").sum()).otherwise(lit(None)) + if agg == "sum" + else getattr(col("ss_quantity"), agg)() + ).alias("store_sales_quantity"), + ( + ctx.when(col("sr_return_quantity").count() > lit(0)) + .then(col("sr_return_quantity").sum()) + .otherwise(lit(None)) + if agg == "sum" + else getattr(col("sr_return_quantity"), agg)() + ).alias("store_returns_quantity"), + ( + ctx.when(col("cs_quantity").count() > lit(0)).then(col("cs_quantity").sum()).otherwise(lit(None)) + if agg == "sum" + else getattr(col("cs_quantity"), agg)() + ).alias("catalog_sales_quantity"), ] - ) - .sort(["i_item_id", "i_item_desc", "s_store_id", "s_store_name"]) - .head(100) - ) + ), + ["i_item_id", "i_item_desc", "s_store_id", "s_store_name"], + ).head(100) def q29_pandas_impl(ctx: DataFrameContext) -> Any: """Q29: Store sales/returns + catalog sales aggregation (Pandas).""" params = get_parameters(29) year = params.get("year", 1999) - months = params.get("months", [1, 2, 3]) + month = params.get("month", 4) + agg = _q29_aggregate(params.get("agg", "sum")) store_sales, store_returns, catalog_sales, date_dim, store, item = _tables( ctx, "store_sales", "store_returns", "catalog_sales", "date_dim", "store", "item" ) # Filter date_dim for store sales - month = months[0] if months else 1 d1 = date_dim[(date_dim["d_moy"] == month) & (date_dim["d_year"] == year)][["d_date_sk"]] # Filter date_dim for returns @@ -6760,20 +8252,17 @@ def q29_pandas_impl(ctx: DataFrameContext) -> Any: ) # Aggregate by item and store - result = result.groupby(["i_item_id", "i_item_desc", "s_store_id", "s_store_name"], as_index=False).agg( + result = _grouped_pandas_aggregates( + result, + ["i_item_id", "i_item_desc", "s_store_id", "s_store_name"], { - "ss_quantity": "sum", - "sr_return_quantity": "sum", - "cs_quantity": "sum", - } - ) - result = result.rename( - columns={ - "ss_quantity": "store_sales_quantity", - "sr_return_quantity": "store_returns_quantity", - "cs_quantity": "catalog_sales_quantity", - } + "store_sales_quantity": ("ss_quantity", agg), + "store_returns_quantity": ("sr_return_quantity", agg), + "catalog_sales_quantity": ("cs_quantity", agg), + }, + dropna=False, ) + result = _none_for_null(result, list(result.columns)) # Sort and limit return result.sort_values(["i_item_id", "i_item_desc", "s_store_id", "s_store_name"]).head(100) @@ -6795,11 +8284,11 @@ def q27_expression_impl(ctx: DataFrameContext) -> Any: from .rollup_helper import expand_rollup_expression params = get_parameters(27) - year = params.get("year", 2002) - gender = params.get("gender", "M") - marital_status = params.get("marital_status", "S") - education = params.get("education", "College") - states = params.get("states", ["TN", "TX", "FL", "CA", "NY", "PA"]) + year = params.get("year", 1998) + gender = params.get("gender", "F") + marital_status = params.get("marital_status", "W") + education = params.get("education", "Primary") + states = params.get("states", ["TN"]) store_sales, customer_demographics, date_dim, store, item = _tables( ctx, "store_sales", "customer_demographics", "date_dim", "store", "item" @@ -6840,8 +8329,13 @@ def q27_expression_impl(ctx: DataFrameContext) -> Any: group_cols = ["i_item_id", "s_state"] result = expand_rollup_expression(ss_joined, group_cols, agg_exprs, ctx) - # Sort and limit - return result.sort(["i_item_id", "s_state"], nulls_last=True).head(100) + # SQL projects GROUPING(s_state) AS g_state in third position with no + return ( + result.with_columns(ctx.when(col("grouping_id") == lit(0)).then(lit(0)).otherwise(lit(1)).alias("g_state")) + .select(["i_item_id", "s_state", "g_state", "agg1", "agg2", "agg3", "agg4"]) + .sort(["i_item_id", "s_state"], nulls_last=True) + .head(100) + ) def q27_pandas_impl(ctx: DataFrameContext) -> Any: @@ -6849,11 +8343,11 @@ def q27_pandas_impl(ctx: DataFrameContext) -> Any: from .rollup_helper import expand_rollup_pandas params = get_parameters(27) - year = params.get("year", 2002) - gender = params.get("gender", "M") - marital_status = params.get("marital_status", "S") - education = params.get("education", "College") - states = params.get("states", ["TN", "TX", "FL", "CA", "NY", "PA"]) + year = params.get("year", 1998) + gender = params.get("gender", "F") + marital_status = params.get("marital_status", "W") + education = params.get("education", "Primary") + states = params.get("states", ["TN"]) store_sales, customer_demographics, date_dim, store, item = _tables( ctx, "store_sales", "customer_demographics", "date_dim", "store", "item" @@ -6887,9 +8381,12 @@ def q27_pandas_impl(ctx: DataFrameContext) -> Any: } result = expand_rollup_pandas(ss_joined, ["i_item_id", "s_state"], agg_dict, ctx) + result = _none_for_null(result, list(agg_dict)) - # Sort and limit - return result.sort_values(["i_item_id", "s_state"], na_position="last").head(100) + # SQL projects GROUPING(s_state) AS g_state in third position with no + result["g_state"] = result["grouping_id"] & 1 + out_cols = ["i_item_id", "s_state", "g_state", "agg1", "agg2", "agg3", "agg4"] + return result[out_cols].sort_values(["i_item_id", "s_state"], na_position="last").head(100) # ============================================================================= @@ -6936,12 +8433,16 @@ def q93_expression_impl(ctx: DataFrameContext) -> Any: ) # Aggregate by customer - return ( - result.group_by("ss_customer_sk") - .agg(ctx.sum("act_sales").alias("sumsales")) - .sort(["sumsales", "ss_customer_sk"]) - .head(100) - ) + return _sort_null_largest_expression( + ctx, + result.group_by("ss_customer_sk").agg( + ctx.when(col("act_sales").count() > lit(0)) + .then(col("act_sales").sum()) + .otherwise(lit(None)) + .alias("sumsales") + ), + ["sumsales", "ss_customer_sk"], + ).head(100) def q93_pandas_impl(ctx: DataFrameContext) -> Any: @@ -6957,7 +8458,9 @@ def q93_pandas_impl(ctx: DataFrameContext) -> Any: # Left join store_sales with store_returns ss_with_sr = store_sales.merge( - store_returns[["sr_item_sk", "sr_ticket_number", "sr_return_quantity", "sr_reason_sk"]], + store_returns[["sr_item_sk", "sr_ticket_number", "sr_return_quantity", "sr_reason_sk"]].dropna( + subset=["sr_item_sk", "sr_ticket_number"] + ), left_on=["ss_item_sk", "ss_ticket_number"], right_on=["sr_item_sk", "sr_ticket_number"], how="left", @@ -6980,8 +8483,10 @@ def q93_pandas_impl(ctx: DataFrameContext) -> Any: ) # Aggregate by customer - result = ss_with_reason.groupby("ss_customer_sk", as_index=False).agg({"act_sales": "sum"}) - result = result.rename(columns={"act_sales": "sumsales"}) + result = _grouped_pandas_aggregates( + ss_with_reason, "ss_customer_sk", {"sumsales": ("act_sales", "sum")}, dropna=False + ) + result = _none_for_null(result, list(result.columns)) # Sort and limit return result.sort_values(["sumsales", "ss_customer_sk"]).head(100) @@ -7004,7 +8509,7 @@ def q80_expression_impl(ctx: DataFrameContext) -> Any: """ col = ctx.col lit = ctx.lit - date_filtered = _date_window_expression(ctx, 80, "2000-08-23") + date_filtered = _date_window_expression(ctx, 80, "1998-08-04") item_filtered = ctx.get_table("item").filter(col("i_current_price") > lit(50)) promo_filtered = ctx.get_table("promotion").filter(col("p_channel_tv") == lit("N")) combined = ctx.concat( @@ -7018,7 +8523,7 @@ def q80_expression_impl(ctx: DataFrameContext) -> Any: def q80_pandas_impl(ctx: DataFrameContext) -> Any: """Q80: Three-channel sales-returns with ROLLUP aggregation (Pandas).""" - date_filtered = _date_window_pandas(ctx, 80, "2000-08-23") + date_filtered = _date_window_pandas(ctx, 80, "1998-08-04") item, promotion = _tables(ctx, "item", "promotion") item_filtered = item[item["i_current_price"] > 50][["i_item_sk"]] promo_filtered = promotion[promotion["p_channel_tv"] == "N"][["p_promo_sk"]] @@ -7042,7 +8547,7 @@ def q77_expression_impl(ctx: DataFrameContext) -> Any: Tables: store_sales, store_returns, catalog_sales, catalog_returns, web_sales, web_returns, date_dim, store, web_page """ - date_filtered = _date_window_expression(ctx, 77, "2000-08-23") + date_filtered = _date_window_expression(ctx, 77, "1998-08-04") combined = ctx.concat( [ _q77_expression_channel(ctx, date_filtered, channel, sales, returns, how) @@ -7054,7 +8559,7 @@ def q77_expression_impl(ctx: DataFrameContext) -> Any: def q77_pandas_impl(ctx: DataFrameContext) -> Any: """Q77: Three-channel sales-returns with separate CTEs and ROLLUP (Pandas).""" - date_filtered = _date_window_pandas(ctx, 77, "2000-08-23") + date_filtered = _date_window_pandas(ctx, 77, "1998-08-04") combined = ctx.concat( [ _q77_pandas_channel(ctx, date_filtered, channel, sales, returns, how) @@ -7135,7 +8640,7 @@ def channel(table: str, item_key: str, date_key: str, value_col: str, alias: str def q58_pandas_impl(ctx: DataFrameContext) -> Any: """Q58: Three-channel item sales by week with balance check (Pandas).""" params = get_parameters(58) - sales_date = params.get("sales_date", "2000-01-03") + sales_date = datetime.strptime(params.get("sales_date", "2000-01-03"), "%Y-%m-%d").date() item, date_dim = _tables(ctx, "item", "date_dim") @@ -7250,21 +8755,21 @@ def q54_expression_impl(ctx: DataFrameContext) -> Any: .join(store_sales, left_on="c_customer_sk", right_on="ss_customer_sk") .join(following_dates, left_on="ss_sold_date_sk", right_on="d_date_sk") .group_by("c_customer_sk") - .agg(col("ss_ext_sales_price").sum().alias("revenue")) + .agg(_sum_or_null_expression(ctx, col("ss_ext_sales_price")).alias("revenue")) ) # Create segments (revenue / 50) - segments = my_revenue.with_columns((col("revenue") / lit(50)).cast_int64().alias("segment")) + segments = my_revenue.with_columns((col("revenue") / lit(50)).round(0).cast_int64().alias("segment")) # Aggregate by segment - return ( + return _sort_null_largest_expression( + ctx, segments.group_by("segment") .agg(ctx.len().alias("num_customers")) .with_columns((col("segment") * lit(50)).alias("segment_base")) - .select(["segment", "num_customers", "segment_base"]) - .sort(["segment", "num_customers"]) - .limit(100) - ) + .select(["segment", "num_customers", "segment_base"]), + ["segment", "num_customers"], + ).limit(100) def q54_pandas_impl(ctx: DataFrameContext) -> Any: @@ -7326,17 +8831,20 @@ def q54_pandas_impl(ctx: DataFrameContext) -> Any: ) my_revenue = my_revenue.merge(following_dates, left_on="ss_sold_date_sk", right_on="d_date_sk") - revenue_agg = my_revenue.groupby("c_customer_sk", as_index=False).agg(revenue=("ss_ext_sales_price", "sum")) + revenue_agg = _grouped_pandas_aggregates(my_revenue, "c_customer_sk", {"revenue": ("ss_ext_sales_price", "sum")}) # Create segments (revenue / 50) - revenue_agg["segment"] = (revenue_agg["revenue"] / 50).astype(int) + revenue_agg["segment"] = (revenue_agg["revenue"] / 50).round().astype("Int64") # Aggregate by segment - result = revenue_agg.groupby("segment", as_index=False).agg(num_customers=("segment", "count")) + result = _grouped_pandas_aggregates( + revenue_agg, "segment", {"num_customers": ("c_customer_sk", "count")}, dropna=False + ) result["segment_base"] = result["segment"] * 50 result = result[["segment", "num_customers", "segment_base"]] - return result.sort_values(["segment", "num_customers"]).head(100) + result = result.sort_values(["segment", "num_customers"]).head(100) + return _none_for_null(result, list(result.columns)) # ============================================================================= @@ -7465,7 +8973,7 @@ def q44_pandas_impl(ctx: DataFrameContext) -> Any: result = result[["rnk_asc", "best_performing", "worst_performing"]].copy() result = result.rename(columns={"rnk_asc": "rnk"}) - return result.sort_values("rnk") + return _none_for_null(result.sort_values("rnk"), ["best_performing", "worst_performing"]) # ============================================================================= @@ -7486,14 +8994,13 @@ def q44_pandas_impl(ctx: DataFrameContext) -> Any: def _q59_day_sales_aggs(ctx: DataFrameContext) -> list[Any]: col, lit = ctx.col, ctx.lit - return [ - ctx.when(col("d_day_name") == lit(day)) - .then(col("ss_sales_price")) - .otherwise(None) - .sum() - .alias(f"{prefix}_sales") - for prefix, day in _Q59_DAYS - ] + aggregations = [] + for prefix, day in _Q59_DAYS: + day_sales = ctx.when(col("d_day_name") == lit(day)).then(col("ss_sales_price")).otherwise(lit(None)) + aggregations.append( + ctx.when(day_sales.count() > lit(0)).then(day_sales.sum()).otherwise(lit(None)).alias(f"{prefix}_sales") + ) + return aggregations def _q59_rename_map(suffix: str, *, include_name: bool) -> dict[str, str]: @@ -7526,7 +9033,7 @@ def _q59_period_expression( ) return ( wss.join(store, left_on="ss_store_sk", right_on="s_store_sk") - .join(date_filtered.select(["d_week_seq"]).unique(), on="d_week_seq") + .join(date_filtered, on="d_week_seq") .select(_q59_select_exprs(ctx, suffix, include_name=suffix == "1")) ) @@ -7556,17 +9063,17 @@ def q59_expression_impl(ctx: DataFrameContext) -> Any: x = _q59_period_expression(ctx, wss, store, date_dim, dms, 12, "2") # Join on store_id and week_seq offset by 52 - return ( + return _sort_null_largest_expression( + ctx, y.join( x, left_on=["s_store_id1", (col("d_week_seq1") + lit(52))], right_on=["s_store_id2", "d_week_seq2"], ) .with_columns(_q59_ratio_exprs(ctx)) - .select(_Q59_RESULT_COLS) - .sort(["s_store_name1", "s_store_id1", "d_week_seq1"]) - .head(100) - ) + .select(_Q59_RESULT_COLS), + ["s_store_name1", "s_store_id1", "d_week_seq1"], + ).head(100) def q59_pandas_impl(ctx: DataFrameContext) -> Any: @@ -7585,22 +9092,20 @@ def q59_pandas_impl(ctx: DataFrameContext) -> Any: # Aggregate by week_seq and store wss = ss_with_date.groupby(["d_week_seq", "ss_store_sk"], as_index=False).agg( - {f"{prefix}_sales": "sum" for prefix, _ in _Q59_DAYS} + {f"{prefix}_sales": (lambda values: values.sum(min_count=1)) for prefix, _ in _Q59_DAYS} ) # Join with store wss = wss.merge(store, left_on="ss_store_sk", right_on="s_store_sk") # Current year filter - weeks_1 = date_dim[(date_dim["d_month_seq"] >= dms) & (date_dim["d_month_seq"] <= dms + 11)]["d_week_seq"].unique() - y = wss[wss["d_week_seq"].isin(weeks_1)].copy() + dates_1 = date_dim[(date_dim["d_month_seq"] >= dms) & (date_dim["d_month_seq"] <= dms + 11)][["d_week_seq"]] + y = wss.merge(dates_1, on="d_week_seq", how="inner").copy() y = y.rename(columns=_q59_rename_map("1", include_name=True)) # Prior year filter - weeks_2 = date_dim[(date_dim["d_month_seq"] >= dms + 12) & (date_dim["d_month_seq"] <= dms + 23)][ - "d_week_seq" - ].unique() - x = wss[wss["d_week_seq"].isin(weeks_2)].copy() + dates_2 = date_dim[(date_dim["d_month_seq"] >= dms + 12) & (date_dim["d_month_seq"] <= dms + 23)][["d_week_seq"]] + x = wss.merge(dates_2, on="d_week_seq", how="inner").copy() x = x.rename(columns=_q59_rename_map("2", include_name=False)) # Prepare join key @@ -7618,7 +9123,12 @@ def q59_pandas_impl(ctx: DataFrameContext) -> Any: result[f"{prefix}_ratio"] = result[f"{prefix}_sales1"] / result[f"{prefix}_sales2"] result = result[_Q59_RESULT_COLS] - return result.sort_values(["s_store_name1", "s_store_id1", "d_week_seq1"]).head(100) + return ( + result.sort_values(["s_store_name1", "s_store_id1", "d_week_seq1"]) + .head(100) + .astype(object) + .where(lambda frame: frame.notna(), None) + ) # ============================================================================= @@ -7667,11 +9177,20 @@ def q61_expression_impl(ctx: DataFrameContext) -> Any: ) # Total sales - total = base.select(ctx.sum("ss_ext_sales_price").alias("total")) + total = base.select( + ctx.when(col("ss_ext_sales_price").count() > lit(0)) + .then(col("ss_ext_sales_price").sum()) + .otherwise(lit(None)) + .alias("total") + ) # Promotional sales - promo = base.join(promo_filtered, left_on="ss_promo_sk", right_on="p_promo_sk").select( - ctx.sum("ss_ext_sales_price").alias("promotions") + promo_base = base.join(promo_filtered, left_on="ss_promo_sk", right_on="p_promo_sk") + promo = promo_base.select( + ctx.when(col("ss_ext_sales_price").count() > lit(0)) + .then(col("ss_ext_sales_price").sum()) + .otherwise(lit(None)) + .alias("promotions") ) # Combine @@ -7713,20 +9232,26 @@ def q61_pandas_impl(ctx: DataFrameContext) -> Any: base = base.merge(item_filtered[["i_item_sk"]], left_on="ss_item_sk", right_on="i_item_sk") # Total sales - total = base["ss_ext_sales_price"].sum() + total = base["ss_ext_sales_price"].sum(min_count=1) # Promotional sales promo_base = base.merge(promo_filtered[["p_promo_sk"]], left_on="ss_promo_sk", right_on="p_promo_sk") - promotions = promo_base["ss_ext_sales_price"].sum() + promotions = promo_base["ss_ext_sales_price"].sum(min_count=1) import pandas as pd - return pd.DataFrame( - { - "promotions": [promotions], - "total": [total], - "promo_pct": [promotions / total * 100 if total else 0], - } + return ( + pd.DataFrame( + { + "promotions": [promotions], + "total": [total], + "promo_pct": [ + promotions / total * 100 if promotions == promotions and total == total and total != 0 else None + ], + } + ) + .astype(object) + .where(lambda frame: frame.notna(), None) ) @@ -7735,6 +9260,21 @@ def q61_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +_Q85_PRICE_BANDS = ((100.00, 150.00), (50.00, 100.00), (150.00, 200.00)) +_Q85_PROFIT_BANDS = ((100, 200), (150, 300), (50, 250)) + + +def _q85_groups(params: Any) -> tuple[list[str], list[str], list[list[str]]]: + marital = list(params.get("marital_statuses", ["M", "D", "U"])) + education = list(params.get("education_statuses", ["4 yr Degree", "Primary", "Advanced Degree"])) + states = list(params.get("states", ["KY", "GA", "NM", "MT", "OR", "IN", "WI", "MO", "WV"])) + if len(marital) != 3 or len(education) != 3 or len(states) != 9: + raise ValueError( + f"Q85 needs 3 marital statuses, 3 education statuses and 9 states, got {marital!r}, {education!r}, {states!r}" + ) + return marital, education, [states[0:3], states[3:6], states[6:9]] + + def q85_expression_impl(ctx: DataFrameContext) -> Any: """Q85: Web sales returns with demographics analysis (Polars). @@ -7746,6 +9286,7 @@ def q85_expression_impl(ctx: DataFrameContext) -> Any: """ params = get_parameters(85) year = params.get("year", 1998) + marital, education, states = _q85_groups(params) col = ctx.col lit = ctx.lit @@ -7786,20 +9327,27 @@ def q85_expression_impl(ctx: DataFrameContext) -> Any: # Join with reason ws_wr = ws_wr.join(reason, left_on="wr_reason_sk", right_on="r_reason_sk") - # Complex filter conditions - simplified for implementation - # Original has OR conditions on (marital_status, education, sales_price) - # and OR conditions on (states, net_profit) - # We'll implement a representative version - demo_filter = (col("cd1_cd_marital_status") == col("cd2_cd_marital_status")) & ( - col("cd1_cd_education_status") == col("cd2_cd_education_status") - ) - - country_filter = col("ca_country") == lit("United States") + demo_filter = None + for status, degree, (low, high) in zip(marital, education, _Q85_PRICE_BANDS): + group = ( + (col("cd1_cd_marital_status") == lit(status)) + & (col("cd1_cd_marital_status") == col("cd2_cd_marital_status")) + & (col("cd1_cd_education_status") == lit(degree)) + & (col("cd1_cd_education_status") == col("cd2_cd_education_status")) + & col("ws_sales_price").is_between(low, high) + ) + demo_filter = group if demo_filter is None else demo_filter | group + state_filter = None + for group_states, (low, high) in zip(states, _Q85_PROFIT_BANDS): + group = col("ca_state").is_in(group_states) & col("ws_net_profit").is_between(low, high) + state_filter = group if state_filter is None else state_filter | group + country_filter = (col("ca_country") == lit("United States")) & state_filter filtered = ws_wr.filter(demo_filter & country_filter) # Aggregate by reason - return ( + return _sort_null_largest_expression( + ctx, filtered.group_by("r_reason_desc") .agg( [ @@ -7809,15 +9357,16 @@ def q85_expression_impl(ctx: DataFrameContext) -> Any: ] ) .with_columns(col("r_reason_desc").str.slice(0, 20).alias("reason_desc_short")) - .sort(["reason_desc_short", "avg_quantity", "avg_refunded_cash", "avg_fee"]) - .head(100) - ) + .select(["reason_desc_short", "avg_quantity", "avg_refunded_cash", "avg_fee"]), + ["reason_desc_short", "avg_quantity", "avg_refunded_cash", "avg_fee"], + ).head(100) def q85_pandas_impl(ctx: DataFrameContext) -> Any: """Q85: Web sales returns with demographics analysis (Pandas).""" params = get_parameters(85) year = params.get("year", 1998) + marital, education, states = _q85_groups(params) web_sales, web_returns, web_page, customer_demographics, customer_address, date_dim, reason = _tables( ctx, "web_sales", "web_returns", "web_page", "customer_demographics", "customer_address", "date_dim", "reason" @@ -7855,12 +9404,22 @@ def q85_pandas_impl(ctx: DataFrameContext) -> Any: # Join with reason ws_wr = ws_wr.merge(reason, left_on="wr_reason_sk", right_on="r_reason_sk") - # Filter - filtered = ws_wr[ - (ws_wr["cd1_cd_marital_status"] == ws_wr["cd2_cd_marital_status"]) - & (ws_wr["cd1_cd_education_status"] == ws_wr["cd2_cd_education_status"]) - & (ws_wr["ca_country"] == "United States") - ] + demo_filter = None + for status, degree, (low, high) in zip(marital, education, _Q85_PRICE_BANDS): + group = ( + (ws_wr["cd1_cd_marital_status"] == status) + & (ws_wr["cd1_cd_marital_status"] == ws_wr["cd2_cd_marital_status"]) + & (ws_wr["cd1_cd_education_status"] == degree) + & (ws_wr["cd1_cd_education_status"] == ws_wr["cd2_cd_education_status"]) + & ws_wr["ws_sales_price"].between(low, high) + ) + demo_filter = group if demo_filter is None else demo_filter | group + state_filter = None + for group_states, (low, high) in zip(states, _Q85_PROFIT_BANDS): + group = ws_wr["ca_state"].isin(group_states) & ws_wr["ws_net_profit"].between(low, high) + state_filter = group if state_filter is None else state_filter | group + country_filter = (ws_wr["ca_country"] == "United States") & state_filter + filtered = ws_wr[demo_filter & country_filter] # Aggregate by reason result = filtered.groupby("r_reason_desc", as_index=False).agg( @@ -7878,7 +9437,11 @@ def q85_pandas_impl(ctx: DataFrameContext) -> Any: } ) result["reason_desc_short"] = result["r_reason_desc"].str[:20] - return result.sort_values(["reason_desc_short", "avg_quantity", "avg_refunded_cash", "avg_fee"]).head(100) + return ( + result[["reason_desc_short", "avg_quantity", "avg_refunded_cash", "avg_fee"]] + .sort_values(["reason_desc_short", "avg_quantity", "avg_refunded_cash", "avg_fee"]) + .head(100) + ) # ============================================================================= @@ -7886,6 +9449,44 @@ def q85_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +# The Q66 template draws the sales and net columns of each channel at random (SALESONE, SALESTWO, +# NETONE, NETTWO), so the DataFrame implementations take them as parameters. +_Q66_SALES_COLUMNS = { + "web": ("ws_sales_price", "ws_ext_sales_price", "ws_ext_list_price"), + "catalog": ("cs_sales_price", "cs_ext_sales_price", "cs_ext_list_price"), +} +_Q66_NET_COLUMNS = { + "web": ("ws_net_paid", "ws_net_paid_inc_tax", "ws_net_paid_inc_ship", "ws_net_paid_inc_ship_tax", "ws_net_profit"), + "catalog": ( + "cs_net_paid", + "cs_net_paid_inc_tax", + "cs_net_paid_inc_ship", + "cs_net_paid_inc_ship_tax", + "cs_net_profit", + ), +} + + +def _q66_value_columns(params: Any) -> dict[str, tuple[str, str]]: + """The (sales, net) column of each channel, checked against the columns the template can choose.""" + chosen = { + "web": ( + params.get("web_sales_col", "ws_sales_price"), + params.get("web_net_col", "ws_net_paid_inc_tax"), + ), + "catalog": ( + params.get("catalog_sales_col", "cs_sales_price"), + params.get("catalog_net_col", "cs_net_paid_inc_ship_tax"), + ), + } + for channel, (sales_col, net_col) in chosen.items(): + if sales_col not in _Q66_SALES_COLUMNS[channel]: + raise ValueError(f"Q66 {channel} sales column {sales_col!r} is not one of {_Q66_SALES_COLUMNS[channel]}") + if net_col not in _Q66_NET_COLUMNS[channel]: + raise ValueError(f"Q66 {channel} net column {net_col!r} is not one of {_Q66_NET_COLUMNS[channel]}") + return chosen + + def q66_expression_impl(ctx: DataFrameContext) -> Any: """Q66: Web/catalog sales monthly warehouse analysis (Polars). @@ -7895,9 +9496,10 @@ def q66_expression_impl(ctx: DataFrameContext) -> Any: Tables: web_sales, catalog_sales, warehouse, date_dim, time_dim, ship_mode """ params = get_parameters(66) - year = params.get("year", 2001) + year = params.get("year", 2002) ship_carriers = params.get("ship_carriers", ["DIAMOND", "AIRBORNE"]) - time_start = params.get("time_start", 30838) + time_start = params.get("time_start", 49530) + value_columns = _q66_value_columns(params) col = ctx.col lit = ctx.lit @@ -7945,7 +9547,8 @@ def build_monthly_aggs(sales_col: str, net_col: str, qty_col: str): ws_agg = ( ws.group_by(["w_warehouse_name", "w_warehouse_sq_ft", "w_city", "w_county", "w_state", "w_country", "d_year"]) - .agg(build_monthly_aggs("ws_ext_sales_price", "ws_net_profit", "ws_quantity")) + # SQL multiplies the chosen sales column by quantity, whichever column the template drew. + .agg(build_monthly_aggs(*value_columns["web"], "ws_quantity")) .with_columns( [ lit(carriers_str).alias("ship_carriers"), @@ -7964,7 +9567,7 @@ def build_monthly_aggs(sales_col: str, net_col: str, qty_col: str): cs_agg = ( cs.group_by(["w_warehouse_name", "w_warehouse_sq_ft", "w_city", "w_county", "w_state", "w_country", "d_year"]) - .agg(build_monthly_aggs("cs_ext_sales_price", "cs_net_profit", "cs_quantity")) + .agg(build_monthly_aggs(*value_columns["catalog"], "cs_quantity")) .with_columns( [ lit(carriers_str).alias("ship_carriers"), @@ -7992,17 +9595,32 @@ def build_monthly_aggs(sales_col: str, net_col: str, qty_col: str): agg_exprs = [] for mname in month_names: agg_exprs.append(ctx.sum(f"{mname}_sales").alias(f"{mname}_sales")) + for mname in month_names: agg_exprs.append(ctx.sum(f"{mname}_net").alias(f"{mname}_net")) - return combined.group_by(group_cols).agg(agg_exprs).sort("w_warehouse_name").head(100) + grouped = combined.group_by(group_cols).agg(agg_exprs) + # SQL computes the per-square-foot columns as SUM(monthly / sq_ft); sq_ft is + # constant per warehouse group, so divide each group's summed monthlies by its + # own sq_ft. (first() here would take the first group's value for every row.) + per_foot = [ + (col(f"{mname}_sales") / col("w_warehouse_sq_ft")).alias(f"{mname}_sales_per_sq_foot") for mname in month_names + ] + ordered = ( + group_cols + + [f"{m}_sales" for m in month_names] + + [f"{m}_sales_per_sq_foot" for m in month_names] + + [f"{m}_net" for m in month_names] + ) + return grouped.with_columns(per_foot).select(ordered).sort("w_warehouse_name", nulls_last=True).head(100) def q66_pandas_impl(ctx: DataFrameContext) -> Any: """Q66: Web/catalog sales monthly warehouse analysis (Pandas).""" params = get_parameters(66) - year = params.get("year", 2001) + year = params.get("year", 2002) ship_carriers = params.get("ship_carriers", ["DIAMOND", "AIRBORNE"]) - time_start = params.get("time_start", 30838) + time_start = params.get("time_start", 49530) + value_columns = _q66_value_columns(params) web_sales, catalog_sales, warehouse, date_dim, time_dim, ship_mode = _tables( ctx, "web_sales", "catalog_sales", "warehouse", "date_dim", "time_dim", "ship_mode" @@ -8036,20 +9654,19 @@ def process_channel(sales_df, wh_col, date_col, time_col, sm_col, sales_col, net agg_dict = {f"{m}_sales": "sum" for m in month_names} agg_dict.update({f"{m}_net": "sum" for m in month_names}) - result = df.groupby(group_cols, as_index=False).agg(agg_dict) + result = df.groupby(group_cols, as_index=False, dropna=False).agg(agg_dict) result["ship_carriers"] = carriers_str result["year"] = result["d_year"] return result - # Process web sales + # Process web sales (the chosen sales and net columns are each multiplied by quantity) ws_agg = process_channel( web_sales, "ws_warehouse_sk", "ws_sold_date_sk", "ws_sold_time_sk", "ws_ship_mode_sk", - "ws_ext_sales_price", - "ws_net_profit", + *value_columns["web"], "ws_quantity", ) @@ -8060,8 +9677,7 @@ def process_channel(sales_df, wh_col, date_col, time_col, sm_col, sales_col, net "cs_sold_date_sk", "cs_sold_time_sk", "cs_ship_mode_sk", - "cs_ext_sales_price", - "cs_net_profit", + *value_columns["catalog"], "cs_quantity", ) @@ -8082,8 +9698,22 @@ def process_channel(sales_df, wh_col, date_col, time_col, sm_col, sales_col, net agg_dict = {f"{m}_sales": "sum" for m in month_names} agg_dict.update({f"{m}_net": "sum" for m in month_names}) - result = combined.groupby(group_cols, as_index=False).agg(agg_dict) - return result.sort_values("w_warehouse_name").head(100) + result = combined.groupby(group_cols, as_index=False, dropna=False).agg(agg_dict) + # SQL computes the per-square-foot columns as SUM(monthly / sq_ft); sq_ft is + # constant per warehouse group, so divide the summed monthlies. + for mname in month_names: + result[f"{mname}_sales_per_sq_foot"] = result[f"{mname}_sales"] / result["w_warehouse_sq_ft"] + out_cols = ( + group_cols + + [f"{m}_sales" for m in month_names] + + [f"{m}_sales_per_sq_foot" for m in month_names] + + [f"{m}_net" for m in month_names] + ) + result = result[out_cols].sort_values("w_warehouse_name", na_position="last").head(100) + # A NULL warehouse name or square footage is NULL in SQL (and so are the per-square-foot columns). + return _none_for_null( + result, ["w_warehouse_name", "w_warehouse_sq_ft"] + [f"{m}_sales_per_sq_foot" for m in month_names] + ) # ============================================================================= @@ -8095,8 +9725,8 @@ def q8_expression_impl(ctx: DataFrameContext) -> Any: """Q8: Store sales net profit analysis with zip code filtering (Polars).""" params = get_parameters(8) year = params.get("year", 1998) - qoy = params.get("qoy", 2) - zip_codes = params.get("zip_codes", ["24128", "76232", "65084", "87816", "83926"]) + qoy = params.get("qoy") + zip_codes = params.get("zip_codes") # Get tables store_sales, date_dim, store, customer_address, customer = _tables( @@ -8144,20 +9774,17 @@ def q8_expression_impl(ctx: DataFrameContext) -> Any: result = result.join(valid_zips_2char, left_on="store_zip2", right_on="zip2") # Aggregate - return ( - result.group_by("s_store_name") - .agg(ctx.sum("ss_net_profit").alias("sum_net_profit")) - .sort("s_store_name") - .head(100) - ) + return _sort_null_largest_expression( + ctx, result.group_by("s_store_name").agg(ctx.sum("ss_net_profit").alias("sum_net_profit")), ["s_store_name"] + ).head(100) def q8_pandas_impl(ctx: DataFrameContext) -> Any: """Q8: Store sales net profit analysis with zip code filtering (Pandas).""" params = get_parameters(8) year = params.get("year", 1998) - qoy = params.get("qoy", 2) - zip_codes = params.get("zip_codes", ["24128", "76232", "65084", "87816", "83926"]) + qoy = params.get("qoy") + zip_codes = params.get("zip_codes") # Get tables store_sales, date_dim, store, customer_address, customer = _tables( @@ -8213,7 +9840,9 @@ def q9_expression_impl(ctx: DataFrameContext) -> Any: params = get_parameters(9) quantity_ranges = params.get("quantity_ranges", [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100)]) # Row count thresholds for CASE-WHEN (simplified for DataFrame) - thresholds = params.get("thresholds", [1000, 1000, 1000, 1000, 1000]) + thresholds = params.get("thresholds", [25437, 22746, 9387, 10098, 18213]) + agg_then = params.get("agg_then", "ss_ext_discount_amt") + agg_else = params.get("agg_else", "ss_net_profit") store_sales, reason = _tables(ctx, "store_sales", "reason") @@ -8231,11 +9860,9 @@ def q9_expression_impl(ctx: DataFrameContext) -> Any: threshold = thresholds[i] if i < len(thresholds) else 1000 if cnt > threshold: - # Return avg of ss_ext_discount_amt - avg_val = bucket.select(ctx.mean("ss_ext_discount_amt").alias("avg_val")) + avg_val = bucket.select(ctx.mean(agg_then).alias("avg_val")) else: - # Return avg of ss_net_paid - avg_val = bucket.select(ctx.mean("ss_net_paid").alias("avg_val")) + avg_val = bucket.select(ctx.mean(agg_else).alias("avg_val")) if hasattr(avg_val, "scalar"): val = avg_val.scalar(0, 0) @@ -8260,7 +9887,9 @@ def q9_pandas_impl(ctx: DataFrameContext) -> Any: params = get_parameters(9) quantity_ranges = params.get("quantity_ranges", [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100)]) - thresholds = params.get("thresholds", [1000, 1000, 1000, 1000, 1000]) + thresholds = params.get("thresholds", [25437, 22746, 9387, 10098, 18213]) + agg_then = params.get("agg_then", "ss_ext_discount_amt") + agg_else = params.get("agg_else", "ss_net_profit") store_sales = ctx.get_table("store_sales") @@ -8272,9 +9901,9 @@ def q9_pandas_impl(ctx: DataFrameContext) -> Any: threshold = thresholds[i] if i < len(thresholds) else 1000 - avg_val = bucket["ss_ext_discount_amt"].mean() if cnt > threshold else bucket["ss_net_paid"].mean() + column = bucket[agg_then if cnt > threshold else agg_else] - buckets.append(avg_val) + buckets.append(column.mean() if column.count() else None) # Create single row result (matching reason table with r_reason_sk = 1) return pd.DataFrame( @@ -8293,13 +9922,26 @@ def q9_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +_Q28_QUANTITY_RANGES = ((0, 5), (6, 10), (11, 15), (16, 20), (21, 25), (26, 30)) + + +def _q28_buckets(params: Any) -> list[tuple[int, int, int, int, int]]: + list_prices = params.get("list_prices", [11, 91, 66, 142, 135, 28]) + coupon_amts = params.get("coupon_amts", [460, 1430, 920, 3054, 14180, 2513]) + wholesale_costs = params.get("wholesale_costs", [14, 32, 4, 80, 38, 42]) + buckets = len(_Q28_QUANTITY_RANGES) + if not len(list_prices) == len(coupon_amts) == len(wholesale_costs) == buckets: + raise ValueError(f"Q28 needs {buckets} values for each of its three parameters") + return [ + (q_min, q_max, int(lp), int(ca), int(wc)) + for (q_min, q_max), lp, ca, wc in zip(_Q28_QUANTITY_RANGES, list_prices, coupon_amts, wholesale_costs) + ] + + def q28_expression_impl(ctx: DataFrameContext) -> Any: """Q28: Extended price analysis across 6 quantity buckets (Polars).""" params = get_parameters(28) - quantity_ranges = params.get("quantity_ranges", [(0, 5), (6, 10), (11, 15), (16, 20), (21, 25), (26, 30)]) - list_prices = params.get("list_prices", [90, 91, 92, 93, 94, 95]) - coupon_amts = params.get("coupon_amts", [1000, 2000, 3000, 4000, 5000, 6000]) - wholesale_costs = params.get("wholesale_costs", [10, 20, 30, 40, 50, 60]) + buckets = _q28_buckets(params) store_sales = ctx.get_table("store_sales") col = ctx.col @@ -8307,11 +9949,7 @@ def q28_expression_impl(ctx: DataFrameContext) -> Any: # Compute stats for each bucket bucket_results = [] - for i, (q_min, q_max) in enumerate(quantity_ranges): - lp = list_prices[i] if i < len(list_prices) else 90 - ca = coupon_amts[i] if i < len(coupon_amts) else 1000 - wc = wholesale_costs[i] if i < len(wholesale_costs) else 10 - + for i, (q_min, q_max, lp, ca, wc) in enumerate(buckets): bucket = store_sales.filter( (col("ss_quantity") >= lit(q_min)) & (col("ss_quantity") <= lit(q_max)) @@ -8324,8 +9962,10 @@ def q28_expression_impl(ctx: DataFrameContext) -> Any: stats = bucket.select( ctx.mean("ss_list_price").alias(f"B{i + 1}_LP"), - ctx.len().alias(f"B{i + 1}_CNT"), - col("ss_list_price").n_unique().alias(f"B{i + 1}_CNTD"), + # SQL counts non-null ss_list_price values, not rows. + col("ss_list_price").count().alias(f"B{i + 1}_CNT"), + # SQL COUNT(DISTINCT col) excludes NULL; mask nulls first. + col("ss_list_price").filter(col("ss_list_price").is_not_null()).n_unique().alias(f"B{i + 1}_CNTD"), ) bucket_results.append(stats) @@ -8342,21 +9982,13 @@ def q28_pandas_impl(ctx: DataFrameContext) -> Any: """Q28: Extended price analysis across 6 quantity buckets (Pandas).""" import pandas as pd - params = get_parameters(28) - quantity_ranges = params.get("quantity_ranges", [(0, 5), (6, 10), (11, 15), (16, 20), (21, 25), (26, 30)]) - list_prices = params.get("list_prices", [90, 91, 92, 93, 94, 95]) - coupon_amts = params.get("coupon_amts", [1000, 2000, 3000, 4000, 5000, 6000]) - wholesale_costs = params.get("wholesale_costs", [10, 20, 30, 40, 50, 60]) + buckets = _q28_buckets(get_parameters(28)) store_sales = ctx.get_table("store_sales") # Compute stats for each bucket result_dict = {} - for i, (q_min, q_max) in enumerate(quantity_ranges): - lp = list_prices[i] if i < len(list_prices) else 90 - ca = coupon_amts[i] if i < len(coupon_amts) else 1000 - wc = wholesale_costs[i] if i < len(wholesale_costs) else 10 - + for i, (q_min, q_max, lp, ca, wc) in enumerate(buckets): bucket = store_sales[ (store_sales["ss_quantity"] >= q_min) & (store_sales["ss_quantity"] <= q_max) @@ -8368,7 +10000,8 @@ def q28_pandas_impl(ctx: DataFrameContext) -> Any: ] result_dict[f"B{i + 1}_LP"] = bucket["ss_list_price"].mean() - result_dict[f"B{i + 1}_CNT"] = len(bucket) + # SQL counts non-null ss_list_price values, not rows. + result_dict[f"B{i + 1}_CNT"] = bucket["ss_list_price"].count() result_dict[f"B{i + 1}_CNTD"] = bucket["ss_list_price"].nunique() # Create single row result @@ -8380,11 +10013,21 @@ def q28_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= +_Q88_STORE_NAME = "ese" + + +def _q88_dep_counts(params: Any) -> list[int]: + dep_counts = [int(value) for value in params.get("dep_counts", [3, 0, 1])] + if len(dep_counts) != 3: + raise ValueError(f"Q88 needs three dependent counts, got {dep_counts!r}") + return dep_counts + + def q88_expression_impl(ctx: DataFrameContext) -> Any: """Q88: Store sales count by 8 half-hour time periods (Polars).""" params = get_parameters(88) - dep_counts = params.get("dep_counts", [0, 1, 3]) - store_name = params.get("store_name", "ese") + dep_counts = _q88_dep_counts(params) + store_name = _Q88_STORE_NAME store_sales, household_demographics, time_dim, store = _tables( ctx, "store_sales", "household_demographics", "time_dim", "store" @@ -8451,8 +10094,8 @@ def q88_pandas_impl(ctx: DataFrameContext) -> Any: import pandas as pd params = get_parameters(88) - dep_counts = params.get("dep_counts", [0, 1, 3]) - store_name = params.get("store_name", "ese") + dep_counts = _q88_dep_counts(params) + store_name = _Q88_STORE_NAME store_sales, household_demographics, time_dim, store = _tables( ctx, "store_sales", "household_demographics", "time_dim", "store" @@ -8462,10 +10105,10 @@ def q88_pandas_impl(ctx: DataFrameContext) -> Any: store_filtered = store[store["s_store_name"] == store_name] # Filter household demographics - hd_mask = pd.Series([False] * len(household_demographics)) + hd_mask = None for dc in dep_counts: cond = (household_demographics["hd_dep_count"] == dc) & (household_demographics["hd_vehicle_count"] <= dc + 2) - hd_mask = hd_mask | cond + hd_mask = cond if hd_mask is None else hd_mask | cond hd_filtered = household_demographics[hd_mask] # Join base tables @@ -8498,32 +10141,17 @@ def q88_pandas_impl(ctx: DataFrameContext) -> Any: # ============================================================================= -# Q14: Cross-channel Item Sales Analysis (Simplified - Part 1) # ============================================================================= -def q14_expression_impl(ctx: DataFrameContext) -> Any: - """Q14: Cross-channel item sales analysis (Polars). - - Finds items sold across all three channels and computes sales above average. - """ - from .rollup_helper import expand_rollup_expression - - params = get_parameters(14) - year = params.get("year", 1999) - - # Get tables - store_sales, catalog_sales, web_sales, item, date_dim = _tables( - ctx, "store_sales", "catalog_sales", "web_sales", "item", "date_dim" - ) - +def _q14_cross_items_expression( + ctx: DataFrameContext, year: int, store_sales: Any, catalog_sales: Any, web_sales: Any, item: Any, date_dim: Any +) -> tuple[Any, Any]: col = ctx.col lit = ctx.lit - # Filter date dimension for the 3-year period date_filtered = date_dim.filter((col("d_year") >= lit(year)) & (col("d_year") <= lit(year + 2))) - # Get items from each channel with brand/class/category ss_items = ( store_sales.join(date_filtered, left_on="ss_sold_date_sk", right_on="d_date_sk") .join(item, left_on="ss_item_sk", right_on="i_item_sk") @@ -8545,14 +10173,11 @@ def q14_expression_impl(ctx: DataFrameContext) -> Any: .unique() ) - # Intersect: items in all three channels cross_items = ss_items.join(cs_items, on=["i_brand_id", "i_class_id", "i_category_id"], how="semi") cross_items = cross_items.join(ws_items, on=["i_brand_id", "i_class_id", "i_category_id"], how="semi") - # Get item_sk for cross items cross_item_sks = item.join(cross_items, on=["i_brand_id", "i_class_id", "i_category_id"]).select("i_item_sk") - # Compute average sales across all channels ss_for_avg = store_sales.join(date_filtered, left_on="ss_sold_date_sk", right_on="d_date_sk").select( (col("ss_quantity") * col("ss_list_price")).alias("sales") ) @@ -8566,6 +10191,63 @@ def q14_expression_impl(ctx: DataFrameContext) -> Any: avg_sales = all_sales.select(ctx.mean("sales").alias("average_sales")) avg_sales_val = avg_sales.scalar(0, 0) if hasattr(avg_sales, "scalar") else avg_sales.to_numpy()[0, 0] + return cross_item_sks, avg_sales_val + + +def _q14_cross_items_pandas( + ctx: DataFrameContext, year: int, store_sales: Any, catalog_sales: Any, web_sales: Any, item: Any, date_dim: Any +) -> tuple[Any, Any]: + date_filtered = date_dim[(date_dim["d_year"] >= year) & (date_dim["d_year"] <= year + 2)] + + ss_items = store_sales.merge(date_filtered[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") + ss_items = ss_items.merge(item, left_on="ss_item_sk", right_on="i_item_sk") + ss_items = ss_items[["i_brand_id", "i_class_id", "i_category_id"]].drop_duplicates() + + cs_items = catalog_sales.merge(date_filtered[["d_date_sk"]], left_on="cs_sold_date_sk", right_on="d_date_sk") + cs_items = cs_items.merge(item, left_on="cs_item_sk", right_on="i_item_sk") + cs_items = cs_items[["i_brand_id", "i_class_id", "i_category_id"]].drop_duplicates() + + ws_items = web_sales.merge(date_filtered[["d_date_sk"]], left_on="ws_sold_date_sk", right_on="d_date_sk") + ws_items = ws_items.merge(item, left_on="ws_item_sk", right_on="i_item_sk") + ws_items = ws_items[["i_brand_id", "i_class_id", "i_category_id"]].drop_duplicates() + + cross_items = ss_items.merge(cs_items, on=["i_brand_id", "i_class_id", "i_category_id"]) + cross_items = cross_items.merge(ws_items, on=["i_brand_id", "i_class_id", "i_category_id"]) + + cross_item_sks = item.merge(cross_items, on=["i_brand_id", "i_class_id", "i_category_id"])["i_item_sk"] + + ss_sales = store_sales.merge(date_filtered[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") + ss_sales["sales"] = ss_sales["ss_quantity"] * ss_sales["ss_list_price"] + + cs_sales = catalog_sales.merge(date_filtered[["d_date_sk"]], left_on="cs_sold_date_sk", right_on="d_date_sk") + cs_sales["sales"] = cs_sales["cs_quantity"] * cs_sales["cs_list_price"] + + ws_sales = web_sales.merge(date_filtered[["d_date_sk"]], left_on="ws_sold_date_sk", right_on="d_date_sk") + ws_sales["sales"] = ws_sales["ws_quantity"] * ws_sales["ws_list_price"] + + all_sales = ctx.concat([ss_sales[["sales"]], cs_sales[["sales"]], ws_sales[["sales"]]]) + avg_sales_val = all_sales["sales"].mean() + if hasattr(avg_sales_val, "compute"): + avg_sales_val = avg_sales_val.compute() + return cross_item_sks, avg_sales_val + + +def q14_expression_impl(ctx: DataFrameContext) -> Any: + from .rollup_helper import expand_rollup_expression + + params = get_parameters(14) + year = params.get("year", 1998) + + store_sales, catalog_sales, web_sales, item, date_dim = _tables( + ctx, "store_sales", "catalog_sales", "web_sales", "item", "date_dim" + ) + + col = ctx.col + lit = ctx.lit + + cross_item_sks, avg_sales_val = _q14_cross_items_expression( + ctx, year, store_sales, catalog_sales, web_sales, item, date_dim + ) # Filter for target month in year+2 date_target = date_dim.filter((col("d_year") == lit(year + 2)) & (col("d_moy") == lit(11))) @@ -8576,9 +10258,11 @@ def channel_sales(sales_df, date_col, item_col, qty_col, price_col, channel_name sales_df.join(date_target, left_on=date_col, right_on="d_date_sk") .join(cross_item_sks, left_on=item_col, right_on="i_item_sk", how="semi") .join(item, left_on=item_col, right_on="i_item_sk") + # SQL sums the per-row quantity*price product, not SUM(qty)*AVG(price). + .with_columns((col(qty_col) * col(price_col)).alias("sales")) ) grouped = df.group_by(["i_brand_id", "i_class_id", "i_category_id"]).agg( - (ctx.sum(qty_col) * ctx.mean(price_col)).alias("sales"), + col("sales").sum().alias("sales"), ctx.len().alias("number_sales"), ) # Filter by average sales @@ -8601,7 +10285,9 @@ def channel_sales(sales_df, date_col, item_col, qty_col, price_col, channel_name ] result = expand_rollup_expression(combined, group_cols, agg_exprs, ctx) - return result.sort(["channel", "i_brand_id", "i_class_id", "i_category_id"]).head(100) + # The SQL projection is (channel, brand, class, category, sales, number_sales) + # with no GROUPING() column: drop the helper's grouping_id and sort NULLs last. + return result.select([*group_cols, "sum_sales", "sum_number_sales"]).sort(group_cols, nulls_last=True).head(100) def q14_pandas_impl(ctx: DataFrameContext) -> Any: @@ -8609,52 +10295,16 @@ def q14_pandas_impl(ctx: DataFrameContext) -> Any: from .rollup_helper import expand_rollup_pandas params = get_parameters(14) - year = params.get("year", 1999) + year = params.get("year", 1998) # Get tables store_sales, catalog_sales, web_sales, item, date_dim = _tables( ctx, "store_sales", "catalog_sales", "web_sales", "item", "date_dim" ) - # Filter date dimension - date_filtered = date_dim[(date_dim["d_year"] >= year) & (date_dim["d_year"] <= year + 2)] - - # Get items from each channel - ss_items = store_sales.merge(date_filtered[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") - ss_items = ss_items.merge(item, left_on="ss_item_sk", right_on="i_item_sk") - ss_items = ss_items[["i_brand_id", "i_class_id", "i_category_id"]].drop_duplicates() - - cs_items = catalog_sales.merge(date_filtered[["d_date_sk"]], left_on="cs_sold_date_sk", right_on="d_date_sk") - cs_items = cs_items.merge(item, left_on="cs_item_sk", right_on="i_item_sk") - cs_items = cs_items[["i_brand_id", "i_class_id", "i_category_id"]].drop_duplicates() - - ws_items = web_sales.merge(date_filtered[["d_date_sk"]], left_on="ws_sold_date_sk", right_on="d_date_sk") - ws_items = ws_items.merge(item, left_on="ws_item_sk", right_on="i_item_sk") - ws_items = ws_items[["i_brand_id", "i_class_id", "i_category_id"]].drop_duplicates() - - # Intersect - cross_items = ss_items.merge(cs_items, on=["i_brand_id", "i_class_id", "i_category_id"]) - cross_items = cross_items.merge(ws_items, on=["i_brand_id", "i_class_id", "i_category_id"]) - - # Get item SKs - cross_item_sks = item.merge(cross_items, on=["i_brand_id", "i_class_id", "i_category_id"])["i_item_sk"] - - # Compute average sales - ss_sales = store_sales.merge(date_filtered[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") - ss_sales["sales"] = ss_sales["ss_quantity"] * ss_sales["ss_list_price"] - - cs_sales = catalog_sales.merge(date_filtered[["d_date_sk"]], left_on="cs_sold_date_sk", right_on="d_date_sk") - cs_sales["sales"] = cs_sales["cs_quantity"] * cs_sales["cs_list_price"] - - ws_sales = web_sales.merge(date_filtered[["d_date_sk"]], left_on="ws_sold_date_sk", right_on="d_date_sk") - ws_sales["sales"] = ws_sales["ws_quantity"] * ws_sales["ws_list_price"] - - # Compute average sales across all channels (Dask-compatible) - all_sales = ctx.concat([ss_sales[["sales"]], cs_sales[["sales"]], ws_sales[["sales"]]]) - avg_sales_val = all_sales["sales"].mean() - # Compute if lazy (Dask) - if hasattr(avg_sales_val, "compute"): - avg_sales_val = avg_sales_val.compute() + cross_item_sks, avg_sales_val = _q14_cross_items_pandas( + ctx, year, store_sales, catalog_sales, web_sales, item, date_dim + ) # Target date date_target = date_dim[(date_dim["d_year"] == year + 2) & (date_dim["d_moy"] == 11)] @@ -8687,34 +10337,123 @@ def channel_sales(sales_df, date_col, item_col, qty_col, price_col, channel_name } result = expand_rollup_pandas(combined, group_cols, agg_dict, ctx) - return result.sort_values(["channel", "i_brand_id", "i_class_id", "i_category_id"]).head(100) + # The SQL projection is (channel, brand, class, category, sales, number_sales) + # with no GROUPING() column: drop the helper's grouping_id. + out_cols = [*group_cols, "sum_sales", "sum_number_sales"] + return result[out_cols].sort_values(["channel", "i_brand_id", "i_class_id", "i_category_id"]).head(100) -# ============================================================================= -# Q23: Frequent Items and Best Customers Analysis -# ============================================================================= +_Q14B_SIDE_COLUMNS = ("channel", "brand", "class", "category", "sales", "number_sales") +_Q14B_COLUMNS = [f"{side}_{name}" for side in ("ty", "ly") for name in _Q14B_SIDE_COLUMNS] -def q23_expression_impl(ctx: DataFrameContext) -> Any: - """Q23: Sum of catalog+web sales for frequent items by best customers (Polars).""" - params = get_parameters(23) - year = params.get("year", 2000) - month = params.get("month", 4) - top_percent = params.get("top_percent", 95) +def q14b_expression_impl(ctx: DataFrameContext) -> Any: + params = get_parameters(14) + year = params.get("year", 1998) + day = params.get("day", 16) - # Get tables - store_sales, catalog_sales, web_sales, customer, item, date_dim = _tables( - ctx, "store_sales", "catalog_sales", "web_sales", "customer", "item", "date_dim" + store_sales, catalog_sales, web_sales, item, date_dim = _tables( + ctx, "store_sales", "catalog_sales", "web_sales", "item", "date_dim" + ) + + col = ctx.col + lit = ctx.lit + + cross_item_sks, avg_sales_val = _q14_cross_items_expression( + ctx, year, store_sales, catalog_sales, web_sales, item, date_dim + ) + keys = ["i_brand_id", "i_class_id", "i_category_id"] + + def store_week(week_year: int, side: str) -> Any: + week = date_dim.filter( + (col("d_year") == lit(week_year)) & (col("d_moy") == lit(12)) & (col("d_dom") == lit(day)) + ).select("d_week_seq") + week_dates = date_dim.join(week, on="d_week_seq", how="semi").select("d_date_sk") + grouped = ( + store_sales.join(week_dates, left_on="ss_sold_date_sk", right_on="d_date_sk") + .join(cross_item_sks, left_on="ss_item_sk", right_on="i_item_sk", how="semi") + .join(item, left_on="ss_item_sk", right_on="i_item_sk") + .with_columns((col("ss_quantity") * col("ss_list_price")).alias("sales")) + .group_by(keys) + .agg(col("sales").sum().alias("sales"), ctx.len().alias("number_sales")) + .filter(col("sales") > lit(avg_sales_val)) + ) + return grouped.select( + lit("store").alias(f"{side}_channel"), + col("i_brand_id").alias(f"{side}_brand"), + col("i_class_id").alias(f"{side}_class"), + col("i_category_id").alias(f"{side}_category"), + col("sales").alias(f"{side}_sales"), + col("number_sales").alias(f"{side}_number_sales"), + *[col(key).alias(f"_{side}_{key}") for key in keys], + ) + + this_year = store_week(year + 1, "ty") + last_year = store_week(year, "ly") + joined = this_year.join(last_year, left_on=[f"_ty_{key}" for key in keys], right_on=[f"_ly_{key}" for key in keys]) + return ( + joined.select(_Q14B_COLUMNS) + .sort(["ty_channel", "ty_brand", "ty_class", "ty_category"], nulls_last=True) + .head(100) + ) + + +def q14b_pandas_impl(ctx: DataFrameContext) -> Any: + params = get_parameters(14) + year = params.get("year", 1998) + day = params.get("day", 16) + + store_sales, catalog_sales, web_sales, item, date_dim = _tables( + ctx, "store_sales", "catalog_sales", "web_sales", "item", "date_dim" + ) + + cross_item_sks, avg_sales_val = _q14_cross_items_pandas( + ctx, year, store_sales, catalog_sales, web_sales, item, date_dim + ) + keys = ["i_brand_id", "i_class_id", "i_category_id"] + + def store_week(week_year: int, side: str) -> Any: + week = date_dim[(date_dim["d_year"] == week_year) & (date_dim["d_moy"] == 12) & (date_dim["d_dom"] == day)] + week_dates = date_dim[date_dim["d_week_seq"].isin(week["d_week_seq"])][["d_date_sk"]] + df = store_sales.merge(week_dates, left_on="ss_sold_date_sk", right_on="d_date_sk") + df = df[df["ss_item_sk"].isin(cross_item_sks)] + df = df.merge(item[["i_item_sk", *keys]], left_on="ss_item_sk", right_on="i_item_sk") + df["sales"] = df["ss_quantity"] * df["ss_list_price"] + grouped = df.groupby(keys, as_index=False).agg(sales=("sales", "sum"), number_sales=("ss_item_sk", "count")) + grouped = grouped[grouped["sales"] > avg_sales_val] + grouped["channel"] = "store" + return grouped[["channel", *keys, "sales", "number_sales"]].rename( + columns={ + "channel": f"{side}_channel", + "i_brand_id": f"{side}_brand", + "i_class_id": f"{side}_class", + "i_category_id": f"{side}_category", + "sales": f"{side}_sales", + "number_sales": f"{side}_number_sales", + } + ) + + this_year = store_week(year + 1, "ty") + last_year = store_week(year, "ly") + joined = this_year.merge( + last_year, left_on=["ty_brand", "ty_class", "ty_category"], right_on=["ly_brand", "ly_class", "ly_category"] ) + return joined[_Q14B_COLUMNS].sort_values(["ty_channel", "ty_brand", "ty_class", "ty_category"]).head(100) + +# ============================================================================= +# Q23: Frequent Items and Best Customers Analysis +# ============================================================================= + + +def _q23_frequent_items_and_best_customers_expression( + ctx: DataFrameContext, year: int, top_percent: Any, store_sales: Any, customer: Any, item: Any, date_dim: Any +) -> tuple[Any, Any]: col = ctx.col lit = ctx.lit - # Date range for frequent items (4 years) date_4yr = date_dim.filter((col("d_year") >= lit(year)) & (col("d_year") <= lit(year + 3))) - # Frequent store sales items: items sold >4 times on same date - # Note: i_item_sk is dropped after join (right key), use ss_item_sk instead frequent_items = ( store_sales.join(date_4yr, left_on="ss_sold_date_sk", right_on="d_date_sk") .join(item, left_on="ss_item_sk", right_on="i_item_sk") @@ -8726,13 +10465,17 @@ def q23_expression_impl(ctx: DataFrameContext) -> Any: .unique() ) - # Max store sales by customer in 4-year period - # Note: c_customer_sk is dropped after join, use ss_customer_sk instead max_store_sales = ( store_sales.join(customer, left_on="ss_customer_sk", right_on="c_customer_sk") .join(date_4yr, left_on="ss_sold_date_sk", right_on="d_date_sk") + .with_columns((col("ss_quantity") * col("ss_sales_price")).alias("csales_row")) .group_by("ss_customer_sk") - .agg((ctx.sum("ss_quantity") * ctx.mean("ss_sales_price")).alias("csales")) + .agg( + ctx.when(col("csales_row").count() > lit(0)) + .then(col("csales_row").sum()) + .otherwise(lit(None)) + .alias("csales") + ) .select(ctx.max_("csales").alias("tpcds_cmax")) ) @@ -8741,17 +10484,77 @@ def q23_expression_impl(ctx: DataFrameContext) -> Any: else: max_sales_val = max_store_sales.to_numpy()[0, 0] - threshold = (top_percent / 100.0) * max_sales_val if max_sales_val else 0 + threshold = (top_percent / 100.0) * max_sales_val if max_sales_val is not None else None - # Best customers: total store sales > threshold - # Note: c_customer_sk is dropped after join, use ss_customer_sk instead best_customers = ( store_sales.join(customer, left_on="ss_customer_sk", right_on="c_customer_sk") + .with_columns((col("ss_quantity") * col("ss_sales_price")).alias("ssales_row")) .group_by("ss_customer_sk") - .agg((ctx.sum("ss_quantity") * ctx.mean("ss_sales_price")).alias("ssales")) + .agg( + ctx.when(col("ssales_row").count() > lit(0)) + .then(col("ssales_row").sum()) + .otherwise(lit(None)) + .alias("ssales") + ) .filter(col("ssales") > lit(threshold)) .select(col("ss_customer_sk").alias("c_customer_sk")) ) + return frequent_items, best_customers + + +def _q23_frequent_items_and_best_customers_pandas( + ctx: DataFrameContext, year: int, top_percent: Any, store_sales: Any, customer: Any, item: Any, date_dim: Any +) -> tuple[set[Any], set[Any]]: + date_4yr = date_dim[(date_dim["d_year"] >= year) & (date_dim["d_year"] <= year + 3)] + + ss_items = store_sales.merge(date_4yr[["d_date_sk", "d_date"]], left_on="ss_sold_date_sk", right_on="d_date_sk") + ss_items = ss_items.merge(item[["i_item_sk", "i_item_desc"]], left_on="ss_item_sk", right_on="i_item_sk") + ss_items["itemdesc"] = ss_items["i_item_desc"].str[:30] + + freq_counts = ctx.groupby_agg( + ss_items, ["itemdesc", "i_item_sk", "d_date"], {"cnt": ("i_item_sk", "count")}, as_index=False, dropna=False + ) + frequent_item_sks = set(freq_counts[freq_counts["cnt"] > 4]["i_item_sk"]) + + ss_cust = store_sales.merge(customer[["c_customer_sk"]], left_on="ss_customer_sk", right_on="c_customer_sk") + ss_cust = ss_cust.merge(date_4yr[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") + ss_cust["_sales"] = ss_cust["ss_quantity"] * ss_cust["ss_sales_price"] + cust_sales = ss_cust.groupby("c_customer_sk", as_index=False).agg( + csales=("_sales", "sum"), count=("_sales", "count") + ) + cust_sales["csales"] = cust_sales["csales"].where(cust_sales["count"] > 0) + max_sales_val = cust_sales["csales"].max() + if hasattr(max_sales_val, "compute"): + max_sales_val = max_sales_val.compute() + + threshold = (top_percent / 100.0) * max_sales_val + + all_cust_sales = store_sales.merge(customer[["c_customer_sk"]], left_on="ss_customer_sk", right_on="c_customer_sk") + all_cust_sales["_sales"] = all_cust_sales["ss_quantity"] * all_cust_sales["ss_sales_price"] + all_cust_sales_agg = all_cust_sales.groupby("c_customer_sk", as_index=False).agg( + ssales=("_sales", "sum"), count=("_sales", "count") + ) + all_cust_sales_agg["ssales"] = all_cust_sales_agg["ssales"].where(all_cust_sales_agg["count"] > 0) + best_customer_sks = set(all_cust_sales_agg[all_cust_sales_agg["ssales"] > threshold]["c_customer_sk"]) + return frequent_item_sks, best_customer_sks + + +def q23_expression_impl(ctx: DataFrameContext) -> Any: + params = get_parameters(23) + year = params.get("year", 1999) + month = params.get("month", 1) + top_percent = params.get("top_percent", 95) + + store_sales, catalog_sales, web_sales, customer, item, date_dim = _tables( + ctx, "store_sales", "catalog_sales", "web_sales", "customer", "item", "date_dim" + ) + + col = ctx.col + lit = ctx.lit + + frequent_items, best_customers = _q23_frequent_items_and_best_customers_expression( + ctx, year, top_percent, store_sales, customer, item, date_dim + ) # Target date date_target = date_dim.filter((col("d_year") == lit(year)) & (col("d_moy") == lit(month))) @@ -8772,9 +10575,13 @@ def q23_expression_impl(ctx: DataFrameContext) -> Any: .select((col("ws_quantity") * col("ws_list_price")).alias("sales")) ) - # Union and sum + # Union and sum. SQL SUM() over an empty set is NULL (not 0.0). all_sales = ctx.concat([cs_sales, ws_sales]) - return all_sales.select(ctx.sum("sales").alias("sum_sales")) + tallied = all_sales.select( + ctx.sum("sales").alias("sum_sales"), + col("sales").count().alias("n"), + ) + return tallied.select(ctx.when(col("n") > lit(0)).then(col("sum_sales")).otherwise(lit(None)).alias("sum_sales")) def q23_pandas_impl(ctx: DataFrameContext) -> Any: @@ -8782,8 +10589,8 @@ def q23_pandas_impl(ctx: DataFrameContext) -> Any: import pandas as pd params = get_parameters(23) - year = params.get("year", 2000) - month = params.get("month", 4) + year = params.get("year", 1999) + month = params.get("month", 1) top_percent = params.get("top_percent", 95) # Get tables @@ -8791,36 +10598,9 @@ def q23_pandas_impl(ctx: DataFrameContext) -> Any: ctx, "store_sales", "catalog_sales", "web_sales", "customer", "item", "date_dim" ) - # Date range for 4 years - date_4yr = date_dim[(date_dim["d_year"] >= year) & (date_dim["d_year"] <= year + 3)] - - # Frequent items - ss_items = store_sales.merge(date_4yr[["d_date_sk", "d_date"]], left_on="ss_sold_date_sk", right_on="d_date_sk") - ss_items = ss_items.merge(item[["i_item_sk", "i_item_desc"]], left_on="ss_item_sk", right_on="i_item_sk") - ss_items["itemdesc"] = ss_items["i_item_desc"].str[:30] - - # Use ctx.groupby_size for Dask compatibility - freq_counts = ctx.groupby_size(ss_items, ["itemdesc", "i_item_sk", "d_date"], name="cnt") - frequent_item_sks = set(freq_counts[freq_counts["cnt"] > 4]["i_item_sk"]) - - # Max store sales by customer (rewritten for Dask compatibility) - ss_cust = store_sales.merge(customer[["c_customer_sk"]], left_on="ss_customer_sk", right_on="c_customer_sk") - ss_cust = ss_cust.merge(date_4yr[["d_date_sk"]], left_on="ss_sold_date_sk", right_on="d_date_sk") - # Compute sales per row first, then aggregate - ss_cust["_sales"] = ss_cust["ss_quantity"] * ss_cust["ss_sales_price"] - cust_sales = ss_cust.groupby("c_customer_sk", as_index=False).agg(csales=("_sales", "sum")) - max_sales_val = cust_sales["csales"].max() - # Compute if lazy (Dask) - if hasattr(max_sales_val, "compute"): - max_sales_val = max_sales_val.compute() - - threshold = (top_percent / 100.0) * max_sales_val if max_sales_val else 0 - - # Best customers (rewritten for Dask compatibility) - all_cust_sales = store_sales.merge(customer[["c_customer_sk"]], left_on="ss_customer_sk", right_on="c_customer_sk") - all_cust_sales["_sales"] = all_cust_sales["ss_quantity"] * all_cust_sales["ss_sales_price"] - all_cust_sales_agg = all_cust_sales.groupby("c_customer_sk", as_index=False).agg(ssales=("_sales", "sum")) - best_customer_sks = set(all_cust_sales_agg[all_cust_sales_agg["ssales"] > threshold]["c_customer_sk"]) + frequent_item_sks, best_customer_sks = _q23_frequent_items_and_best_customers_pandas( + ctx, year, top_percent, store_sales, customer, item, date_dim + ) # Target date date_target = date_dim[(date_dim["d_year"] == year) & (date_dim["d_moy"] == month)] @@ -8837,22 +10617,130 @@ def q23_pandas_impl(ctx: DataFrameContext) -> Any: ws = ws[ws["ws_bill_customer_sk"].isin(best_customer_sks)] ws["sales"] = ws["ws_quantity"] * ws["ws_list_price"] + if cs["sales"].count() + ws["sales"].count() == 0: + return pd.DataFrame({"sum_sales": [None]}) total = cs["sales"].sum() + ws["sales"].sum() return pd.DataFrame({"sum_sales": [total]}) +def q23b_expression_impl(ctx: DataFrameContext) -> Any: + params = get_parameters(23) + year = params.get("year", 1999) + month = params.get("month", 1) + top_percent = params.get("top_percent", 95) + + store_sales, catalog_sales, web_sales, customer, item, date_dim = _tables( + ctx, "store_sales", "catalog_sales", "web_sales", "customer", "item", "date_dim" + ) + + col = ctx.col + lit = ctx.lit + + frequent_items, best_customers = _q23_frequent_items_and_best_customers_expression( + ctx, year, top_percent, store_sales, customer, item, date_dim + ) + date_target = date_dim.filter((col("d_year") == lit(year)) & (col("d_moy") == lit(month))) + names = customer.select("c_customer_sk", "c_last_name", "c_first_name") + + def channel_sales(sales_df: Any, date_col: str, item_col: str, customer_col: str, qty: str, price: str) -> Any: + grouped = ( + sales_df.join(date_target, left_on=date_col, right_on="d_date_sk") + .join(frequent_items, left_on=item_col, right_on="i_item_sk", how="semi") + .join(best_customers, left_on=customer_col, right_on="c_customer_sk", how="semi") + .join(names, left_on=customer_col, right_on="c_customer_sk") + .with_columns((col(qty) * col(price)).alias("_sales")) + .group_by(["c_last_name", "c_first_name"]) + .agg(col("_sales").sum().alias("_sum"), col("_sales").count().alias("_n")) + ) + return grouped.select( + "c_last_name", + "c_first_name", + ctx.when(col("_n") > lit(0)).then(col("_sum")).otherwise(lit(None)).alias("sales"), + ) + + cs_sales = channel_sales( + catalog_sales, "cs_sold_date_sk", "cs_item_sk", "cs_bill_customer_sk", "cs_quantity", "cs_list_price" + ) + ws_sales = channel_sales( + web_sales, "ws_sold_date_sk", "ws_item_sk", "ws_bill_customer_sk", "ws_quantity", "ws_list_price" + ) + return ctx.concat([cs_sales, ws_sales]).sort(["c_last_name", "c_first_name", "sales"], nulls_last=True).head(100) + + +def q23b_pandas_impl(ctx: DataFrameContext) -> Any: + params = get_parameters(23) + year = params.get("year", 1999) + month = params.get("month", 1) + top_percent = params.get("top_percent", 95) + + store_sales, catalog_sales, web_sales, customer, item, date_dim = _tables( + ctx, "store_sales", "catalog_sales", "web_sales", "customer", "item", "date_dim" + ) + + frequent_item_sks, best_customer_sks = _q23_frequent_items_and_best_customers_pandas( + ctx, year, top_percent, store_sales, customer, item, date_dim + ) + date_target = date_dim[(date_dim["d_year"] == year) & (date_dim["d_moy"] == month)] + names = customer[["c_customer_sk", "c_last_name", "c_first_name"]] + + def channel_sales(sales_df: Any, date_col: str, item_col: str, customer_col: str, qty: str, price: str) -> Any: + df = sales_df.merge(date_target[["d_date_sk"]], left_on=date_col, right_on="d_date_sk") + df = df[df[item_col].isin(frequent_item_sks)] + df = df[df[customer_col].isin(best_customer_sks)] + df = df.merge(names, left_on=customer_col, right_on="c_customer_sk") + df["_sales"] = df[qty] * df[price] + grouped = ctx.groupby_agg( + df, + ["c_last_name", "c_first_name"], + {"sales": ("_sales", "sum"), "_n": ("_sales", "count")}, + as_index=False, + dropna=False, + ) + grouped["sales"] = grouped["sales"].where(grouped["_n"] > 0) + return grouped[["c_last_name", "c_first_name", "sales"]] + + cs_sales = channel_sales( + catalog_sales, "cs_sold_date_sk", "cs_item_sk", "cs_bill_customer_sk", "cs_quantity", "cs_list_price" + ) + ws_sales = channel_sales( + web_sales, "ws_sold_date_sk", "ws_item_sk", "ws_bill_customer_sk", "ws_quantity", "ws_list_price" + ) + combined = ctx.concat([cs_sales, ws_sales]) + return _none_for_null( + combined.sort_values(["c_last_name", "c_first_name", "sales"], na_position="last").head(100), + ["c_last_name", "c_first_name", "sales"], + ) + + # ============================================================================= # Q24: Store Sales Returns Analysis by Color # ============================================================================= +_Q24_AMOUNT_COLUMNS = ("ss_net_paid", "ss_net_paid_inc_tax", "ss_net_profit", "ss_sales_price", "ss_ext_sales_price") + + +def _q24_amount_column(params: Any) -> str: + amount = str(params.get("amount_column", "ss_sales_price")).lower() + if amount not in _Q24_AMOUNT_COLUMNS: + raise ValueError(f"Q24 amount_column must be one of {_Q24_AMOUNT_COLUMNS}, got {amount!r}") + return amount + + def q24_expression_impl(ctx: DataFrameContext) -> Any: """Q24: Store sales with returns filtered by market and color (Polars).""" + return _q24_expression(ctx, get_parameters(24).get("color", "orchid")) + + +def q24b_expression_impl(ctx: DataFrameContext) -> Any: + return _q24_expression(ctx, get_parameters(24).get("second_color", "chiffon")) + + +def _q24_expression(ctx: DataFrameContext, color: str) -> Any: params = get_parameters(24) - market_id = params.get("market_id", 8) - color = params.get("color", "chiffon") + market_id = params.get("market_id", 7) + amount = _q24_amount_column(params) - # Get tables store_sales, store_returns, store, item, customer, customer_address = _tables( ctx, "store_sales", "store_returns", "store", "item", "customer", "customer_address" ) @@ -8860,10 +10748,8 @@ def q24_expression_impl(ctx: DataFrameContext) -> Any: col = ctx.col lit = ctx.lit - # Filter store by market_id store_filtered = store.filter(col("s_market_id") == lit(market_id)) - # Join store_sales with store_returns on ticket_number and item_sk ssales = ( store_sales.join( store_returns, @@ -8876,12 +10762,10 @@ def q24_expression_impl(ctx: DataFrameContext) -> Any: .join(customer_address, left_on="c_current_addr_sk", right_on="ca_address_sk") ) - # Filter: c_birth_country <> upper(ca_country) and s_zip = ca_zip ssales = ssales.filter( (col("c_birth_country") != col("ca_country").str.to_uppercase()) & (col("s_zip") == col("ca_zip")) ) - # Group and compute netpaid ssales_agg = ssales.group_by( [ "c_last_name", @@ -8895,39 +10779,43 @@ def q24_expression_impl(ctx: DataFrameContext) -> Any: "i_units", "i_size", ] - ).agg(ctx.sum("ss_net_paid").alias("netpaid")) + ).agg(ctx.when(col(amount).count() > lit(0)).then(col(amount).sum()).otherwise(lit(None)).alias("netpaid")) - # Compute average netpaid - use platform-agnostic scalar() method avg_netpaid = ssales_agg.select(ctx.mean("netpaid").alias("avg_netpaid")) avg_val = avg_netpaid.scalar(0, 0) - threshold = 0.05 * avg_val if avg_val else 0 + threshold = 0.05 * avg_val if avg_val is not None else None - # Filter by color and aggregate - return ( + return _sort_null_largest_expression( + ctx, ssales_agg.filter(col("i_color") == lit(color)) .group_by(["c_last_name", "c_first_name", "s_store_name"]) - .agg(ctx.sum("netpaid").alias("paid")) - .filter(col("paid") > lit(threshold)) - .sort(["c_last_name", "c_first_name", "s_store_name"]) + .agg(ctx.when(col("netpaid").count() > lit(0)).then(col("netpaid").sum()).otherwise(lit(None)).alias("paid")) + .filter(col("paid") > lit(threshold)), + ["c_last_name", "c_first_name", "s_store_name"], ) def q24_pandas_impl(ctx: DataFrameContext) -> Any: """Q24: Store sales with returns filtered by market and color (Pandas).""" + return _q24_pandas(ctx, get_parameters(24).get("color", "orchid")) + + +def q24b_pandas_impl(ctx: DataFrameContext) -> Any: + return _q24_pandas(ctx, get_parameters(24).get("second_color", "chiffon")) + + +def _q24_pandas(ctx: DataFrameContext, color: str) -> Any: params = get_parameters(24) - market_id = params.get("market_id", 8) - color = params.get("color", "chiffon") + market_id = params.get("market_id", 7) + amount = _q24_amount_column(params) - # Get tables store_sales, store_returns, store, item, customer, customer_address = _tables( ctx, "store_sales", "store_returns", "store", "item", "customer", "customer_address" ) - # Filter store store_filtered = store[store["s_market_id"] == market_id] - # Join tables ssales = store_sales.merge( store_returns, left_on=["ss_ticket_number", "ss_item_sk"], @@ -8938,12 +10826,15 @@ def q24_pandas_impl(ctx: DataFrameContext) -> Any: ssales = ssales.merge(customer, left_on="ss_customer_sk", right_on="c_customer_sk") ssales = ssales.merge(customer_address, left_on="c_current_addr_sk", right_on="ca_address_sk") - # Filter - use query for Dask compatibility (avoids index alignment issues) - # Note: str.upper() doesn't work in query, need to pre-compute ssales["_ca_country_upper"] = ssales["ca_country"].str.upper() + ssales = ssales[ + (~ssales["c_birth_country"].isna()) + & (~ssales["_ca_country_upper"].isna()) + & (~ssales["s_zip"].isna()) + & (~ssales["ca_zip"].isna()) + ] ssales = ssales.query("c_birth_country != _ca_country_upper and s_zip == ca_zip") - # Aggregate group_cols = [ "c_last_name", "c_first_name", @@ -8956,22 +10847,31 @@ def q24_pandas_impl(ctx: DataFrameContext) -> Any: "i_units", "i_size", ] - ssales_agg = ctx.groupby_agg(ssales, group_cols, {"netpaid": ("ss_net_paid", "sum")}, as_index=False) + ssales_agg = ctx.groupby_agg( + ssales, group_cols, {"netpaid": (amount, "sum"), "_n": (amount, "count")}, as_index=False, dropna=False + ) + ssales_agg["netpaid"] = ssales_agg["netpaid"].where(ssales_agg["_n"] > 0) - # Compute average and threshold (compute for Dask compatibility) avg_val = ssales_agg["netpaid"].mean() if hasattr(avg_val, "compute"): avg_val = avg_val.compute() - threshold = 0.05 * avg_val if avg_val else 0 + threshold = 0.05 * avg_val - # Filter by color using query for Dask compatibility color_data = ssales_agg.query("i_color == @_color", local_dict={"_color": color}) result = ctx.groupby_agg( - color_data, ["c_last_name", "c_first_name", "s_store_name"], {"paid": ("netpaid", "sum")}, as_index=False + color_data, + ["c_last_name", "c_first_name", "s_store_name"], + {"paid": ("netpaid", "sum"), "_n": ("netpaid", "count")}, + as_index=False, + dropna=False, ) - # Use ctx.filter_gt for Dask-safe filtering (avoids index alignment issues) + result["paid"] = result["paid"].where(result["_n"] > 0) + result = result.drop(columns="_n") result = ctx.filter_gt(result, "paid", threshold) - return result.sort_values(["c_last_name", "c_first_name", "s_store_name"]) + return _none_for_null( + result.sort_values(["c_last_name", "c_first_name", "s_store_name"]), + ["c_last_name", "c_first_name", "s_store_name", "paid"], + ) # ============================================================================= @@ -8981,13 +10881,12 @@ def q24_pandas_impl(ctx: DataFrameContext) -> Any: def q21_expression_impl(ctx: DataFrameContext) -> Any: """Q21: Inventory analysis comparing quantities before/after a date (Polars).""" - from datetime import date, timedelta + from datetime import timedelta params = get_parameters(21) - year = params.get("year", 2000) - month = params.get("month", 1) - price_min = params.get("price_min", 0.99) - price_max = params.get("price_max", 1.49) + # The template fixes the price band (``i_current_price between 0.99 and 1.49``); only the date is drawn. + price_min, price_max = 0.99, 1.49 + sales_date_default = params.get("sales_date", "1998-04-08") # Get tables inventory, warehouse, item, date_dim = _tables(ctx, "inventory", "warehouse", "item", "date_dim") @@ -8995,8 +10894,12 @@ def q21_expression_impl(ctx: DataFrameContext) -> Any: col = ctx.col lit = ctx.lit - # Define the sales date (middle of the date range) - sales_date = date(year, month, 31) + # Pivot date from parameters (stream-0 SQL uses 1998-04-08) + sales_date = ( + datetime.strptime(sales_date_default, "%Y-%m-%d").date() + if isinstance(sales_date_default, str) + else sales_date_default + ) date_start = sales_date - timedelta(days=30) date_end = sales_date + timedelta(days=30) @@ -9042,27 +10945,30 @@ def q21_expression_impl(ctx: DataFrameContext) -> Any: & ((col("inv_after") / col("inv_before")) >= lit(2.0 / 3.0)) & ((col("inv_after") / col("inv_before")) <= lit(3.0 / 2.0)) ) - .sort(["w_warehouse_name", "i_item_id"]) + .sort(["w_warehouse_name", "i_item_id"], nulls_last=True) .head(100) ) def q21_pandas_impl(ctx: DataFrameContext) -> Any: """Q21: Inventory analysis comparing quantities before/after a date (Pandas).""" - from datetime import date, timedelta + from datetime import timedelta params = get_parameters(21) - year = params.get("year", 2000) - month = params.get("month", 1) - price_min = params.get("price_min", 0.99) - price_max = params.get("price_max", 1.49) + # The template fixes the price band (``i_current_price between 0.99 and 1.49``); only the date is drawn. + price_min, price_max = 0.99, 1.49 + sales_date_default = params.get("sales_date", "1998-04-08") # Get tables inventory, warehouse, item, date_dim = _tables(ctx, "inventory", "warehouse", "item", "date_dim") - # Define date range + # Define date range (stream-0 SQL pivots on 1998-04-08) # Note: Use datetime.date consistently as d_date column may contain date objects - sales_date = date(year, month, 31) + sales_date = ( + datetime.strptime(sales_date_default, "%Y-%m-%d").date() + if isinstance(sales_date_default, str) + else sales_date_default + ) date_start = sales_date - timedelta(days=30) date_end = sales_date + timedelta(days=30) @@ -9088,7 +10994,7 @@ def q21_pandas_impl(ctx: DataFrameContext) -> Any: ) # Aggregate - grouped = inv_data.groupby(["w_warehouse_name", "i_item_id"], as_index=False).agg( + grouped = inv_data.groupby(["w_warehouse_name", "i_item_id"], as_index=False, dropna=False).agg( inv_before=("inv_before", "sum"), inv_after=("inv_after", "sum"), ) @@ -9098,7 +11004,8 @@ def q21_pandas_impl(ctx: DataFrameContext) -> Any: result["ratio"] = result["inv_after"] / result["inv_before"] result = result[(result["ratio"] >= 2.0 / 3.0) & (result["ratio"] <= 3.0 / 2.0)] result = result[["w_warehouse_name", "i_item_id", "inv_before", "inv_after"]] - return result.sort_values(["w_warehouse_name", "i_item_id"]).head(100) + result = result.sort_values(["w_warehouse_name", "i_item_id"], na_position="last").head(100) + return _none_for_null(result, ["w_warehouse_name"]) # ============================================================================= @@ -9111,19 +11018,15 @@ def q22_expression_impl(ctx: DataFrameContext) -> Any: from .rollup_helper import expand_rollup_expression params = get_parameters(22) - year = params.get("year", 2001) - months = params.get("months", [1, 2, 3, 4, 5, 6]) + dms = params.get("dms", 1212) # Get tables inventory, item, date_dim = _tables(ctx, "inventory", "item", "date_dim") col = ctx.col - lit = ctx.lit - # Filter dates by month_seq range (approximated by year and months) - # d_month_seq is typically: year * 12 + month - 1 - # For months param, filter by d_moy in months and d_year - date_filtered = date_dim.filter((col("d_year") == lit(year)) & (col("d_moy").is_in(months))) + # Filter dates by the d_month_seq window from the SQL template. + date_filtered = date_dim.filter(col("d_month_seq").is_between(dms, dms + 11)) # Join inventory with item and date inv_data = inventory.join(date_filtered, left_on="inv_date_sk", right_on="d_date_sk").join( @@ -9135,7 +11038,13 @@ def q22_expression_impl(ctx: DataFrameContext) -> Any: agg_exprs = [ctx.mean("inv_quantity_on_hand").alias("qoh")] result = expand_rollup_expression(inv_data, group_cols, agg_exprs, ctx) - return result.sort(["qoh", "i_product_name", "i_brand", "i_class", "i_category"]).head(100) + # The SQL projection has no GROUPING() column: drop the helper's grouping_id + # and sort NULLs last like DuckDB ASC. + return ( + result.select([*group_cols, "qoh"]) + .sort(["qoh", "i_product_name", "i_brand", "i_class", "i_category"], nulls_last=True) + .head(100) + ) def q22_pandas_impl(ctx: DataFrameContext) -> Any: @@ -9143,14 +11052,13 @@ def q22_pandas_impl(ctx: DataFrameContext) -> Any: from .rollup_helper import expand_rollup_pandas params = get_parameters(22) - year = params.get("year", 2001) - months = params.get("months", [1, 2, 3, 4, 5, 6]) + dms = params.get("dms", 1212) # Get tables inventory, item, date_dim = _tables(ctx, "inventory", "item", "date_dim") - # Filter dates - date_filtered = date_dim[(date_dim["d_year"] == year) & (date_dim["d_moy"].isin(months))] + # Filter dates by the d_month_seq window from the SQL template. + date_filtered = date_dim[(date_dim["d_month_seq"] >= dms) & (date_dim["d_month_seq"] <= dms + 11)] # Join tables inv_data = inventory.merge(date_filtered, left_on="inv_date_sk", right_on="d_date_sk") @@ -9161,40 +11069,51 @@ def q22_pandas_impl(ctx: DataFrameContext) -> Any: agg_dict = {"qoh": ("inv_quantity_on_hand", "mean")} result = expand_rollup_pandas(inv_data, group_cols, agg_dict, ctx) - return result.sort_values(["qoh", "i_product_name", "i_brand", "i_class", "i_category"]).head(100) + # The SQL projection has no GROUPING() column: drop the helper's grouping_id. + # pandas sort_values already sorts NaN last, matching DuckDB ASC. + return ( + result[[*group_cols, "qoh"]] + .sort_values(["qoh", "i_product_name", "i_brand", "i_class", "i_category"]) + .head(100) + ) # ============================================================================= # Q39: Inventory Variance by Month (Two-part query) # ============================================================================= +_Q39B_MIN_FIRST_MONTH_COV = 1.5 + def q39_expression_impl(ctx: DataFrameContext) -> Any: """Q39: Inventory variance analysis comparing consecutive months (Polars).""" + return _q39_expression(ctx, first_month_min_cov=None) + + +def q39b_expression_impl(ctx: DataFrameContext) -> Any: + return _q39_expression(ctx, first_month_min_cov=_Q39B_MIN_FIRST_MONTH_COV) + + +def _q39_expression(ctx: DataFrameContext, first_month_min_cov: float | None) -> Any: params = get_parameters(39) year = params.get("year", 2001) months = params.get("months", [1, 2]) month1 = months[0] if len(months) > 0 else 1 month2 = months[1] if len(months) > 1 else 2 - # Get tables inventory, item, warehouse, date_dim = _tables(ctx, "inventory", "item", "warehouse", "date_dim") col = ctx.col lit = ctx.lit - # Filter dates for the year date_filtered = date_dim.filter(col("d_year") == lit(year)) - # Join inventory with item, warehouse, date inv_data = ( inventory.join(item, left_on="inv_item_sk", right_on="i_item_sk") .join(warehouse, left_on="inv_warehouse_sk", right_on="w_warehouse_sk") .join(date_filtered, left_on="inv_date_sk", right_on="d_date_sk") ) - # Group by warehouse, item, month and compute stddev and mean - # Note: w_warehouse_sk and i_item_sk are dropped after joins, use preserved left-side keys grouped = inv_data.group_by(["w_warehouse_name", "inv_warehouse_sk", "inv_item_sk", "d_moy"]).agg( [ ctx.std("inv_quantity_on_hand").alias("stdev"), @@ -9202,19 +11121,18 @@ def q39_expression_impl(ctx: DataFrameContext) -> Any: ] ) - # Compute coefficient of variation (cov = stdev/mean) grouped = grouped.with_columns( ctx.when(col("mean") != lit(0)).then(col("stdev") / col("mean")).otherwise(lit(None)).alias("cov") ) - # Filter to items with cov > 1 inv_cov = grouped.filter( ctx.when(col("mean") == lit(0)).then(lit(False)).otherwise(col("stdev") / col("mean") > lit(1)) ) - # Self-join for consecutive months - # Note: Using preserved left-side keys inv_warehouse_sk and inv_item_sk - inv1 = inv_cov.filter(col("d_moy") == lit(month1)).select( + inv1 = inv_cov.filter(col("d_moy") == lit(month1)) + if first_month_min_cov is not None: + inv1 = inv1.filter(col("cov") > lit(first_month_min_cov)) + inv1 = inv1.select( [ col("inv_warehouse_sk").alias("inv1_w_sk"), col("inv_item_sk").alias("inv1_i_sk"), @@ -9231,55 +11149,79 @@ def q39_expression_impl(ctx: DataFrameContext) -> Any: col("d_moy").alias("inv2_moy"), col("mean").alias("inv2_mean"), col("cov").alias("inv2_cov"), + col("inv_warehouse_sk").alias("_join_w_sk"), + col("inv_item_sk").alias("_join_i_sk"), ] ) - return inv1.join( - inv2, - left_on=["inv1_w_sk", "inv1_i_sk"], - right_on=["inv2_w_sk", "inv2_i_sk"], - ).sort(["inv1_w_sk", "inv1_i_sk", "inv1_moy", "inv1_mean", "inv1_cov"]) + return ( + inv1.join( + inv2, + left_on=["inv1_w_sk", "inv1_i_sk"], + right_on=["_join_w_sk", "_join_i_sk"], + ) + .select( + [ + "inv1_w_sk", + "inv1_i_sk", + "inv1_moy", + "inv1_mean", + "inv1_cov", + "inv2_w_sk", + "inv2_i_sk", + "inv2_moy", + "inv2_mean", + "inv2_cov", + ] + ) + .sort(["inv1_w_sk", "inv1_i_sk", "inv1_moy", "inv1_mean", "inv1_cov"]) + ) def q39_pandas_impl(ctx: DataFrameContext) -> Any: """Q39: Inventory variance analysis comparing consecutive months (Pandas).""" + return _q39_pandas(ctx, first_month_min_cov=None) + + +def q39b_pandas_impl(ctx: DataFrameContext) -> Any: + return _q39_pandas(ctx, first_month_min_cov=_Q39B_MIN_FIRST_MONTH_COV) + + +def _q39_pandas(ctx: DataFrameContext, first_month_min_cov: float | None) -> Any: params = get_parameters(39) year = params.get("year", 2001) months = params.get("months", [1, 2]) month1 = months[0] if len(months) > 0 else 1 month2 = months[1] if len(months) > 1 else 2 - # Get tables inventory, item, warehouse, date_dim = _tables(ctx, "inventory", "item", "warehouse", "date_dim") - # Filter dates date_filtered = date_dim[date_dim["d_year"] == year] - # Join tables inv_data = inventory.merge(item, left_on="inv_item_sk", right_on="i_item_sk") inv_data = inv_data.merge(warehouse, left_on="inv_warehouse_sk", right_on="w_warehouse_sk") inv_data = inv_data.merge(date_filtered, left_on="inv_date_sk", right_on="d_date_sk") - # Group and compute stats - grouped = inv_data.groupby(["w_warehouse_name", "w_warehouse_sk", "i_item_sk", "d_moy"], as_index=False).agg( + grouped = inv_data.groupby( + ["w_warehouse_name", "w_warehouse_sk", "i_item_sk", "d_moy"], as_index=False, dropna=False + ).agg( stdev=("inv_quantity_on_hand", "std"), mean=("inv_quantity_on_hand", "mean"), ) - # Compute cov grouped["cov"] = grouped.apply(lambda r: r["stdev"] / r["mean"] if r["mean"] != 0 else None, axis=1) - # Filter cov > 1 inv_cov = grouped[(grouped["mean"] != 0) & (grouped["stdev"] / grouped["mean"] > 1)].copy() - # Get month1 and month2 data - inv1 = inv_cov[inv_cov["d_moy"] == month1][["w_warehouse_sk", "i_item_sk", "d_moy", "mean", "cov"]].copy() + inv1 = inv_cov[inv_cov["d_moy"] == month1] + if first_month_min_cov is not None: + inv1 = inv1[inv1["cov"] > first_month_min_cov] + inv1 = inv1[["w_warehouse_sk", "i_item_sk", "d_moy", "mean", "cov"]].copy() inv1.columns = ["inv1_w_sk", "inv1_i_sk", "inv1_moy", "inv1_mean", "inv1_cov"] inv2 = inv_cov[inv_cov["d_moy"] == month2][["w_warehouse_sk", "i_item_sk", "d_moy", "mean", "cov"]].copy() inv2.columns = ["inv2_w_sk", "inv2_i_sk", "inv2_moy", "inv2_mean", "inv2_cov"] - # Join result = inv1.merge(inv2, left_on=["inv1_w_sk", "inv1_i_sk"], right_on=["inv2_w_sk", "inv2_i_sk"]) return result.sort_values(["inv1_w_sk", "inv1_i_sk", "inv1_moy", "inv1_mean", "inv1_cov"]) @@ -9295,7 +11237,6 @@ def q64_expression_impl(ctx: DataFrameContext) -> Any: year = params.get("year", 1999) colors = params.get("colors", ["slate", "blanched", "burnished", "chartreuse", "peru", "thistle"]) price_min = params.get("price_min", 0) - price_max = params.get("price_max", 85) # Get tables ( @@ -9342,10 +11283,12 @@ def q64_expression_impl(ctx: DataFrameContext) -> Any: cs_with_returns.group_by("cs_item_sk") .agg( [ - ctx.sum("cs_ext_list_price").alias("sale"), - (ctx.sum("cr_refunded_cash") + ctx.sum("cr_reversed_charge") + ctx.sum("cr_store_credit")).alias( - "refund" - ), + _sum_or_null_expression(ctx, ctx.col("cs_ext_list_price")).alias("sale"), + ( + _sum_or_null_expression(ctx, ctx.col("cr_refunded_cash")) + + _sum_or_null_expression(ctx, ctx.col("cr_reversed_charge")) + + _sum_or_null_expression(ctx, ctx.col("cr_store_credit")) + ).alias("refund"), ] ) .filter(col("sale") > lit(2) * col("refund")) @@ -9356,7 +11299,9 @@ def q64_expression_impl(ctx: DataFrameContext) -> Any: item_filtered = item.filter( col("i_color").is_in(colors) & (col("i_current_price") >= lit(price_min)) - & (col("i_current_price") <= lit(price_max + 10)) + & (col("i_current_price") <= lit(price_min + 10)) + & (col("i_current_price") >= lit(price_min + 1)) + & (col("i_current_price") <= lit(price_min + 15)) ) # Join store_sales with store_returns @@ -9408,6 +11353,16 @@ def q64_expression_impl(ctx: DataFrameContext) -> Any: left_on="ss_sold_date_sk", right_on="d_date_sk", ) + .join( + date_dim.select(["d_date_sk", "d_year"]).rename({"d_date_sk": "d2_date_sk", "d_year": "fsyear"}), + left_on="c_first_sales_date_sk", + right_on="d2_date_sk", + ) + .join( + date_dim.select(["d_date_sk", "d_year"]).rename({"d_date_sk": "d3_date_sk", "d_year": "s2year"}), + left_on="c_first_shipto_date_sk", + right_on="d3_date_sk", + ) .join( customer_demographics.select(["cd_demo_sk", "cd_marital_status"]).rename( {"cd_marital_status": "cd2_marital_status", "cd_demo_sk": "cd2_demo_sk"} @@ -9469,13 +11424,15 @@ def q64_expression_impl(ctx: DataFrameContext) -> Any: "c_city", "c_zip", "syear", + "fsyear", + "s2year", ] ).agg( [ ctx.len().alias("cnt"), - ctx.sum("ss_wholesale_cost").alias("s1"), - ctx.sum("ss_list_price").alias("s2"), - ctx.sum("ss_coupon_amt").alias("s3"), + _sum_or_null_expression(ctx, ctx.col("ss_wholesale_cost")).alias("s1"), + _sum_or_null_expression(ctx, ctx.col("ss_list_price")).alias("s2"), + _sum_or_null_expression(ctx, ctx.col("ss_coupon_amt")).alias("s3"), ] ) @@ -9483,7 +11440,8 @@ def q64_expression_impl(ctx: DataFrameContext) -> Any: cs1 = grouped.filter(col("syear") == lit(year)) cs2 = grouped.filter(col("syear") == lit(year + 1)) - return ( + return _sort_null_largest_expression( + ctx, cs1.join( cs2, on=["ss_item_sk", "s_store_name", "s_zip"], @@ -9514,8 +11472,8 @@ def q64_expression_impl(ctx: DataFrameContext) -> Any: col("syear_y2").alias("syear2"), col("cnt_y2").alias("cnt2"), ] - ) - .sort(["i_product_name", "s_store_name", "cnt2", "s1", "s12"]) + ), + ["i_product_name", "s_store_name", "cnt2", "s1", "s12"], ) @@ -9525,7 +11483,6 @@ def q64_pandas_impl(ctx: DataFrameContext) -> Any: year = params.get("year", 1999) colors = params.get("colors", ["slate", "blanched", "burnished", "chartreuse", "peru", "thistle"]) price_min = params.get("price_min", 0) - price_max = params.get("price_max", 85) # Get tables ( @@ -9565,11 +11522,15 @@ def q64_pandas_impl(ctx: DataFrameContext) -> Any: left_on=["cs_item_sk", "cs_order_number"], right_on=["cr_item_sk", "cr_order_number"], ) - cs_agg = cs_cr.groupby("cs_item_sk", as_index=False).agg( - sale=("cs_ext_list_price", "sum"), - refund_cash=("cr_refunded_cash", "sum"), - refund_charge=("cr_reversed_charge", "sum"), - refund_credit=("cr_store_credit", "sum"), + cs_agg = _grouped_pandas_aggregates( + cs_cr, + "cs_item_sk", + { + "sale": ("cs_ext_list_price", "sum"), + "refund_cash": ("cr_refunded_cash", "sum"), + "refund_charge": ("cr_reversed_charge", "sum"), + "refund_credit": ("cr_store_credit", "sum"), + }, ) cs_agg["refund"] = cs_agg["refund_cash"] + cs_agg["refund_charge"] + cs_agg["refund_credit"] cs_ui_items = set(cs_agg[cs_agg["sale"] > 2 * cs_agg["refund"]]["cs_item_sk"]) @@ -9578,7 +11539,9 @@ def q64_pandas_impl(ctx: DataFrameContext) -> Any: item_filtered = item[ item["i_color"].isin(colors) & (item["i_current_price"] >= price_min) - & (item["i_current_price"] <= price_max + 10) + & (item["i_current_price"] <= price_min + 10) + & (item["i_current_price"] >= price_min + 1) + & (item["i_current_price"] <= price_min + 15) ] # Join store_sales with returns @@ -9612,6 +11575,14 @@ def q64_pandas_impl(ctx: DataFrameContext) -> Any: d1.columns = ["d1_date_sk", "syear"] cross_sales = cross_sales.merge(d1, left_on="ss_sold_date_sk", right_on="d1_date_sk") + d2 = date_dim[["d_date_sk", "d_year"]].copy() + d2.columns = ["d2_date_sk", "fsyear"] + cross_sales = cross_sales.merge(d2, left_on="c_first_sales_date_sk", right_on="d2_date_sk") + + d3 = date_dim[["d_date_sk", "d_year"]].copy() + d3.columns = ["d3_date_sk", "s2year"] + cross_sales = cross_sales.merge(d3, left_on="c_first_shipto_date_sk", right_on="d3_date_sk") + cd2 = customer_demographics[["cd_demo_sk", "cd_marital_status"]].copy() cd2.columns = ["cd2_demo_sk", "cd2_marital_status"] cross_sales = cross_sales.merge(cd2, left_on="c_current_cdemo_sk", right_on="cd2_demo_sk") @@ -9636,7 +11607,8 @@ def q64_pandas_impl(ctx: DataFrameContext) -> Any: cross_sales = cross_sales[cross_sales["cd1_marital_status"] != cross_sales["cd2_marital_status"]] # Group - grouped = cross_sales.groupby( + grouped = _grouped_pandas_aggregates( + cross_sales, [ "i_product_name", "i_item_sk", @@ -9651,24 +11623,52 @@ def q64_pandas_impl(ctx: DataFrameContext) -> Any: "c_city", "c_zip", "syear", + "fsyear", + "s2year", ], - as_index=False, - ).agg( - cnt=("ss_item_sk", "count"), - s1=("ss_wholesale_cost", "sum"), - s2=("ss_list_price", "sum"), - s3=("ss_coupon_amt", "sum"), + { + "cnt": ("ss_item_sk", "count"), + "s1": ("ss_wholesale_cost", "sum"), + "s2": ("ss_list_price", "sum"), + "s3": ("ss_coupon_amt", "sum"), + }, + dropna=False, ) - # Self-join for year comparison - cs1 = grouped[grouped["syear"] == year].copy() - cs2 = grouped[grouped["syear"] == year + 1].copy() + join_keys = ["i_item_sk", "s_store_name", "s_zip"] + cs1 = grouped[grouped["syear"] == year].dropna(subset=join_keys) + cs2 = grouped[grouped["syear"] == year + 1].dropna(subset=join_keys) cs2 = cs2.rename(columns={"syear": "syear2", "cnt": "cnt2", "s1": "s12", "s2": "s22", "s3": "s32"}) - cs2 = cs2[["i_item_sk", "s_store_name", "s_zip", "syear2", "cnt2", "s12", "s22", "s32"]] + cs2 = cs2[[*join_keys, "syear2", "cnt2", "s12", "s22", "s32"]] - result = cs1.merge(cs2, on=["i_item_sk", "s_store_name", "s_zip"]) + result = cs1.merge(cs2, on=join_keys) result = result[result["cnt2"] <= result["cnt"]] - return result.sort_values(["i_product_name", "s_store_name", "cnt2", "s1", "s12"]) + result = result.sort_values(["i_product_name", "s_store_name", "cnt2", "s1", "s12"])[ + [ + "i_product_name", + "s_store_name", + "s_zip", + "b_street_number", + "b_street_name", + "b_city", + "b_zip", + "c_street_number", + "c_street_name", + "c_city", + "c_zip", + "syear", + "cnt", + "s1", + "s2", + "s3", + "s12", + "s22", + "s32", + "syear2", + "cnt2", + ] + ] + return _none_for_null(result, list(result.columns)) # ============================================================================= @@ -9680,8 +11680,9 @@ def q84_expression_impl(ctx: DataFrameContext) -> Any: """Q84: Customer lookup filtered by city and income band (Polars).""" params = get_parameters(84) city = params.get("city", "Edgewood") - income_min = params.get("income_min", 38128) - income_max = params.get("income_max", income_min + 50000) + income_min = params.get("income_band", 38128) + # The template bounds the band at [INCOME] + 50000. + income_max = income_min + 50000 # Get tables customer, customer_address, customer_demographics, household_demographics, income_band, store_returns = _tables( @@ -9711,7 +11712,9 @@ def q84_expression_impl(ctx: DataFrameContext) -> Any: .join(customer_demographics, left_on="c_current_cdemo_sk", right_on="cd_demo_sk") .join(household_demographics, left_on="c_current_hdemo_sk", right_on="hd_demo_sk") .join(ib_filtered, left_on="hd_income_band_sk", right_on="ib_income_band_sk") - .join(store_returns, left_on="c_current_cdemo_sk", right_on="sr_cdemo_sk", how="semi") + # An inner join, not a semi join: the SQL repeats a customer once for each return on the same + # demographic row. + .join(store_returns.select("sr_cdemo_sk"), left_on="c_current_cdemo_sk", right_on="sr_cdemo_sk") ) # Select and format output @@ -9736,8 +11739,9 @@ def q84_pandas_impl(ctx: DataFrameContext) -> Any: """Q84: Customer lookup filtered by city and income band (Pandas).""" params = get_parameters(84) city = params.get("city", "Edgewood") - income_min = params.get("income_min", 38128) - income_max = params.get("income_max", income_min + 50000) + income_min = params.get("income_band", 38128) + # The template bounds the band at [INCOME] + 50000. + income_max = income_min + 50000 # Get tables customer, customer_address, customer_demographics, household_demographics, income_band, store_returns = _tables( @@ -9764,9 +11768,9 @@ def q84_pandas_impl(ctx: DataFrameContext) -> Any: result = result.merge(household_demographics, left_on="c_current_hdemo_sk", right_on="hd_demo_sk") result = result.merge(ib_filtered, left_on="hd_income_band_sk", right_on="ib_income_band_sk") - # Filter to customers with store returns (semi-join) - sr_cdemo_sks = set(store_returns["sr_cdemo_sk"].dropna()) - result = result[result["c_current_cdemo_sk"].isin(sr_cdemo_sks)] + # An inner join, not a semi join: the SQL repeats a customer once for each return on the same + # demographic row. + result = result.merge(store_returns[["sr_cdemo_sk"]], left_on="c_current_cdemo_sk", right_on="sr_cdemo_sk") # Format output result["customername"] = result["c_last_name"].fillna("") + ", " + result["c_first_name"].fillna("") diff --git a/benchbox/core/tpcds/dataframe_queries/query_metadata.csv b/benchbox/core/tpcds/dataframe_queries/query_metadata.csv index 20d2a47130..7638509cec 100644 --- a/benchbox/core/tpcds/dataframe_queries/query_metadata.csv +++ b/benchbox/core/tpcds/dataframe_queries/query_metadata.csv @@ -88,12 +88,16 @@ Q66|Web/Catalog Warehouse Monthly Sales|Union of web and catalog sales aggregate Q8|Store Sales Zip Code Net Profit|Analyzes store net profit filtered by preferred customer zip codes|M,A,Q,T Q9|Store Sales Quantity Bucket Analysis|Computes conditional averages across quantity buckets|A,N,T Q14|Cross-channel Item Sales|Finds items sold across all channels with ROLLUP aggregation|M,A,N,T +Q14b|Cross-channel Item Sales Week over Week|Compares one week of store sales of cross-channel items with the same week a year earlier|M,A,N,T Q23|Frequent Items Best Customers|Sum of catalog+web sales for frequent items by best customers|M,A,Q,T +Q23b|Frequent Items Best Customers by Name|Catalog+web sales of frequent items to the best customers, per customer name|M,A,Q,T Q24|Store Sales Returns by Color|Store sales with returns filtered by market and color threshold|M,A,Q,T +Q24b|Store Sales Returns by Second Color|Store sales with returns filtered by market and the second color threshold|M,A,Q,T Q28|Store Sales Quantity Bucket Extended|Extended price analysis across 6 quantity buckets with filters|A,N,T Q88|Store Sales Time Period Counts|Counts store sales by 8 half-hour time periods with demographics|M,A,N,T Q21|Inventory Before/After Date|Compares inventory quantities before and after a specific date by warehouse|M,A,N,T Q22|Inventory ROLLUP by Product|Analyzes inventory with ROLLUP by product name, brand, class, category|M,A,N,T Q39|Inventory Variance Monthly|Compares inventory variance (coefficient of variation) across consecutive months|M,A,N,T +Q39b|Inventory Variance Monthly High Variation|Q39 restricted to first-month coefficients of variation above 1.5|M,A,N,T Q64|Cross-Sales Income Band|Complex cross-sales analysis with income band filtering and year-over-year comparison|M,A,Q,N,T Q84|Customer Income Band Lookup|Customer lookup filtered by city and income band with store returns|M,Q,T diff --git a/benchbox/core/tpcds/dataframe_queries/query_specs.yaml b/benchbox/core/tpcds/dataframe_queries/query_specs.yaml index 6110042994..adb0ecbc33 100644 --- a/benchbox/core/tpcds/dataframe_queries/query_specs.yaml +++ b/benchbox/core/tpcds/dataframe_queries/query_specs.yaml @@ -9,7 +9,7 @@ joined_aggregate: - [customer_address, c_current_addr_sk, ca_address_sk] - [store, ss_store_sk, s_store_sk] filters: - - [eq, i_manager_id, {param: manager_id, default: 8}] + - [eq, i_manager_id, {param: manager_id, default: 7}] - [eq, d_moy, {param: month, default: 11}] - [eq, d_year, {param: year, default: 1999}] - [prefix_ne_cols, ca_zip, s_zip, 5] @@ -27,93 +27,13 @@ joined_aggregate: - [date_dim, cs_sold_date_sk, d_date_sk] filters: - [eq, d_qoy, {param: quarter, default: 2}] - - [eq, d_year, {param: year, default: 2001}] - - [prefix_eq, ca_zip, 2, {param: zip_prefix, default: "85"}] + - [eq, d_year, {param: year, default: 2000}] + # The template draws only the quarter and the year; the zip codes, states and price floor are fixed in its SQL. + - [or, [substr_in, ca_zip, 5, ["85669", "86197", "88274", "83405", "86475", "85392", "85460", "80348", "81792"]], [in, ca_state, [CA, WA, GA]], [gt, cs_sales_price, 500]] group_by: [ca_zip] aggs: - [total_sales, cs_sales_price, sum] sort_by: [ca_zip] - - query_id: 43 - title: Store Sales Day Analysis - base: date_dim - joins: - - [store_sales, d_date_sk, ss_sold_date_sk] - - [store, ss_store_sk, s_store_sk] - filters: - - [eq, s_gmt_offset, {param: gmt_offset, default: -5.0}] - - [eq, d_year, {param: year, default: 2000}] - group_by: [s_store_name, s_store_id, d_day_name] - aggs: - - [total_sales, ss_sales_price, sum] - sort_by: [s_store_name, s_store_id, d_day_name] - - query_id: 46 - title: Store Sales Household Analysis - base: store_sales - joins: - - [date_dim, ss_sold_date_sk, d_date_sk] - - [store, ss_store_sk, s_store_sk] - - [household_demographics, ss_hdemo_sk, hd_demo_sk] - - [customer_address, ss_addr_sk, ca_address_sk] - - [customer, ss_customer_sk, c_customer_sk] - filters: - - [eq, d_year, {param: year, default: 2001}] - - [between, d_dom, 1, 2] - - [in, ca_city, {param: cities, default: [Fairview, Midway, Fairview, Fairview, Fairview]}] - - [or, [eq, hd_dep_count, 5], [eq, hd_vehicle_count, 3]] - group_by: [c_last_name, c_first_name, ca_city, ss_ticket_number, ss_coupon_amt, ss_net_profit] - aggs: - - [bought_city, ss_ext_sales_price, sum] - sort_by: [c_last_name, c_first_name, ca_city, ss_ticket_number] - - query_id: 50 - title: Store Sales Returns Analysis - base: store_sales - joins: - - [store_returns, [ss_item_sk, ss_customer_sk, ss_ticket_number], [sr_item_sk, sr_customer_sk, sr_ticket_number]] - - [store, ss_store_sk, s_store_sk] - - [date_dim, sr_returned_date_sk, d_date_sk] - filters: - - [eq, d_year, {param: year, default: 2001}] - - [eq, d_moy, 8] - group_by: [s_store_name, s_company_id, s_street_number, s_street_name, s_street_type] - aggs: - - [return_amt, sr_return_amt, sum] - - [net_loss, sr_net_loss, sum] - sort_by: [s_store_name, s_company_id, return_amt] - descending: [false, false, true] - - query_id: 65 - title: Store Sales Item Profit - base: store_sales - joins: - - [store, ss_store_sk, s_store_sk] - - [item, ss_item_sk, i_item_sk] - - [date_dim, ss_sold_date_sk, d_date_sk] - filters: - - [eq, d_year, {param: year, default: 2000}] - - [between, d_dom, 1, 15] - group_by: [s_store_name, i_item_desc] - aggs: - - [revenue, ss_sales_price, sum] - sort_by: [revenue, s_store_name, i_item_desc] - descending: [true, false, false] - - query_id: 68 - title: Store Sales Customer Household - base: store_sales - joins: - - [date_dim, ss_sold_date_sk, d_date_sk] - - [store, ss_store_sk, s_store_sk] - - [household_demographics, ss_hdemo_sk, hd_demo_sk] - - [customer_address, ss_addr_sk, ca_address_sk] - filters: - - [eq, d_year, {param: year, default: 2001}] - - [between, d_dom, 1, 2] - - [in, ca_city, {param: cities, default: [Midway, Fairview]}] - - [or, [eq, hd_dep_count, 4], [eq, hd_vehicle_count, 3]] - group_by: [ss_ticket_number, ss_customer_sk, ss_addr_sk, ca_city] - aggs: - - [extended_price, ss_ext_sales_price, sum] - - [list_price, ss_ext_list_price, sum] - - [extended_tax, ss_ext_tax, sum] - sort_by: [ss_ticket_number] - query_id: 73 title: Store Sales Household Vehicle base: store_sales @@ -123,48 +43,20 @@ joined_aggregate: - [household_demographics, ss_hdemo_sk, hd_demo_sk] - [customer, ss_customer_sk, c_customer_sk] filters: - - [in, d_year, {param: year, default: 1999, offsets: [0, 1, 2]}] + - [in, d_year, {param: year, default: 1998, offsets: [0, 1, 2]}] - [between, d_dom, 1, 2] - - [eq, s_county, {param: county, default: Williamson County}] - - [in, hd_buy_potential, [">10000", Unknown]] + # The template draws four store counties (COUNTY_A to COUNTY_D, which may repeat) and two buying potentials. + - [in, s_county, {param: counties, default: [Williamson County, Williamson County, Williamson County, Williamson County]}] + - [or, [eq, hd_buy_potential, {param: buy_potential_1, default: ">10000"}], [eq, hd_buy_potential, {param: buy_potential_2, default: Unknown}]] - [gt, hd_vehicle_count, 0] + - [ratio_gt, hd_dep_count, hd_vehicle_count, 1] group_by: [c_last_name, c_first_name, c_salutation, c_preferred_cust_flag, ss_ticket_number] aggs: - [cnt, ss_ticket_number, count] post_filter: [between, cnt, 1, 5] - sort_by: [c_last_name, c_first_name] - - query_id: 79 - title: Store Sales Customer/Store Profit - base: store_sales - joins: - - [date_dim, ss_sold_date_sk, d_date_sk] - - [store, ss_store_sk, s_store_sk] - - [household_demographics, ss_hdemo_sk, hd_demo_sk] - - [customer, ss_customer_sk, c_customer_sk] - filters: - - [eq, d_year, {param: year, default: 2000}] - - [between, d_dom, 1, 2] - - [or, [eq, hd_dep_count, {param: household_dep_count, default: 6}], [gt, hd_vehicle_count, 0]] - group_by: [c_last_name, c_first_name, ss_ticket_number, s_city] - aggs: - - [amt, ss_coupon_amt, sum] - - [profit, ss_net_profit, sum] - sort_by: [c_last_name, c_first_name, s_city, profit] - descending: [false, false, false, true] - - query_id: 89 - title: Store Item Sales Profit - base: store_sales - joins: - - [date_dim, ss_sold_date_sk, d_date_sk] - - [store, ss_store_sk, s_store_sk] - - [item, ss_item_sk, i_item_sk] - filters: - - [eq, d_year, {param: year, default: 1999}] - - [or, [and, [eq, i_category, Books], [eq, i_class, business]], [and, [eq, i_category, Electronics], [eq, i_class, portable]]] - group_by: [i_category, i_class, i_brand, s_store_name, s_company_name, d_moy] - aggs: - - [sum_sales, ss_sales_price, sum] - sort_by: [sum_sales, s_store_name, i_category] + sort_by: [cnt, c_last_name] + descending: [true, false] + limit: - query_id: 91 title: Call Center Returns Analysis base: catalog_returns @@ -176,10 +68,10 @@ joined_aggregate: - [household_demographics, c_current_hdemo_sk, hd_demo_sk] - [customer_address, c_current_addr_sk, ca_address_sk] filters: - - [eq, d_year, {param: year, default: 1998}] + - [eq, d_year, {param: year, default: 1999}] - [eq, d_moy, {param: month, default: 11}] - [or, [and, [eq, cd_marital_status, M], [eq, cd_education_status, Unknown]], [and, [eq, cd_marital_status, W], [eq, cd_education_status, "Advanced Degree"]]] - - [starts_with, hd_buy_potential, {param: buy_potential, default: Unknown}] + - [starts_with, hd_buy_potential, {param: buy_potential, default: 0-500}] - [eq, ca_gmt_offset, {param: gmt_offset, default: -7}] group_by: [cc_call_center_id, cc_name, cc_manager, cd_marital_status, cd_education_status] aggs: @@ -187,6 +79,7 @@ joined_aggregate: sort_by: [returns_loss] descending: [true] limit: + select: [cc_call_center_id, cc_name, cc_manager, returns_loss] state_average_returns: - query_id: 30 @@ -197,7 +90,7 @@ state_average_returns: return_customer_key: wr_returning_customer_sk return_amount: wr_return_amt year_default: 2002 - state_default: GA + state_default: IL expression_select: [c_customer_id, c_salutation, c_first_name, c_last_name, c_preferred_cust_flag, c_birth_day, c_birth_month, c_birth_year, c_birth_country, c_login, c_email_address, c_last_review_date_sk, ctr_total_return] pandas_select: [c_customer_id, c_salutation, c_first_name, c_last_name, c_preferred_cust_flag, c_birth_day, c_birth_month, c_birth_year, c_birth_country, c_login, c_email_address, c_last_review_date_sk, ctr_total_return] - query_id: 81 @@ -207,8 +100,8 @@ state_average_returns: return_addr_key: cr_returning_addr_sk return_customer_key: cr_returning_customer_sk return_amount: cr_return_amt_inc_tax - year_default: 2000 - state_default: GA + year_default: 1998 + state_default: IL expression_select: - c_customer_id - c_salutation @@ -220,10 +113,10 @@ state_average_returns: - ca_suite_number - ca_city - ca_county - - {column: ca_state, alias: ca_state_out} + - ca_state - ca_zip - ca_country - ca_gmt_offset - ca_location_type - ctr_total_return - pandas_select: [c_customer_id, c_salutation, c_first_name, c_last_name, ca_street_number, ca_street_name, ca_street_type, ca_suite_number, ca_city, ca_county, ca_state_y, ca_zip, ca_country, ca_gmt_offset, ca_location_type, ctr_total_return] + pandas_select: [c_customer_id, c_salutation, c_first_name, c_last_name, ca_street_number, ca_street_name, ca_street_type, ca_suite_number, ca_city, ca_county, ca_state, ca_zip, ca_country, ca_gmt_offset, ca_location_type, ctr_total_return] diff --git a/benchbox/core/tpcds/dataframe_queries/rollup_helper.py b/benchbox/core/tpcds/dataframe_queries/rollup_helper.py index 8859bf1e78..87331931af 100644 --- a/benchbox/core/tpcds/dataframe_queries/rollup_helper.py +++ b/benchbox/core/tpcds/dataframe_queries/rollup_helper.py @@ -22,18 +22,22 @@ from __future__ import annotations -import contextlib +import logging from typing import TYPE_CHECKING, Any if TYPE_CHECKING: pass +logger = logging.getLogger(__name__) + def expand_rollup_expression( df: Any, group_cols: list[str], agg_exprs: list[Any], ctx: Any, + *, + count_sources: dict[str, str] | None = None, ) -> Any: """Expand ROLLUP into multiple GROUP BYs for expression-family DataFrames. @@ -50,6 +54,11 @@ def expand_rollup_expression( group_cols: Columns to include in ROLLUP (order matters) agg_exprs: List of aggregation expressions ctx: DataFrameContext for platform-specific operations + count_sources: Optional mapping of agg output name to its source column. + A guarded output is restored to NULL when its group has zero + non-null inputs, matching SQL (where SUM/AVG over all-NULL inputs + is NULL, not 0.0). Outputs without an entry keep the legacy + behavior, so existing callers are unaffected. Returns: Combined DataFrame with all rollup levels @@ -57,11 +66,12 @@ def expand_rollup_expression( from benchbox.platforms.dataframe.unified_frame import UnifiedExpr, UnifiedLazyFrame lit = ctx.lit + col = ctx.col + count_sources = count_sources or {} results = [] n = len(group_cols) - def get_output_name(expr: Any) -> str: - """Get the alias/output name from an aggregation expression.""" + def get_output_name(expr: Any) -> str | None: # Unwrap UnifiedExpr if needed native = expr.native if isinstance(expr, UnifiedExpr) else expr # Polars - use meta.output_name() @@ -80,18 +90,45 @@ def get_output_name(expr: Any) -> str: return name.strip("`") except Exception: pass - # Fallback - return f"agg_{id(expr)}" - - agg_col_names = [get_output_name(e) for e in agg_exprs] + if hasattr(native, "schema_name"): + try: + return native.schema_name() + except Exception: + pass + return None + + resolved_names = [get_output_name(e) for e in agg_exprs] + names_resolved = all(name is not None for name in resolved_names) + agg_col_names = [name if name is not None else f"agg_{id(e)}" for name, e in zip(resolved_names, agg_exprs)] + if not names_resolved: + unresolved = next(e for name, e in zip(resolved_names, agg_exprs) if name is None) + logger.warning( + "ROLLUP: cannot read the output name of a %s aggregation expression; " + "skipping the column reorder, so levels are unioned in their own column order", + type(unresolved.native if isinstance(unresolved, UnifiedExpr) else unresolved).__name__, + ) for i in range(n + 1): # Columns to group by at this level current_group = group_cols[: n - i] + # Companion non-null counts for NULL-guarded outputs. The count shares + # the level's grouping so the guard below sees the same groups. + guarded = {out: src for out, src in count_sources.items() if out in agg_col_names} + count_exprs = [col(source).count().alias(f"__n_{output}") for output, source in guarded.items()] + # Perform aggregation - use *args not list # Grand total (empty group) uses select for aggregation - grouped = df.group_by(*current_group).agg(*agg_exprs) if current_group else df.select(*agg_exprs) + if current_group: + grouped = df.group_by(*current_group).agg(*agg_exprs, *count_exprs) + else: + grouped = df.select(*agg_exprs, *count_exprs) + + # Restore NULL for guarded outputs whose group has zero non-null inputs. + for output in guarded: + grouped = grouped.with_columns( + ctx.when(col(f"__n_{output}") > lit(0)).then(col(output)).otherwise(lit(None)).alias(output) + ) # Add NULL for rolled-up columns rolled_up_cols = group_cols[n - i :] @@ -103,9 +140,8 @@ def get_output_name(expr: Any) -> str: grouping_id = sum(2**j for j in range(i)) grouped = grouped.with_columns(lit(grouping_id).alias("grouping_id")) - # Reorder columns to consistent layout (if column ordering fails, continue with current layout) - all_cols = group_cols + agg_col_names + ["grouping_id"] - with contextlib.suppress(Exception): + if names_resolved: + all_cols = group_cols + agg_col_names + ["grouping_id"] # Use *args for UnifiedLazyFrame, list for native grouped = grouped.select(*all_cols) if isinstance(grouped, UnifiedLazyFrame) else grouped.select(all_cols) @@ -120,6 +156,8 @@ def expand_rollup_pandas( group_cols: list[str], agg_dict: dict[str, tuple[str, str]], ctx: Any, + *, + count_sources: dict[str, str] | None = None, ) -> Any: """Expand ROLLUP into multiple GROUP BYs for pandas-family DataFrames. @@ -131,6 +169,11 @@ def expand_rollup_pandas( agg_dict: Dictionary mapping output column names to (input_col, agg_func) tuples Example: {"sum_sales": ("sales", "sum"), "avg_qty": ("qty", "mean")} ctx: DataFrameContext for platform-specific operations + count_sources: Optional mapping of agg output name to its source column. + A guarded output is restored to None when its group has zero + non-null inputs, matching SQL (where SUM/AVG over all-NULL inputs + is NULL, not 0.0). Outputs without an entry keep the legacy + behavior, so existing callers are unaffected. Returns: Combined DataFrame with all rollup levels @@ -142,6 +185,7 @@ def expand_rollup_pandas( # Get adapter for platform-specific operations (handles Dask as_index=False) adapter = ctx._adapter if hasattr(ctx, "_adapter") else None + count_sources = count_sources or {} results = [] n = len(group_cols) @@ -150,21 +194,28 @@ def expand_rollup_pandas( # Columns to group by at this level current_group = group_cols[: n - i] + # Companion non-null counts for NULL-guarded outputs. + guarded = {out: src for out, src in count_sources.items() if out in agg_dict} + count_spec = {f"__n_{out}": (src, "count") for out, src in guarded.items()} + # Perform aggregation if current_group: # Build pandas-style aggregation agg_spec = {out_col: (in_col, func) for out_col, (in_col, func) in agg_dict.items()} - # Use adapter's groupby_agg which handles Dask's lack of as_index support + agg_spec.update(count_spec) + # Use adapter's groupby_agg which handles Dask's lack of as_index support. + # SQL GROUP BY keeps NULL keys as a group; pass dropna=False so NaN + # keys (e.g. a NULL dimension id) survive like the reference query. if adapter is not None: - grouped = adapter.groupby_agg(native_df, current_group, agg_spec, as_index=False) + grouped = adapter.groupby_agg(native_df, current_group, agg_spec, as_index=False, dropna=False) else: # Fallback for non-pandas contexts (shouldn't happen in practice) - grouped = native_df.groupby(current_group, as_index=False).agg(**agg_spec) + grouped = native_df.groupby(current_group, as_index=False, dropna=False).agg(**agg_spec) else: # Grand total - aggregate entire DataFrame # For Dask, we need to compute scalars differently result_data = {} - for out_col, (in_col, func) in agg_dict.items(): + for out_col, (in_col, func) in {**agg_dict, **count_spec}.items(): col_data = native_df[in_col] if func == "sum": val = col_data.sum() @@ -188,6 +239,25 @@ def expand_rollup_pandas( grouped = pd.DataFrame(result_data) + # Restore None for guarded outputs whose group has zero non-null inputs. + # Assigning None into a float column coerces back to NaN, so build + # explicit object Series. + import pandas as _pd + + for output in guarded: + grouped[output] = _pd.Series( + [value if count > 0 else None for value, count in zip(grouped[output], grouped[f"__n_{output}"])], + dtype=object, + index=grouped.index, + ) + grouped = grouped.drop(columns=list(count_spec)) + + # Pandas materializes a None group key as NaN, which the order-key + # grouping distinguishes from SQL NULL: map NaN back to None so NULL + # keys group and compare equal across rollup levels. + for group_col in current_group: + grouped[group_col] = grouped[group_col].astype(object).where(grouped[group_col].notna(), None) + # Add NULL for rolled-up columns rolled_up_cols = group_cols[n - i :] for null_col in rolled_up_cols: diff --git a/benchbox/core/tpcds/generator/filesystem.py b/benchbox/core/tpcds/generator/filesystem.py index f15f15ae34..26b46052de 100644 --- a/benchbox/core/tpcds/generator/filesystem.py +++ b/benchbox/core/tpcds/generator/filesystem.py @@ -378,6 +378,14 @@ def _write_manifest(self, output_dir: Path, table_paths: dict[str, list[Path]]) } ) + # A manifest naming no tables, or only tables without files, would be + # reused by later runs and load nothing, so generation fails instead of + # recording it. + if not any(manifest["tables"].values()): + raise RuntimeError( + f"TPC-DS data generation produced no table files in {output_dir}; refusing to write a manifest that lists no tables" + ) + out = output_dir / "_datagen_manifest.json" with open(out, "w", encoding="utf-8") as f: diff --git a/benchbox/core/tpcds/generator/runner.py b/benchbox/core/tpcds/generator/runner.py index 7f777f585c..bae98cb89e 100644 --- a/benchbox/core/tpcds/generator/runner.py +++ b/benchbox/core/tpcds/generator/runner.py @@ -9,6 +9,7 @@ import subprocess from pathlib import Path +from benchbox.core.tpcds.c_tools import tpcds_option from benchbox.utils.printing import emit from benchbox.utils.tpc_compilation import CompilationStatus, ensure_tpc_binaries @@ -169,11 +170,11 @@ def _run_file_based_dsdgen(self, output_dir: Path) -> None: """ cmd = [ str(self.dsdgen_exe), - "-verbose", # verbose output - "-force", # force overwrites - "-terminate", + tpcds_option("verbose"), # verbose output + tpcds_option("force"), # force overwrites + tpcds_option("terminate"), "n", # disable trailing field delimiters - "-scale", + tpcds_option("scale"), str(self.scale_factor), # scale factor ] @@ -319,15 +320,15 @@ def generate_chunk(chunk_id: int) -> None: """Generate a specific chunk of data.""" cmd = [ str(self.dsdgen_exe), - "-verbose", # verbose output - "-force", # force overwrites - "-terminate", + tpcds_option("verbose"), # verbose output + tpcds_option("force"), # force overwrites + tpcds_option("terminate"), "n", # disable trailing field delimiters - "-scale", + tpcds_option("scale"), str(self.scale_factor), # scale factor - "-child", + tpcds_option("child"), str(chunk_id), # chunk number (1-based) - "-parallel", + tpcds_option("parallel"), str(self.parallel), # total number of chunks ] diff --git a/benchbox/core/tpcds/generator/streaming.py b/benchbox/core/tpcds/generator/streaming.py index d6fb7949f5..5502c7b9f2 100644 --- a/benchbox/core/tpcds/generator/streaming.py +++ b/benchbox/core/tpcds/generator/streaming.py @@ -7,6 +7,7 @@ import subprocess from pathlib import Path +from benchbox.core.tpcds.c_tools import tpcds_option from benchbox.utils.printing import emit @@ -42,13 +43,13 @@ def _generate_single_table_streaming(self, output_dir: Path, table_name: str) -> """Generate a single table to .dat then compress (robust path).""" cmd = [ str(self.dsdgen_exe), - "-verbose" if self.verbose else "-quiet", - "-force", - "-terminate", + tpcds_option("verbose") if self.verbose else tpcds_option("quiet"), + tpcds_option("force"), + tpcds_option("terminate"), "n", - "-scale", + tpcds_option("scale"), str(self.scale_factor), - "-table", + tpcds_option("table"), table_name, ] expected_filename = f"{table_name}.dat" @@ -135,13 +136,13 @@ def _generate_parent_table_with_children( # We need to generate to files first, then compress them to maintain data integrity cmd = [ str(self.dsdgen_exe), - "-verbose" if self.verbose else "-quiet", - "-force", # force overwrites - "-terminate", + tpcds_option("verbose") if self.verbose else tpcds_option("quiet"), + tpcds_option("force"), # force overwrites + tpcds_option("terminate"), "n", # disable trailing field delimiters - "-scale", + tpcds_option("scale"), str(self.scale_factor), - "-table", + tpcds_option("table"), parent_table, # generate parent table (and children automatically) ] @@ -217,19 +218,19 @@ def _generate_single_table_chunk_streaming(self, output_dir: Path, table_name: s """ cmd = [ str(self.dsdgen_exe), - "-verbose" if self.verbose else "-quiet", - "-force", - "-terminate", + tpcds_option("verbose") if self.verbose else tpcds_option("quiet"), + tpcds_option("force"), + tpcds_option("terminate"), "n", - "-scale", + tpcds_option("scale"), str(self.scale_factor), - "-table", + tpcds_option("table"), table_name, - "-child", + tpcds_option("child"), str(chunk_id), - "-parallel", + tpcds_option("parallel"), str(self.parallel), - "-FILTER", + tpcds_option("filter"), "Y", ] @@ -381,17 +382,17 @@ def _generate_parent_table_chunk_with_children( # Generate parent table chunk (which automatically creates child tables) cmd = [ str(self.dsdgen_exe), - "-verbose" if self.verbose else "-quiet", - "-force", # force overwrites - "-terminate", + tpcds_option("verbose") if self.verbose else tpcds_option("quiet"), + tpcds_option("force"), # force overwrites + tpcds_option("terminate"), "n", # disable trailing field delimiters - "-scale", + tpcds_option("scale"), str(self.scale_factor), - "-table", + tpcds_option("table"), parent_table, # generate parent table (creates children automatically) - "-child", + tpcds_option("child"), str(chunk_id), # chunk number (1-based) - "-parallel", + tpcds_option("parallel"), str(self.parallel), ] diff --git a/benchbox/core/tpcds/parameter_log.py b/benchbox/core/tpcds/parameter_log.py new file mode 100644 index 0000000000..720dd93d75 --- /dev/null +++ b/benchbox/core/tpcds/parameter_log.py @@ -0,0 +1,122 @@ +"""Parser for the substitution parameters that dsqgen records with ``-LOG``. + +dsqgen writes one block per stream and template:: + + BEGIN STREAM 0 + Template: query39.tpl + YEAR.01 = 2002 + MONTH.01 = 4 + ... + END STREAM 0 + +The values are the ones substituted into the generated SQL, so they are the +authoritative source for DataFrame implementations that must use the same +parameters as the SQL. The log records base variables only: an expression such +as ``[MONTH]+1`` in a template appears as ``MONTH.01`` here. + +The per-stream query ordering in the same log is consumed by +``benchbox.core.tpcds.streams``; this module reads the parameter lines. + +Copyright 2026 Joe Harris / BenchBox Project + +TPC Benchmark(TM) DS (TPC-DS) - Copyright (c) Transaction Processing Performance Council + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Optional + +from benchbox.core.tpcds.streams import _DSQGEN_BEGIN_STREAM_RE + +# dsqgen names a variant template by its path (``../query_variants/query5a.tpl``) and a base template by its file name. +_TEMPLATE_RE = re.compile(r"^Template:\s*(?:\S*[/\\])?query(\d+)([ab]?)\.tpl\s*$", re.IGNORECASE) +_END_STREAM_RE = re.compile(r"^END STREAM\s+(\d+)\s*$", re.IGNORECASE) +_PARAMETER_RE = re.compile(r"^(\S+)\s*=\s*(.*)$") + + +@dataclass(frozen=True) +class TemplateParameters: + """Substituted parameters for one template in one dsqgen stream. + + ``values`` maps each logged name (for example ``YEAR.01``) to its string + value, in log order. Names that start with an underscore are dsqgen's own + bookkeeping (``_LIMIT.01``, ``_END.01``); ``substitutions`` omits them. + """ + + stream: int + query_id: int + variant: Optional[str] + values: dict[str, str] = field(default_factory=dict) + + @property + def substitutions(self) -> dict[str, str]: + """Template variables only, without dsqgen's underscore-prefixed bookkeeping.""" + return {name: value for name, value in self.values.items() if not name.startswith("_")} + + +def parse_dsqgen_parameter_log(log_text: str) -> dict[int, list[TemplateParameters]]: + """Parse a dsqgen ``-LOG`` file into per-stream template parameters. + + Returns a mapping from stream number to the templates of that stream in log + order. Malformed input raises ``ValueError`` rather than being skipped, so a + truncated or unexpected log cannot silently yield partial parameters. + """ + streams: dict[int, list[TemplateParameters]] = {} + current_stream: Optional[int] = None + current: Optional[TemplateParameters] = None + + for line_number, raw_line in enumerate(log_text.splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + + begin = _DSQGEN_BEGIN_STREAM_RE.match(line) + if begin: + if current_stream is not None: + raise ValueError( + f"dsqgen log line {line_number}: BEGIN STREAM {begin.group(1)} before END STREAM {current_stream}" + ) + current_stream = int(begin.group(1)) + if current_stream in streams: + raise ValueError(f"dsqgen log line {line_number}: stream {current_stream} appears twice") + streams[current_stream] = [] + current = None + continue + + end = _END_STREAM_RE.match(line) + if end: + if current_stream is None or int(end.group(1)) != current_stream: + raise ValueError(f"dsqgen log line {line_number}: END STREAM {end.group(1)} without matching BEGIN") + current_stream = None + current = None + continue + + template = _TEMPLATE_RE.match(line) + if template: + if current_stream is None: + raise ValueError(f"dsqgen log line {line_number}: template outside a stream: {line!r}") + current = TemplateParameters( + stream=current_stream, + query_id=int(template.group(1)), + variant=template.group(2) or None, + ) + streams[current_stream].append(current) + continue + + parameter = _PARAMETER_RE.match(line) + if parameter and current is not None: + name, value = parameter.group(1), parameter.group(2).strip() + if name in current.values: + raise ValueError(f"dsqgen log line {line_number}: {name} repeated in query{current.query_id}") + current.values[name] = value + continue + + raise ValueError(f"dsqgen log line {line_number}: unrecognized content {line!r}") + + if current_stream is not None: + raise ValueError(f"dsqgen log ended inside stream {current_stream} (truncated log)") + return streams diff --git a/benchbox/core/tpcds/power_test.py b/benchbox/core/tpcds/power_test.py index 9e7a9c3506..160fe45569 100644 --- a/benchbox/core/tpcds/power_test.py +++ b/benchbox/core/tpcds/power_test.py @@ -25,6 +25,10 @@ from benchbox.utils.clock import elapsed_seconds, mono_time +def power_parameter_seed(seed: Optional[int], stream_id: int) -> int: + return (seed or 1) + stream_id + 1000 + + @dataclass class TPCDSPowerTestConfig: """Configuration for TPC-DS Power Test.""" @@ -192,7 +196,7 @@ def run(self) -> TPCDSPowerTestResult: try: connection = self.connection_factory() # Per TPC-DS spec: all queries in a stream share one parameter seed. - stream_param_seed = self.config.seed + self.config.stream_id + 1000 + stream_param_seed = power_parameter_seed(self.config.seed, self.config.stream_id) if self.config.verbose: self.logger.info(f"Using parameter seed {stream_param_seed} for stream {self.config.stream_id}") @@ -472,7 +476,7 @@ def _preflight_validate_generation(self, available_query_ids: list[int]) -> None # Use same parameter seed calculation as main execution loop # Per TPC-DS spec: all queries in a stream use the same parameter seed - stream_param_seed = self.config.seed + self.config.stream_id + 1000 + stream_param_seed = power_parameter_seed(self.config.seed, self.config.stream_id) for position, item in enumerate(sequence): query_id, variant = item if isinstance(item, tuple) else (item, None) diff --git a/benchbox/core/tpcds/qualification/__init__.py b/benchbox/core/tpcds/qualification/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/benchbox/core/tpcds/qualification/inputs.json b/benchbox/core/tpcds/qualification/inputs.json new file mode 100644 index 0000000000..89b320b663 --- /dev/null +++ b/benchbox/core/tpcds/qualification/inputs.json @@ -0,0 +1,790 @@ +{ + "specification": "TPC-DS 4.0.0", + "parameters_sha256": "c6f15da0a0a67cc3f6e8d150ad1b67e4f01d83e98528d12e03f74a1afb7f0808", + "answers": { + "1.ans": { + "sha256": "4a884f5f384cc1921b911fda947f39c6bb1fbfd5ddfb6dfdda8b613cee25b3ac", + "null_tokens": [] + }, + "10.ans": { + "sha256": "1d9b2cff3242a8afea3c8cf10402d06d07738904c146658235d7073e99483c9e", + "null_tokens": [] + }, + "11.ans": { + "sha256": "a6ec36a18ccbef613e2801631a906187ae31404c6a9dd3e18861594b520c74a4", + "null_tokens": [] + }, + "12.ans": { + "sha256": "04e5e9cdb0f7fc9beea617c7eb93d4d68ce8b290abdc8dcd32c5b9ef2fa09b5e", + "null_tokens": [] + }, + "13.ans": { + "sha256": "33933aeb498a5f90abafd5a431b5e99ad88cdf7e37c9bba7d5a1da99b1ec2775", + "null_tokens": [] + }, + "14_NULLS_FIRST.ans": { + "sha256": "3511018e77e4126ca64b5e720538e5e87d0bfd30827bdf2f5b0d897ecd0babf8", + "null_tokens": [] + }, + "14_NULLS_LAST.ans": { + "sha256": "da9b89e97842a4dd53798c5f4d36e3da9fd3053333cd2a20961a3c64bac4f8e3", + "null_tokens": [] + }, + "15_NULLS_FIRST.ans": { + "sha256": "700b9ef7f745ac1c5f93179662bffbe1b4e895d3da9dbc4607af0d73ee245bed", + "null_tokens": [ + "NULL" + ] + }, + "15_NULLS_LAST.ans": { + "sha256": "b8837e0fc470981310c6e0bdfbf89848a82a8fd7817b53d890d01259eabd6d26", + "null_tokens": [] + }, + "16.ans": { + "sha256": "f820703d324b6bbcc137b8cc1206a0daa5b5992a610593f3af2fe2ee943fe835", + "null_tokens": [] + }, + "17.ans": { + "sha256": "6c2cef17ab183a4c833a4367269d0ca8c1016b6ed2ad81dd6677f7b94335f967", + "null_tokens": [] + }, + "18_NULLS_FIRST.ans": { + "sha256": "45252866306f9968182791123f1d4d1076526d3eb40881c98c711116c3bdc17e", + "null_tokens": [] + }, + "18_NULLS_LAST.ans": { + "sha256": "e71a96a815fb78ae0c22333c05afb3aea27ad48d9c651d3cf7449f3c6ccc4f2c", + "null_tokens": [] + }, + "19.ans": { + "sha256": "e3809b350de8af7ecba72144b8d75ef52ed193ab080034cb6e102faed0a03f94", + "null_tokens": [] + }, + "2.ans": { + "sha256": "7eb895105fec2ddea6075f2fcd8d22d64621e9b1fd677c6bb181a5ab1c5a064e", + "null_tokens": [] + }, + "20_NULLS_FIRST.ans": { + "sha256": "1659768acd1540ff32d7136562b4cd4e6a4a5440a288fca16598105b71dccfb9", + "null_tokens": [] + }, + "20_NULLS_LAST.ans": { + "sha256": "0950751f1721aa338ab68ff7d5638baa8de7fae3badd01eda8e54231a68bfd1a", + "null_tokens": [] + }, + "21_NULLS_FIRST.ans": { + "sha256": "9c51843828382111629f34a409bddcad76e31937a9b5e2db2377297bbd3ec0c0", + "null_tokens": [] + }, + "21_NULLS_LAST.ans": { + "sha256": "bfa3a93eacb3cc59a6da89861f1f2b6b6629561a23dc2e0bc9c04d7149e07053", + "null_tokens": [] + }, + "22_NULLS_FIRST.ans": { + "sha256": "c0b57b4811bca15e37aadab90b739bb933c0c39bb3f014f453cb8e34648efa05", + "null_tokens": [] + }, + "22_NULLS_LAST.ans": { + "sha256": "b5090ffb58924be2457a33b43a6ece8ab1e22ca4ce551a745f7bed532904fab4", + "null_tokens": [ + "%" + ] + }, + "23_NULLS_FIRST.ans": { + "sha256": "380a82dc521aa8cd3962ac1b2d4711b2482113278e58ed7241838bda3fc814a2", + "null_tokens": [] + }, + "23_NULLS_LAST.ans": { + "sha256": "9071c3947fdb17fffb69a9d229dee5f5ada000445cc1978df99d658a57da1351", + "null_tokens": [] + }, + "24.ans": { + "sha256": "0aaf2ee1bae6a7faa1d766599967770df2551266677355201cf6357e06cd08fa", + "null_tokens": [] + }, + "25.ans": { + "sha256": "d18c2652a6481fac04d0399ffbbbec1adabf9daa2e82ea8f8d7288220e136d0e", + "null_tokens": [] + }, + "26.ans": { + "sha256": "a9f91d48b5c8ac88378f0a30b687e06ae27309113139fe06987d40a967ee4e63", + "null_tokens": [] + }, + "27_NULLS_FIRST.ans": { + "sha256": "5c11075a42aaef0c3c7457e7cd759d11b83eff3e2fcc1e981c5140f750b59405", + "null_tokens": [] + }, + "27_NULLS_LAST.ans": { + "sha256": "bc7fbde5228dbf69a0da2c5ee743e8c4e4174d995e381e67fdaf5f552cf0b0e4", + "null_tokens": [ + "%" + ] + }, + "28.ans": { + "sha256": "10d6b44843cf3189bcbf4f12ba1692ac301f5dd9582f7f9ccec0ce153327490e", + "null_tokens": [] + }, + "29.ans": { + "sha256": "8160dd61d9e367427cf7ddf76d09d113fe52688bff64e1bbbebfdcbef526e13d", + "null_tokens": [] + }, + "3.ans": { + "sha256": "7cb7425e7769ef78ab05752bee921c8b3248cd97373664b572293fd7441d4a54", + "null_tokens": [] + }, + "30.ans": { + "sha256": "31f8c367247d410d7636504aab37d75ae48b2d4da84b92387f767e4ba532eb74", + "null_tokens": [ + "%" + ] + }, + "31.ans": { + "sha256": "fc778a4c7002a27227dbce8da559267ff2f0a9d95f578b0a5531e42d6fe13a32", + "null_tokens": [] + }, + "32.ans": { + "sha256": "c76997201c9d01365baf1b487ea9291478721065cb793ab28fc4e230094e0a59", + "null_tokens": [] + }, + "33.ans": { + "sha256": "63bf981ac3a2bb4896ed8eb2d8da1976dd014815fb8e5f09f680362293e3c763", + "null_tokens": [] + }, + "34_NULLS_FIRST.ans": { + "sha256": "7ae2647e623a36a8a3e0c8bcb55fc551f0c2c353937a0c21dfd0d28aaa2b0b76", + "null_tokens": [] + }, + "34_NULLS_LAST.ans": { + "sha256": "c442e66a4667246bdd28d61e6a19deb669ed958e78fdd379564382533114269e", + "null_tokens": [] + }, + "35_NULLS_FIRST.ans": { + "sha256": "3e92dfeaa8da9c7e3f97e48f44535b266ddad665cab8b2a1cebb32316fcb6b68", + "null_tokens": [] + }, + "35_NULLS_LAST.ans": { + "sha256": "f6aaae189f9d8b41bfe8841c2aa7597b9ea05b7682ec8135196f4c5734e2b3ea", + "null_tokens": [] + }, + "36_NULLS_FIRST.ans": { + "sha256": "04cd1ccbbb696068eee997dc30bbb159b55d8aac3fe97ba906867c3bacdf2d8a", + "null_tokens": [] + }, + "36_NULLS_LAST.ans": { + "sha256": "a021eb2fe0a92614e1ccd395de63427d68b6e8cb014ad442e759363639a6b1ca", + "null_tokens": [ + "%" + ] + }, + "37.ans": { + "sha256": "41ac28fbc33c008eae55091bf78f01053ed3982137374e3aaf0c974841196e3a", + "null_tokens": [] + }, + "38.ans": { + "sha256": "9fc9abe77d225fa2989c869ff5cf9c71bd44221659fa99a17a40fe9214233779", + "null_tokens": [] + }, + "39.ans": { + "sha256": "cba94c3535b8c69a9b354a60554455ec595ddf342a93290fc4e46f532f3705b1", + "null_tokens": [] + }, + "4.ans": { + "sha256": "a86dc020393a8f2df5529dcdcc22ea2dede62c51652621b48654c17251e763c4", + "null_tokens": [] + }, + "40.ans": { + "sha256": "3a9e52ab42e0f425d676da76e869d1da3e7a452003ffc713e10b91c6e7568572", + "null_tokens": [] + }, + "41.ans": { + "sha256": "1dd1f9c0a7595a8a2ccc0ffc1ac551afbab85a164bff8615b1797db1c5f607c1", + "null_tokens": [] + }, + "42.ans": { + "sha256": "860377916a6d9182fd951888a978688834ade4eb29245bdeb8e8eea3677d273e", + "null_tokens": [] + }, + "43.ans": { + "sha256": "8ee987067de200168c736793da21a5f79b379334f8d0e0b3b7815635cddceb9a", + "null_tokens": [] + }, + "44.ans": { + "sha256": "a86de6dbde3d4b7d0efac78f093e0113d8e53b6ceb11da3d6367a652068dd0a6", + "null_tokens": [ + "%" + ] + }, + "45.ans": { + "sha256": "e160c5a1ef4be1b2b9282014d0c4b7ee5200505e0398214c4a0e11ddbecc7b4a", + "null_tokens": [] + }, + "46_NULLS_FIRST.ans": { + "sha256": "a4d0d9c5c78c21f448fc47d582511857039c361bb6dcf58ccb5dc3cfb9b49770", + "null_tokens": [ + "NULL" + ] + }, + "46_NULLS_LAST.ans": { + "sha256": "7586bfbe369d12bdd7a39665f571ae9c6c5f8e57d60d8b6f580818605daf312b", + "null_tokens": [ + "%" + ] + }, + "47.ans": { + "sha256": "064efb80f920d4ab911475a3dea61cca39932f8dfb4d29de58b7520be020f473", + "null_tokens": [] + }, + "48.ans": { + "sha256": "84e7c476c773cdd4a829dcf3e6671b7e12e8bf9f076de61e631b0b5452bdc365", + "null_tokens": [] + }, + "49.ans": { + "sha256": "24baaca64a6c853a90879f5c18586c0b01d9621c70d5e808be01c3d0dd5f215c", + "null_tokens": [] + }, + "50.ans": { + "sha256": "70228fd00b4444e5b53df53eff5df667f0e6222b6f7cdfd0ce13dfc8f8d00ef3", + "null_tokens": [] + }, + "51.ans": { + "sha256": "e7ac644867ba90379bcdcc079f0854db416a711a7c8830e2aaf055d58f9d0c78", + "null_tokens": [ + "%" + ] + }, + "52.ans": { + "sha256": "482adde1a887f1ee58ba340d4e7a1722d677279ec997672a429f0805c3cd44f4", + "null_tokens": [] + }, + "53.ans": { + "sha256": "3155287cd5845b31607f64ec6ab0e37b2bc36b70fedebd94b6989aaa6cf1b544", + "null_tokens": [] + }, + "54.ans": { + "sha256": "316ce499d30063f2df7632a7476f3b4adba7855814d8897ebc66c20c9c076ce6", + "null_tokens": [] + }, + "55.ans": { + "sha256": "bd7df39e6521c8a3cca6d9ccf980472c58e88746c22a61095240d209aa801845", + "null_tokens": [] + }, + "56_NULLS_FIRST.ans": { + "sha256": "cd873abfa9c1eec65ec855b3e96e0fbb72adf7ac411ee79abb09f6bd36b2c9fc", + "null_tokens": [] + }, + "56_NULLS_LAST.ans": { + "sha256": "64d10c13c1d6eb58dcf3faad810d7250a3efedaca6115301fa6947b647918ea6", + "null_tokens": [] + }, + "57.ans": { + "sha256": "75679003173e747fcdb6762fa506147afb0d80b1ca7df7e6f630cdb59fcb6d2a", + "null_tokens": [] + }, + "58.ans": { + "sha256": "f00d7cbb2fe02461224ca8a431dfd55f1d50523701033fa1d39bc3d49966c063", + "null_tokens": [] + }, + "59.ans": { + "sha256": "a4505db5802d02378362fa47795a2a77223a6356e6afcd17ffe6f8d7bf1ec6b0", + "null_tokens": [] + }, + "5_NULLS_FIRST.ans": { + "sha256": "2c5415b4597d8eb59d093e90cca6406994dc8869ee5bf68ff2f5db6b84d91e8c", + "null_tokens": [] + }, + "5_NULLS_LAST.ans": { + "sha256": "c1e8625cdc67497e6fbfa84e6dc61d326045ceb2673241fe813e40dc06667782", + "null_tokens": [] + }, + "60.ans": { + "sha256": "484f64d2dc7da603c81c22e7d6fbcc534bc89cd0ec9ca56000c54acea947225b", + "null_tokens": [] + }, + "61.ans": { + "sha256": "e1283fa6e2b6b70983698f2cfd47343fff3a5346ce38374879e2eb411fd803b1", + "null_tokens": [] + }, + "62_NULLS_FIRST.ans": { + "sha256": "c86b45c5bb553bab8b7f4d2fab1009c299afa5ab2965404f13236b7220c63b1d", + "null_tokens": [ + "NULL" + ] + }, + "62_NULLS_LAST.ans": { + "sha256": "c89e4e86ed6792de16bb182c45d3ac1f2b31e547f8b200c1e3666ea866092b17", + "null_tokens": [] + }, + "63.ans": { + "sha256": "376b57c46129c2b822fe5a9831e4b675c3bbf371244628a9d8f3e072c62c462d", + "null_tokens": [] + }, + "64.ans": { + "sha256": "c31d10836cd209e5dcebbcaf31d8a59b34b79638acf297b144a3e8ec59653b6c", + "null_tokens": [ + "[NULL]" + ] + }, + "65_NULLS_FIRST.ans": { + "sha256": "b13aeffea5dde438b7f3ac1dfb0280a9611c878cce366b9a3adde717d319d3ee", + "null_tokens": [] + }, + "65_NULLS_LAST.ans": { + "sha256": "a81eb30a49259daace7d9d782c21ce60dafbe88a04cb1cb242b5c24a3c3410c3", + "null_tokens": [] + }, + "66_NULLS_FIRST.ans": { + "sha256": "f0dc3d8e25f96c60678d64212d4a7aeb272283c2971b073d49a3733f8d057f0a", + "null_tokens": [] + }, + "66_NULLS_LAST.ans": { + "sha256": "8fe88f6749fb0ff0c6792e2e2034b6f270220ff5d006148b9fdcf57193c300e8", + "null_tokens": [ + "%" + ] + }, + "67_NULLS_FIRST.ans": { + "sha256": "850aa4fc675612d73a1d2615b6ed255c3065365fceedc94215fddbbdbe9c0984", + "null_tokens": [ + "NULL" + ] + }, + "67_NULLS_LAST.ans": { + "sha256": "0492ee704b191444181696512bca03efe0dcb7f70a00e4ebbd5f82c19ede521e", + "null_tokens": [ + "%" + ] + }, + "68_NULLS_FIRST.ans": { + "sha256": "71dba27e12de1711fe57d2292e097deb05b57aadde634d58dfd6c07f799264cd", + "null_tokens": [] + }, + "68_NULLS_LAST.ans": { + "sha256": "eae3f12cceaf1d8d87d9cc322f7f8c78a57ae77902d1020dae722c2b7bd39726", + "null_tokens": [ + "%" + ] + }, + "69.ans": { + "sha256": "b1eaa3940f6cc306acf8280d60634be4d76b3fd00fd53fef100d98dc605e06eb", + "null_tokens": [] + }, + "6_NULLS_FIRST.ans": { + "sha256": "a765b2510161f9f5d6631d8850c73361fece6615b985d47bf63c9ea5d276139f", + "null_tokens": [] + }, + "6_NULLS_LAST.ans": { + "sha256": "dac7af62dc146787a3340d410cba764710f94d29f7d91d8d18a6c693be1c2987", + "null_tokens": [] + }, + "7.ans": { + "sha256": "7692b11a4c3412097687c88bf0330cfd3f7cf2e79ca765364f00bb65dd532de2", + "null_tokens": [] + }, + "70.ans": { + "sha256": "e90dd15d3777eea1f3f6eccb4916ebd40143eed529ab5d8ad93092b4572f8041", + "null_tokens": [ + "%" + ] + }, + "71_NULLS_FIRST.ans": { + "sha256": "1a699aa5100d868325e48d62122e3ad86e3eb7624f05da5768c4ca3eec1e92a0", + "null_tokens": [] + }, + "71_NULLS_LAST.ans": { + "sha256": "8bdd7c36352bb6681261777a842fb12cd16be9c5bd2b965103fef07af368383c", + "null_tokens": [] + }, + "72_NULLS_FIRST.ans": { + "sha256": "53548a657aea07059c47b41cefadc9d0f4a2a7272c1ce672be834b7bcfa0e496", + "null_tokens": [] + }, + "72_NULLS_LAST.ans": { + "sha256": "185dd23b2dcc44b4ef17fd20b1f8e82ac98a4b86c7b116db8b230c3a46dbe224", + "null_tokens": [] + }, + "73.ans": { + "sha256": "03ef9ecdda1a968307aaf977b4a8e687d321f75259ef5625994b042d3905d077", + "null_tokens": [ + "-" + ] + }, + "74.ans": { + "sha256": "a852661d53b824270c0f93cdb431cd110f282df6764a27156054c4408d750cfd", + "null_tokens": [ + "%" + ] + }, + "75.ans": { + "sha256": "bc6caad01906cd017d2582c1d4db2f3cad67cf5d082c26a71c105847532fba63", + "null_tokens": [] + }, + "76_NULLS_FIRST.ans": { + "sha256": "955b9c512f8f9ecd806394d11a30631b700005d6db5bfabc26d0ac7b1b211829", + "null_tokens": [] + }, + "76_NULLS_LAST.ans": { + "sha256": "ff33b5c9aefff0dc2451938679357f98fb206cd9f011cd83158f232fca76124e", + "null_tokens": [ + "%" + ] + }, + "77_NULLS_FIRST.ans": { + "sha256": "de5a681bc237681736a69b93ed5d3bda332526c149058b140cad40acee368648", + "null_tokens": [] + }, + "77_NULLS_LAST.ans": { + "sha256": "bb42ab15b7220c704603f6d7fae35b8e933b3ffe2b655da0050c67ee2d2909ce", + "null_tokens": [] + }, + "78.ans": { + "sha256": "0629e683f6eebc774778a632a90178cd867d7655211fb1ac0da6af19bcf563a4", + "null_tokens": [] + }, + "79_NULLS_FIRST.ans": { + "sha256": "d00b00f276e724eba627bde000b07e1a65f92bb3c0eb22713ecbbd124730a67a", + "null_tokens": [] + }, + "79_NULLS_LAST.ans": { + "sha256": "066f0592fe7652fcdc9e32e88b31a182461c3ac0c61b6b89d0a06c97512aebac", + "null_tokens": [ + "%" + ] + }, + "8.ans": { + "sha256": "5aac21336263fc69cf762bebc31b723b1926083773eb155c22ee50ed07363cd5", + "null_tokens": [] + }, + "80_NULLS_FIRST.ans": { + "sha256": "5a86d59f78759f350df94daf28ff37958b910e3bb323f21c28edeaf67e1e2b69", + "null_tokens": [] + }, + "80_NULLS_LAST.ans": { + "sha256": "a4fc12fcd7963c47ab80609d443edaa8e60cb409b029ea39ee2b7123e9ade8a0", + "null_tokens": [] + }, + "81.ans": { + "sha256": "e92e975c501fd66a85829bac505e59c07350ab836a1f62674df9dbd86505da54", + "null_tokens": [ + "%" + ] + }, + "82.ans": { + "sha256": "7f91a54bedb105217b7ea1d1cddb392e3352d9ff25c113335567f8b8d9bf7a1f", + "null_tokens": [] + }, + "83.ans": { + "sha256": "916bf213fdfe163eeee6652b778f823c2b68a02d70d0bdaef8256fa7eb4cb3cb", + "null_tokens": [] + }, + "84.ans": { + "sha256": "561b196b07d32ba89301cd3500d0798510b1e8cd10878a290139d7a052dcb33d", + "null_tokens": [] + }, + "85.ans": { + "sha256": "fae5c613e06d068cde3f48b252c775da35e4934cb7b6c0dbd91f984484b1558c", + "null_tokens": [] + }, + "86_NULLS_FIRST.ans": { + "sha256": "7a0ff86214b7cd47040621acc1907d4b0163a00ec308a08526b858a63d5b44a4", + "null_tokens": [] + }, + "86_NULLS_LAST.ans": { + "sha256": "6fe4032d7fd33add682b8063d43b3de34de1bbfeca2e7b970007a4bf1ae8b24b", + "null_tokens": [ + "%" + ] + }, + "87.ans": { + "sha256": "b14d736ce106fe3b8d7b4a186fa31b0ff65e31dd51d7791623a37513fe222c19", + "null_tokens": [] + }, + "88.ans": { + "sha256": "2c5239919b6f7abacd25710c5116ba7956c7b996607ba1f7b3b46751cdd9090d", + "null_tokens": [] + }, + "89.ans": { + "sha256": "c10460dbcfcd13b106012e18ce3ec7ccdbb7b881a0ab70483b9d55a7add34691", + "null_tokens": [] + }, + "9.ans": { + "sha256": "4c848d5edea8154dc5594d38796fabb8ba630f2f99f0dd7aad503c81b9399461", + "null_tokens": [] + }, + "90.ans": { + "sha256": "0d7bb1013cd0a34af5255139da208c2b1743f640396e75224141baeb222662b4", + "null_tokens": [] + }, + "91.ans": { + "sha256": "46f719a77d86c0ffe40e2e5f7e749949baec752bb784d635b776972c99f40e9d", + "null_tokens": [] + }, + "92.ans": { + "sha256": "7a04682261cec1c68efc4c5d559cf98b47c0d356d9a9efe97397c70d5dbf0cc7", + "null_tokens": [] + }, + "93_NULLS_FIRST.ans": { + "sha256": "6c2335b2bbf68c3b31bd49cac7aed8b868475170ef6dde0744c7bba06ae831da", + "null_tokens": [] + }, + "93_NULLS_LAST.ans": { + "sha256": "dba412a945a41a44a4becfe309fd52823e9c27e076ac3a437bc031e8a05402e2", + "null_tokens": [] + }, + "94.ans": { + "sha256": "aad76ae103429e08622dbc10496c06f6747b4b544a26ddcf02914bb6f5a069d8", + "null_tokens": [] + }, + "95.ans": { + "sha256": "0e5d2a91d15b60fbaf9d974cd29db1c588f0886d127b5860755328c991c3e273", + "null_tokens": [] + }, + "96.ans": { + "sha256": "5693ccb68822622cfb1c8d8644dd699f6ac300d55e312b473241b3db194d1dad", + "null_tokens": [] + }, + "97.ans": { + "sha256": "0a2f1a66ab4fee4f410a5619b4d681c5155b7d63d988b9c71657aa741140e1bc", + "null_tokens": [] + }, + "98_NULLS_FIRST.ans": { + "sha256": "16680877fc1c84ac03d400a3bc95d445f7b396eeeb7d3be7ff35f64589d59ffb", + "null_tokens": [] + }, + "98_NULLS_LAST.ans": { + "sha256": "0e6d6b3c11be8fc79362947d448864cac1d2a8b30b41a9d1c53512db78608f51", + "null_tokens": [] + }, + "99_NULLS_FIRST.ans": { + "sha256": "ad0300f7232e1b9e652447510dab830624515e0c0bb4ed4dcb3c9f7e3e7e2e43", + "null_tokens": [] + }, + "99_NULLS_LAST.ans": { + "sha256": "d22f9e84b8d20e2d907c6ab530f9f5a4890990fb1ddf058670f420859bc99376", + "null_tokens": [ + "%" + ] + } + }, + "templates": { + "query1.tpl": "43bd12704315f45866976de974e809c5371734d8dfe2a04f3ba0c1384350ecf7", + "query10.tpl": "937882b86b0a0c545d771e9f23622819fda4304339d3da79cbf7eb626b1b4591", + "query11.tpl": "a0331ea068e9c4fdb7e361d98e8f0febc74df581ce0a84d1075f3c3357a4fc9f", + "query12.tpl": "849ee94964441bde85ff32078f3cab3a38d497a5cf2924fca71382e1a16c5a69", + "query13.tpl": "2647cff4eaa10d5bf26900b049861d306bd53a7ae58faa89e905d488f1da6f5a", + "query14.tpl": "a550cd5c5a82a8cd6fed17380bed8cf2069c3e9e52d5e7a2349a811fb533ff5e", + "query15.tpl": "9d39dbdeffa56ea3722870bee703d5bd2293343de4e8431a8daef62ffb83bc57", + "query16.tpl": "59113489221207907f33d4a86834aa3e279debc5471bb903a178583b5b6df4b0", + "query17.tpl": "44a9f3c81f4dc60cef26511c05630b8c289ae0c1be7c5dba37e1e9ff85f12448", + "query18.tpl": "5a09294922e9690fb47aff91cb9c0eb43d64a713f05110c93336409364fae11b", + "query19.tpl": "6299dfd107bebd87ac64010843801996efedd99605efd78a4b11d1f8b9e976f3", + "query2.tpl": "4dd48917556634fb3d7da1a0de242599272e27f22476bfe2b5221b4f15b0afc2", + "query20.tpl": "7e0bc5bc02a305c4c8527711a965e5f419e728b4e76684349456e8d7225e68c9", + "query21.tpl": "4713d6437ea565c54f1543e894280b782dbb7a1c132b677d4c07f11ee42b6c67", + "query22.tpl": "ba0b994760186422f07223d6a08415c4292ec1da5be170a2b03c921e56b6896d", + "query23.tpl": "80c7baff33b3ecfc90316ba7d89cf098cdb3e9deb77a62ade5109347909ee3e0", + "query24.tpl": "71ffb0e79f9525988a211da2b1a321f61a01ea025b34717a657cf95d278714f4", + "query25.tpl": "e5115ba519ea8befb701bf5bbd7043c3df2f0bc6b01a42be788c2aaa471edef2", + "query26.tpl": "74bb32512a2c4f8eb11617e50f0441cfe9a1e4e82051e52118df07452af3d2b3", + "query27.tpl": "08af4b80c1efc52572ff8799a7abfc7c071e5dc3d96b689cfdeeee34b6ae47d6", + "query28.tpl": "33ca27ebc07a8f4d624eae0089d6b6907f26422818426a4ea21841ea32ede987", + "query29.tpl": "84fb042f9acaa59efaf2df09752745844c95854098161e612e133e7aaaf8593c", + "query3.tpl": "9756742590f746ac97703a30af751f278dd9b16a46a2a5f80a1e8eb6a9c5c3f2", + "query30.tpl": "06fff9f6f3072daf5dce68aa9fb5c80f685990fd7cb2d37aac1279e7e53cc46f", + "query31.tpl": "bdae939691ec41da6708632ae527033a703140785ce9b6eda0c979983ba3ad43", + "query32.tpl": "1d533417f573d7aa04b31fb508cd519602f395415e7430500f765b50cf1a519f", + "query33.tpl": "7ef665b6739a6da484147b636155d661cc307272168f06776a52496b219f103b", + "query34.tpl": "6971af3b732977ba39c75b2e12295f5558966d5d50b4f3e7fbd8f0701713facc", + "query35.tpl": "e02104bb39c696768aa5bbeed537f052f5397535525ee83c03cfca37a9f56574", + "query36.tpl": "e9bb7e8daec6f4a514e4c8f2c1d946c7fc3b8cdc2ebe6e858ef0871b452282d3", + "query37.tpl": "95642c6a3dd175cf932a8937a0d60034a2a6537021c011ba1ed612db57e08131", + "query38.tpl": "bc427fdf4fba2dd776b7fd0fb190991cc3a34e29c2735e2113591b3b0f68361e", + "query39.tpl": "9498ef818487075a8f0f5e9628d3c7701d76797faceee6988d38cd79ee7699ca", + "query4.tpl": "0a5b92bffa65fa46ed04f6ee110796d5a49bab2b0819c776d868a0b8850b7bcd", + "query40.tpl": "5164c668e6a38f360300c281293b1e36fb836c1ecdd6857082da4a701a3508d6", + "query41.tpl": "bc21e35bedb844dd8ab7a8e5c20cb926b3e506eddd2c4d31515a23b252c26a17", + "query42.tpl": "ba9b9036ec2eb8bfabc142dc3ed9df6a66392401c42f5b8d33356ffae7adcd89", + "query43.tpl": "e7d1819899194a375c3c762e10f25fde04f947997f5ef7d1c719b7074dddaec8", + "query44.tpl": "9a8959967cce0dd5ba092a541cffab0b20fac59b6f0fd154198213699751f387", + "query45.tpl": "efce1fc309b47eb643bcae147938d443ce1f63dc116074e625dc2f9b9af218d1", + "query46.tpl": "e1d3dd65a7ee00443b0aa2ed5a06db5cd1c38c1f843532e1028cb80ea7203c54", + "query47.tpl": "d5f9457508466915c97d1233b7814db38d74d168756198d6ff04cd11f3ad3b1c", + "query48.tpl": "56ccfeb3e6cdd7511df2b36fb7b4ebc3429a828633a302bd2977775e3821029e", + "query49.tpl": "047749a7cc035264061ee7cc46ff743194bf2aba066873d973894bb16989feef", + "query5.tpl": "e232c642b31f7bd5a27f652a0528f5d33eb38e854f6c45ea6f27e0ad3abaa4de", + "query50.tpl": "585e9248582c650ee1f5c78a42ce0de4806d9aeaf5bd6a9b336d1a0cbebe2947", + "query51.tpl": "4cb6fb3f5dd4e9b7aa26a6c29c759517f29cc7bfe9822706992e14c9b40e7e80", + "query52.tpl": "015e216bad2103b172d26a5dd6dcbb7de0ccdd9c8655784f282174e30d7e66df", + "query53.tpl": "2a12565a0a8f32575667a0c93c212e7d21f7919f03ad318be35a5ededbbf0437", + "query54.tpl": "cde384d141c912b85addbff254f29abdc9028b103d25b2a010b295d28dc58d6e", + "query55.tpl": "dacd58d6fdf24fd54a7d7d77ef608df6d98c0e8c701eff77f3ef65252d3dddef", + "query56.tpl": "7a8f163f71ded650bbc9d89af88114b44b7fcc4caafa776857ac229b855c5a3b", + "query57.tpl": "9833481006694b565ce7e19f513a62b49bb16660731b173e224abbe26f38c240", + "query58.tpl": "452109c63497766415fa73ce4ab69d5ba87c10c396c66b1836e7e41ab339e55f", + "query59.tpl": "5f43ea2aefd5c90096eafa1313b296e4073849f5e54e5b12de4b7daf6dd32bb7", + "query6.tpl": "502f4a7d64d8de504379c750be4e9918ef466b1db28024b1b44fb70de257705e", + "query60.tpl": "97c30bec850e139ec4ab0206c8c54fbd6d96258e9af24669660b8220b32961fe", + "query61.tpl": "9501f48fd73d4ed27111943dfa869480695884d70b9aaaf054a0585ee49c8d6f", + "query62.tpl": "a20540c366048c36a67c2e8779472beee91f81e2a95981918ffe9186a901454e", + "query63.tpl": "cd290b6f8a83e690fcfa8ee29ce49cbd153a88840b7712dc1ced3912f5870459", + "query64.tpl": "dac0f9c9e0cf71596ee8c76d7cd2e32db190124e52f25a7722fb73fa4d041d93", + "query65.tpl": "4769a55171ba59d35d667d6b108a6d7d5e5723f704823e2a875474cf4604c838", + "query66.tpl": "83c4b13212c223c138c2f807f65b67371192a540acff4e4906b747ef4f0b7c13", + "query67.tpl": "3bb0ac8d59ee37e04f223ff90041bfd2e0bb0a8cbf0aedaab3c9c610338e99cc", + "query68.tpl": "b8894f13bdd1b82f9dba3aeae49f8d5687c83b99c29b134c35e239b8ceca8b07", + "query69.tpl": "f02e8b9792ebf3adae7440c25f513492485dcbcef85f6d4a97c183f707aa562a", + "query7.tpl": "1a5fe27db2d5011a0732d9f98db5558f40eb28edfb1ce4741dfccf569eafce2d", + "query70.tpl": "922d98620b44c0dd13740ec6cb94a700a0c0098ed32401af7d75107d3cc961ee", + "query71.tpl": "230bd52957a448c46d8c63264552edf26054f3d5891aa771d34356772303dd01", + "query72.tpl": "5f9117a8a7184d36125ca4c4224110da7b76dcefa24678d4c8c2d7efb0cd31f1", + "query73.tpl": "6f501a02843b22a99d85ecc830999d05e77cf15d814f4278ce7e717af628d037", + "query74.tpl": "a19d9ecdd4d24515c7d8fa7b3a9fa585d91c28369346b47738cf71f2ef070c47", + "query75.tpl": "1b2b723ff63faa6fe1670b316ba9d452c11bb01bc9c69523bfe288b2e691a9d0", + "query76.tpl": "748201a1fd429951aa16c5a7a361aea437cc7f83dbfa0dffa5f6844e86aac05c", + "query77.tpl": "21644fbbc5b7cdab24b51a71647cec937f8bb261ee5f6fd49cc7f8e62a669026", + "query78.tpl": "50007b69c4b2a1a07ccda17781a91e323ccbc935a303722a0fd675b256a14c39", + "query79.tpl": "bc9b85ad7c8448deaded4769a1d03e2dc6816da7c2a7963fe76d1687143db26f", + "query8.tpl": "378c3fec4638dd57cef1732be700659fc70d78d0a5e04b5d2e5f3b12b0a9babe", + "query80.tpl": "94dbb1c80f8c4e88bb1a61c2885234de3f245ea67b2af68ed81b1129d7014e26", + "query81.tpl": "61def14bcc7c4d116cc67febbf1f173650d750731595bd97c526b4fe8dafb958", + "query82.tpl": "e6fbe19cb97f43d473b20934491ba48bdcccc3a1c132dfef5b96bd0fdac08c29", + "query83.tpl": "70f5bc97783e4140e95528d6a43fb72d3fc078d143329f69d69ada43539dd5d5", + "query84.tpl": "7eed933ac66a43af5e776c604dc16d4b6624bef0ac8d10d1e983f5c5648bf7af", + "query85.tpl": "1e95614e7aebdb858f5d9aa76f98ff1940ed69d86cb26bb926237e99c9789025", + "query86.tpl": "9b7722986d6efe9cfabcabd87dab12628f7df9adbb0609cacef6b23247ebdf10", + "query87.tpl": "a521a506634cb62f7cc056d9111e616318cc151eb205e1d14f25adf7b9af5976", + "query88.tpl": "9e0075539c5547530f1e995738fe3edfabf05c36673fbb11684f8fcde0b7dd3c", + "query89.tpl": "a60df27ee23e89fca368f2dab482da8bff61c9303a11dc3787e807ec86dacdfa", + "query9.tpl": "2db1d593f05a0d364831bb4c5b4870743f8b91b68a9bee62f3f71b3023cedb29", + "query90.tpl": "40520ef7e96c93ed3ffacfae84e2d33f2175a795e0db50e36b2c7b1758e24de8", + "query91.tpl": "04586097e383871e74dfe5b36dfd363c39676276d61e76b74b84afcbefa8c986", + "query92.tpl": "a8e6c13375da18c2ca9cce8d044bedf179e66d199e77b0b53ddf0c8c1f289ee2", + "query93.tpl": "94039860c835a6f144c1c2b713fabfc0e19237833d612f14df54807024f44556", + "query94.tpl": "8ac07ea9b9c35daf93d64980825b60676990eec43c70a653c7e6acaad3d1089f", + "query95.tpl": "c4675aad174569ff477d3918c084fd56fd6ab4fac2c5d3c59d078f0889a908ea", + "query96.tpl": "4f153d6102c19ac7fc263575e14ea9ee623d0d72f85e22db8edd2c7411dbf4fc", + "query97.tpl": "aab37cc8fb542b65063b475eadae9116a36e714a12b562d830713cb1ca8d9c83", + "query98.tpl": "0c8c2737bd74229df853f45b38f4e6794251422b3f0ec09620282dfc80ede33f", + "query99.tpl": "a38e5200cecc4731e5f0da0f3eaf798c19372e4c732b5e1a57255e7aee3d2b58" + }, + "block_rows": { + "39": [ + 243, + 14 + ], + "98": [ + 1997, + 519 + ] + }, + "specification_audit": { + "specification": "TPC-DS 4.0.0", + "pdf_sha256": "f66af4b53099680669bfa49cafeb59325373edaafd74e26093f3f1334c8695fe", + "parameters_sha256": "c6f15da0a0a67cc3f6e8d150ad1b67e4f01d83e98528d12e03f74a1afb7f0808", + "canonical_values_sha256": "47a4048be5eebd71de7d0b95beab5dd8bf996c31d905c1b45621a3f1744df602", + "queries": 99, + "values": 829, + "extraction_dependency": "pypdf==6.0.0", + "query_pdf_pages": { + "1": 107, + "2": 107, + "3": 107, + "4": 107, + "5": 107, + "6": 108, + "7": 108, + "8": 108, + "9": 110, + "10": 110, + "11": 110, + "12": 111, + "13": 111, + "14": 111, + "15": 112, + "16": 112, + "17": 112, + "18": 112, + "19": 113, + "20": 113, + "21": 113, + "22": 113, + "23": 114, + "24": 114, + "25": 114, + "26": 115, + "27": 115, + "28": 115, + "29": 116, + "30": 116, + "31": 116, + "32": 116, + "33": 117, + "34": 117, + "35": 118, + "36": 118, + "37": 118, + "38": 118, + "39": 119, + "40": 119, + "41": 119, + "42": 120, + "43": 120, + "44": 120, + "45": 120, + "46": 121, + "47": 121, + "48": 121, + "49": 122, + "50": 122, + "51": 122, + "52": 122, + "53": 122, + "54": 122, + "55": 123, + "56": 123, + "57": 123, + "58": 123, + "59": 124, + "60": 124, + "61": 124, + "62": 124, + "63": 124, + "64": 125, + "65": 125, + "66": 125, + "67": 125, + "68": 126, + "69": 126, + "70": 126, + "71": 126, + "72": 126, + "73": 127, + "74": 127, + "75": 127, + "76": 127, + "77": 128, + "78": 128, + "79": 128, + "80": 128, + "81": 128, + "82": 129, + "83": 129, + "84": 129, + "85": 129, + "86": 130, + "87": 130, + "88": 130, + "89": 130, + "90": 131, + "91": 131, + "92": 131, + "93": 131, + "94": 132, + "95": 132, + "96": 132, + "97": 132, + "98": 132, + "99": 133 + } + }, + "specification_url": "https://www.tpc.org/tpc_documents_current_versions/pdf/tpc-ds_v4.0.0.pdf" +} diff --git a/benchbox/core/tpcds/qualification/runner.py b/benchbox/core/tpcds/qualification/runner.py new file mode 100644 index 0000000000..3e9c9c38c5 --- /dev/null +++ b/benchbox/core/tpcds/qualification/runner.py @@ -0,0 +1,697 @@ +from __future__ import annotations + +import argparse +import hashlib +import importlib +import importlib.metadata +import json +import math +import os +import re +import signal +import subprocess +import sys +import time +from datetime import date, datetime +from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +from benchbox.core.expected_results.loader import TpcdsAnswerBlock, parse_tpcds_answer_values +from benchbox.utils.clock import elapsed_seconds, mono_time + +_INPUTS = Path(__file__).with_name("inputs.json") +_PARAMETERS = Path(__file__).parents[1] / "dataframe_queries" / "qualification_values.json" +_MULTIPART = {14, 23, 24, 39} +STATEMENTS = tuple(str(n) + suffix for n in range(1, 100) for suffix in (("a", "b") if n in _MULTIPART else ("",))) +_ENGINES = { + "polars": ("benchbox.platforms.dataframe.polars_df", "PolarsDataFrameAdapter", "expression"), + "pandas": ("benchbox.platforms.dataframe.pandas_df", "PandasDataFrameAdapter", "pandas"), + "datafusion": ("benchbox.platforms.dataframe.datafusion_df", "DataFusionDataFrameAdapter", "expression"), +} + + +def sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def parameter_hash(values: dict[str, Any]) -> str: + return hashlib.sha256(json.dumps(values, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def qualification_parameters(path: Path = _PARAMETERS, manifest: dict[str, Any] | None = None) -> dict[str, Any]: + inputs = manifest if manifest is not None else json.loads(_INPUTS.read_text(encoding="utf-8")) + if sha256(path) != inputs["parameters_sha256"]: + raise ValueError("Qualification parameter file differs from the pinned specification values") + document = json.loads(path.read_text(encoding="utf-8")) + if set(document["values"]) != {str(n) for n in range(1, 100)}: + raise ValueError("Qualification parameter inventory must contain exactly queries 1 through 99") + if document["not_in_template"] != {"71": ["MANAGER.01"]}: + raise ValueError("Unknown qualification template exception") + receipt = inputs["specification_audit"] + if receipt["queries"] != 99 or receipt["canonical_values_sha256"] != parameter_hash(document["values"]): + raise ValueError("Qualification values differ from the complete Appendix B audit") + if set(receipt["query_pdf_pages"]) != set(document["values"]): + raise ValueError("Incomplete specification audit provenance") + return document + + +def mapped_blocks( + query: int, blocks: tuple[TpcdsAnswerBlock, ...], inputs: dict[str, Any] +) -> tuple[TpcdsAnswerBlock, ...]: + if str(query) in inputs["block_rows"]: + if [len(block.rows) for block in blocks] != inputs["block_rows"][str(query)]: + raise ValueError(f"Q{query}: unknown official block shape") + if len({block.columns for block in blocks}) != 1: + raise ValueError(f"Q{query}: inconsistent columns across official blocks") + if query == 98: + blocks = (TpcdsAnswerBlock(blocks[0].columns, tuple(row for block in blocks for row in block.rows)),) + expected = 2 if query in _MULTIPART else 1 + if len(blocks) != expected: + raise ValueError(f"Q{query}: expected {expected} statement blocks, found {len(blocks)}") + return blocks + + +def official_inventory(directory: Path, inputs: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: + names = {path.name for path in directory.glob("*.ans")} + if names != set(inputs["answers"]): + raise ValueError( + f"Official answer inventory differs: missing={sorted(set(inputs['answers']) - names)}, extra={sorted(names - set(inputs['answers']))}" + ) + result: dict[str, list[dict[str, Any]]] = {query: [] for query in STATEMENTS} + for number in range(1, 100): + available = [ + name for name in (f"{number}.ans", f"{number}_NULLS_FIRST.ans", f"{number}_NULLS_LAST.ans") if name in names + ] + if not available: + raise ValueError(f"Q{number}: missing official answer") + for name in available: + pin = inputs["answers"][name] + if sha256(directory / name) != pin["sha256"]: + raise ValueError(f"Official answer hash differs: {name}") + raw = parse_tpcds_answer_values(directory / name) + blocks = mapped_blocks(number, raw, inputs) + for index, block in enumerate(blocks): + query = str(number) + ("ab"[index] if number in _MULTIPART else "") + result[query].append( + { + "file": name, + "sha256": pin["sha256"], + "null_tokens": pin["null_tokens"], + "raw_blocks": len(raw), + "columns": block.columns, + "rows": block.rows, + "null_order": "first" if "FIRST" in name else "last" if "LAST" in name else "unspecified", + } + ) + if set(result) != set(STATEMENTS) or any(not values for values in result.values()): + raise ValueError("Official answers do not cover all 103 statements") + return result + + +def convert_cell(text: str | None, sql_type: str, null_tokens: list[str]) -> Any: + if text is None or text in null_tokens: + return None + kind = sql_type.upper() + if kind in {"VARCHAR", "CHAR", "TEXT", "STRING"}: + return text + if kind.startswith("DECIMAL("): + value = Decimal(text) + if not value.is_finite(): + raise ValueError(f"Nonfinite DECIMAL answer: {text!r}") + return value + if kind in { + "TINYINT", + "SMALLINT", + "INTEGER", + "BIGINT", + "HUGEINT", + "UTINYINT", + "USMALLINT", + "UINTEGER", + "UBIGINT", + "UHUGEINT", + }: + if not re.fullmatch(r"[+-]?\d+", text): + raise ValueError(f"Invalid integer answer: {text!r}") + return int(text) + if kind in {"FLOAT", "DOUBLE", "REAL"}: + return float(text) + if kind == "DATE": + return date.fromisoformat(text) + if kind.startswith("TIMESTAMP"): + return datetime.fromisoformat(text) + if kind == "BOOLEAN" and text.lower() in {"true", "false"}: + return text.lower() == "true" + raise ValueError(f"Unsupported official answer conversion: {sql_type!r}, {text!r}") + + +def convert_rows(answer: dict[str, Any], sql_types: list[str]) -> list[tuple[Any, ...]]: + if len(answer["columns"]) != len(sql_types): + raise ValueError("Official columns differ from SQL reference width") + if any(len(row) != len(sql_types) for row in answer["rows"]): + raise ValueError("Malformed official row width") + return [ + tuple(convert_cell(cell, kind, answer["null_tokens"]) for cell, kind in zip(row, sql_types)) + for row in answer["rows"] + ] + + +def display_rounded( + actual: list[tuple[Any, ...]], answer: dict[str, Any], sql_types: list[str] +) -> list[tuple[Any, ...]]: + """Replace each numeric cell that rounds to its official printed text with the printed value. + + Official answer files print rounded values. A cell is replaced only when rounding + it, half up or half to even, to the decimal places its printed text shows gives + exactly that text. Rows are paired by position, so a different row count or order + leaves the result unchanged. Callers report the comparison of this result as a + classification of a strict mismatch; it never turns a mismatch into a match. + """ + if len(actual) != len(answer["rows"]): + return actual + return [ + tuple( + _display_cell(value, text, kind, answer["null_tokens"]) + for value, text, kind in zip(row, printed, sql_types, strict=True) + ) + for row, printed in zip(actual, answer["rows"], strict=True) + ] + + +def _display_cell(value: Any, text: str | None, sql_type: str, null_tokens: list[str]) -> Any: + if text is None or text in null_tokens or isinstance(value, bool) or not isinstance(value, (int, float, Decimal)): + return value + if not sql_type.upper().startswith(("DECIMAL(", "DOUBLE", "FLOAT", "REAL")): + return value + if isinstance(value, float) and not math.isfinite(value): + return value + try: + printed = Decimal(text) + except InvalidOperation: + return value + if not printed.is_finite(): + return value + exponent = printed.as_tuple().exponent + if not isinstance(exponent, int): + return value + step = Decimal(1).scaleb(exponent) + actual = Decimal(str(value)) + if printed in {actual.quantize(step, ROUND_HALF_UP), actual.quantize(step, ROUND_HALF_EVEN)}: + return convert_cell(text, sql_type, null_tokens) + return value + + +def compare_rows( + expected: list[tuple[Any, ...]], + actual: list[tuple[Any, ...]], + query: str, + sql: str, + columns: list[tuple[str, str]] | None = None, +) -> dict[str, Any]: + from benchbox.core.equivalence.cross_surface import ( + _derived_order_violation, + _is_truncated_top_n, + _order_by_result_key, + ) + from benchbox.core.equivalence.dataframe_surface import _normalize_value + from benchbox.core.tpchavoc.validation import ResultValidator, ValidationError + + order_by = _order_by_result_key(sql) + try: + ResultValidator(strip_strings=True).validate_results_exact( + [tuple(_normalize_value(value) for value in row) for row in expected], + [tuple(_normalize_value(value) for value in row) for row in actual], + int(re.sub(r"\D", "", query)), + 0, + order_aware=bool(order_by), + order_by=order_by, + tie_aware=_is_truncated_top_n(sql), + ) + violation = _derived_order_violation(sql, columns, actual) if order_by is None and columns else None + if violation is not None: + raise ValidationError(f"Q{query}: {violation}") + return {"status": "match", "rows": len(actual), "order_by": order_by} + except ValidationError as exc: + return { + "status": "mismatch", + "rows": len(actual), + "expected_rows": len(expected), + "detail": str(exc), + "order_by": order_by, + } + + +def emit(path: Path, event: dict[str, Any]) -> None: + with path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(event, default=str, allow_nan=False) + "\n") + stream.flush() + + +def prepare(answers_dir: Path) -> dict[str, Any]: + from benchbox.core.tpcds.c_tools import DSQGenBinary + from benchbox.core.tpcds.dataframe_queries import TPCDS_DATAFRAME_QUERIES + from benchbox.core.tpcds.dataframe_queries.parameter_adapters import ADAPTERS + from benchbox.tpcds import TPCDS + + inputs = json.loads(_INPUTS.read_text(encoding="utf-8")) + if set(inputs["templates"]) != {f"query{number}.tpl" for number in range(1, 100)}: + raise ValueError("Qualification template inventory must contain exactly queries 1 through 99") + specification = Path(__file__).parents[4] / "_sources/tpc-ds/specification/specification_4.0.0.pdf" + if sha256(specification) != inputs["specification_audit"]["pdf_sha256"]: + raise ValueError("Qualification specification hash differs") + document = qualification_parameters(manifest=inputs) + answers = official_inventory(answers_dir, inputs) + if set(ADAPTERS) != set(range(1, 100)): + raise ValueError("Qualification adapters must cover all 99 queries") + dsqgen = DSQGenBinary() + benchmark = TPCDS(scale_factor=1.0) + dsdgen = benchmark.generator.dsdgen_exe + if dsdgen is None: + raise ValueError("Missing dsdgen binary") + binaries = { + "dsqgen": {"path": str(dsqgen.dsqgen_path), "sha256": sha256(dsqgen.dsqgen_path)}, + "dsdgen": {"path": str(dsdgen), "sha256": sha256(dsdgen)}, + } + for name, digest in inputs["templates"].items(): + if sha256(dsqgen.templates_dir / name) != digest: + raise ValueError(f"Qualification template hash differs: {name}") + sqls: dict[str, str] = {} + for query in STATEMENTS: + number = int(re.sub(r"\D", "", query)) + registry_id = f"Q{number}" + ("b" if query.endswith("b") else "") + definition = TPCDS_DATAFRAME_QUERIES.get_or_raise(registry_id) + if definition.expression_impl is None or definition.pandas_impl is None: + raise ValueError(f"Missing qualification implementation: {registry_id}") + ADAPTERS[number](document["values"][str(number)]) + raw = dsqgen.generate_with_parameters(query, document["values"][str(number)], scale_factor=1.0, seed=1) + translated = benchmark._impl.translate_query_text(raw, "netezza", "duckdb") + sqls[query] = benchmark._impl._apply_target_dialect_overrides( + number, translated, "duckdb", variant="b" if query.endswith("b") else None + ) + if len(sqls) != 103: + raise ValueError("Qualification SQL inventory must contain all 103 statements") + return { + "specification": inputs["specification"], + "specification_audit": inputs["specification_audit"], + "specification_url": inputs["specification_url"], + "parameters": document, + "parameters_sha256": sha256(_PARAMETERS), + "parameter_values_hash": parameter_hash(document["values"]), + "sql": sqls, + "answers": answers, + "binaries": binaries, + "templates": inputs["templates"], + "answer_manifest_sha256": sha256(_INPUTS), + "source_head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "dependencies": { + name: importlib.metadata.version(name) + for name in ("duckdb", "polars", "pandas", "datafusion", "pyarrow", "sqlglot") + }, + } + + +def _table_paths(value: Any) -> list[Path]: + return [Path(path) for path in (value if isinstance(value, list) else [value])] + + +def worker(job: Path, engine: str, events: Path, data_dir: Path) -> int: + from benchbox.core.equivalence.builders.base import _load_duckdb_cell + from benchbox.core.equivalence.dataframe_surface import materialize_rows + from benchbox.core.tpcds.dataframe_queries import TPCDS_DATAFRAME_QUERIES + from benchbox.core.tpcds.dataframe_queries.parameter_adapters import ADAPTERS, ParameterBinding + from benchbox.core.tpcds.dataframe_queries.production_binding import bind_queries + from benchbox.core.tpcds.schema.registry import TABLES + from benchbox.core.tpcds.schema.tables import DBGEN_VERSION + from benchbox.tpcds import TPCDS + + prepared = json.loads(job.read_text(encoding="utf-8")) + for binary in prepared["binaries"].values(): + if sha256(Path(binary["path"])) != binary["sha256"]: + raise ValueError("Input binary changed after qualification preflight") + benchmark = TPCDS(scale_factor=1.0, output_dir=data_dir, compress_data=False) + generated = benchmark.generate_data() + data_hashes = {} + paths = [path for group in generated for path in _table_paths(group)] + for path in sorted(paths, key=str): + key = path.resolve().relative_to(data_dir.resolve()).as_posix() + if key in data_hashes: + raise ValueError(f"Duplicate qualification data path: {key}") + data_hashes[key] = sha256(path) + if not data_hashes: + raise ValueError("Qualification data inventory is empty") + table_data_hashes = {} + for table in (table for table in TABLES if table.name != DBGEN_VERSION.name): + if table.name not in benchmark.tables: + raise ValueError(f"Missing qualification schema table: {table.name}") + table_paths = _table_paths(benchmark.tables[table.name]) + if not table_paths: + raise ValueError(f"Empty qualification schema table: {table.name}") + keys = [path.resolve().relative_to(data_dir.resolve()).as_posix() for path in table_paths] + table_data_hashes[table.name] = {key: data_hashes[key] for key in keys} + table_keys = {key for files in table_data_hashes.values() for key in files} + metadata_hashes = {key: value for key, value in data_hashes.items() if key not in table_keys} + connection = _load_duckdb_cell(benchmark, data_dir, [table.name for table in TABLES], label="TPC-DS qualification") + connection.execute("SET threads = 2") + connection.execute("SET memory_limit = '2GB'") + module, name, family = _ENGINES[engine] + adapter = getattr(importlib.import_module(module), name)() + context = adapter.load_benchmark_into_context(benchmark, data_dir, scale_factor=1.0) + emit( + events, + { + "event": "loaded", + "engine": engine, + "data_sha256": data_hashes, + "table_data_sha256": table_data_hashes, + "generator_metadata_sha256": metadata_hashes, + "duckdb_null_order": connection.execute("SELECT current_setting('default_null_order')").fetchone()[0], + }, + ) + failed = False + try: + for query in STATEMENTS: + emit(events, {"event": "query_start", "engine": engine, "query": query}) + started = mono_time() + try: + number = int(re.sub(r"\D", "", query)) + values = prepared["parameters"]["values"][str(number)] + binding = ParameterBinding( + number, 1.0, None, 0, prepared["binaries"]["dsqgen"]["sha256"], values, ADAPTERS[number](values) + ) + registry_id = f"Q{number}" + ("b" if query.endswith("b") else "") + definition = bind_queries([TPCDS_DATAFRAME_QUERIES.get_or_raise(registry_id)], {number: binding})[0] + sql = prepared["sql"][query] + cursor = connection.execute(sql) + types = [str(column[1]) for column in cursor.description] + columns = [(str(column[0]), str(column[1])) for column in cursor.description] + reference = cursor.fetchall() + result = definition.get_impl_for_family(family)(context) + candidate = materialize_rows(result) + parity = compare_rows(reference, candidate, query, sql, columns) + comparisons = [] + answer_error = False + for answer in prepared["answers"][query]: + try: + expected = convert_rows(answer, types) + except (ValueError, ArithmeticError) as exc: + answer_error = True + comparisons.append( + { + "file": answer["file"], + "sha256": answer["sha256"], + "null_order": answer["null_order"], + "status": "error", + "detail": str(exc), + } + ) + continue + item = { + "file": answer["file"], + "sha256": answer["sha256"], + "null_order": answer["null_order"], + "sql_to_printed": compare_rows(expected, reference, query, sql, columns), + "dataframe_to_printed": compare_rows(expected, candidate, query, sql, columns), + } + for side, rows in (("sql", reference), ("dataframe", candidate)): + if item[f"{side}_to_printed"]["status"] != "match": + item[f"{side}_to_printed_display"] = compare_rows( + expected, display_rounded(rows, answer, types), query, sql, columns + ) + comparisons.append(item) + matched = ( + not answer_error + and parity["status"] == "match" + and any( + item["sql_to_printed"]["status"] == "match" + and item["dataframe_to_printed"]["status"] == "match" + for item in comparisons + ) + ) + failed |= not matched + emit( + events, + { + "event": "query_result", + "engine": engine, + "query": query, + "sql_types": types, + "sql_sha256": hashlib.sha256(sql.encode()).hexdigest(), + "parameters": values, + "parameter_hash": parameter_hash(values), + "df_parameters": binding.parameters, + "elapsed_seconds": elapsed_seconds(started), + "dataframe_to_sql": parity, + "official_files": comparisons, + "status": "error" if answer_error else "match" if matched else "mismatch", + }, + ) + except Exception as exc: + failed = True + emit( + events, + { + "event": "query_result", + "engine": engine, + "query": query, + "status": "error", + "detail": str(exc), + "elapsed_seconds": elapsed_seconds(started), + }, + ) + finally: + connection.close() + return int(failed) + + +def supervise( + command: list[str], + events: Path, + log: Path, + query_seconds: float, + overall_seconds: float, + max_rss_bytes: int = 6 * 1024**3, +) -> int: + started = mono_time() + deadline = started + overall_seconds + offset = 0 + active: str | None = None + import psutil + + peak_rss = 0 + with log.open("w", encoding="utf-8") as output: + process = subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) + try: + while process.poll() is None: + try: + owned = psutil.Process(process.pid) + rss = sum(member.memory_info().rss for member in [owned, *owned.children(recursive=True)]) + peak_rss = max(peak_rss, rss) + if rss > max_rss_bytes: + emit(events, {"event": "memory_limit", "status": "error", "rss_bytes": rss}) + _kill_worker(process) + return 1 + except psutil.NoSuchProcess: + pass + if events.exists(): + with events.open(encoding="utf-8") as stream: + stream.seek(offset) + while True: + position = stream.tell() + line = stream.readline() + if not line.endswith("\n"): + stream.seek(position) + break + event = json.loads(line) + if event["event"] == "query_start": + active = event["query"] + deadline = min(started + overall_seconds, mono_time() + query_seconds) + elif event["event"] == "query_result": + active = None + deadline = started + overall_seconds + offset = stream.tell() + if mono_time() >= deadline: + emit(events, {"event": "timeout", "query": active, "status": "error"}) + _kill_worker(process) + return 1 + time.sleep(0.1) + emit(events, {"event": "worker_exit", "returncode": process.returncode, "peak_rss_bytes": peak_rss}) + return process.returncode + finally: + if process.poll() is None: + _kill_worker(process) + + +def _kill_worker(process: subprocess.Popen[Any]) -> None: + try: + if hasattr(signal, "SIGKILL"): + os.killpg(process.pid, signal.SIGKILL) + else: + process.kill() + except ProcessLookupError: + pass + process.wait() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--answers-dir", type=Path) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--query-seconds", type=float, default=120) + parser.add_argument("--overall-seconds", type=float, default=1200) + parser.add_argument("--max-rss-gib", type=float, default=6) + parser.add_argument("--preflight-only", action="store_true") + parser.add_argument("--worker", choices=tuple(_ENGINES)) + parser.add_argument("--prepare-worker", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args(argv) + if not (0 < args.query_seconds <= 1200 and 0 < args.overall_seconds <= 1200): + parser.error("Query and overall bounds must be positive and at most 1200 seconds") + if not 0 < args.max_rss_gib <= 9: + parser.error("Resident memory bound must be positive and at most 9 GiB") + args.output_dir.mkdir(parents=True, exist_ok=True) + if args.worker: + return worker( + args.output_dir / "inputs.json", + args.worker, + args.output_dir / f"{args.worker}.jsonl", + args.output_dir / "data", + ) + private = args.output_dir / "cache" + private.mkdir(exist_ok=True) + os.environ["BENCHBOX_CACHE_DIR"] = str(private.resolve()) + os.environ["XDG_CACHE_HOME"] = str(private.resolve()) + started = mono_time() + try: + if not args.prepare_worker: + if (args.output_dir / "inputs.json").exists() or (args.output_dir / "summary.jsonl").exists(): + raise ValueError("Qualification output directory must be fresh") + emit( + args.output_dir / "summary.jsonl", + { + "event": "limits", + "query_seconds": args.query_seconds, + "overall_seconds": args.overall_seconds, + "max_rss_bytes": int(args.max_rss_gib * 1024**3), + }, + ) + command = [ + sys.executable, + "-m", + "benchbox.core.tpcds.qualification.runner", + "--prepare-worker", + "--output-dir", + str(args.output_dir), + ] + if args.answers_dir is not None: + command.extend(["--answers-dir", str(args.answers_dir)]) + result = supervise( + command, + args.output_dir / "summary.jsonl", + args.output_dir / "preflight.log", + args.query_seconds, + args.overall_seconds, + int(args.max_rss_gib * 1024**3), + ) + if result or not (args.output_dir / "inputs.json").exists(): + raise ValueError("Qualification preflight failed; see preflight.log and summary.jsonl") + else: + return prepare_worker(args) + if args.preflight_only: + return 0 + failures = [] + data_identity = None + metadata_identity = None + for engine in _ENGINES: + events = args.output_dir / f"{engine}.jsonl" + if events.exists(): + raise ValueError("Qualification output directory must be fresh") + remaining = args.overall_seconds - elapsed_seconds(started) + if remaining <= 0: + raise TimeoutError("Qualification overall timeout") + command = [ + sys.executable, + "-m", + "benchbox.core.tpcds.qualification.runner", + "--worker", + engine, + "--output-dir", + str(args.output_dir), + ] + result = supervise( + command, + events, + args.output_dir / f"{engine}.log", + args.query_seconds, + remaining, + int(args.max_rss_gib * 1024**3), + ) + records = ( + [json.loads(line) for line in events.read_text(encoding="utf-8").splitlines()] + if events.exists() + else [] + ) + completed = [record["query"] for record in records if record["event"] == "query_result"] + if completed != list(STATEMENTS): + result = 1 + loaded = [record for record in records if record["event"] == "loaded"] + if len(loaded) != 1: + result = 1 + elif data_identity is None: + data_identity = loaded[0]["table_data_sha256"] + metadata_identity = loaded[0]["generator_metadata_sha256"] + elif loaded[0]["table_data_sha256"] != data_identity: + result = 1 + emit(events, {"event": "input_drift", "status": "error", "detail": "Data differs across engines"}) + if loaded and loaded[0]["generator_metadata_sha256"] != metadata_identity: + emit( + events, + { + "event": "generation_metadata_difference", + "reference": metadata_identity, + "current": loaded[0]["generator_metadata_sha256"], + }, + ) + failures.append(bool(result)) + emit( + args.output_dir / "summary.jsonl", + { + "event": "engine_result", + "engine": engine, + "status": "failure" if result else "match", + "completed": len(completed), + }, + ) + return int(any(failures)) + except Exception as exc: + emit(args.output_dir / "summary.jsonl", {"event": "error", "status": "failure", "detail": str(exc)}) + return 1 + + +def prepare_worker(args: argparse.Namespace) -> int: + try: + if args.answers_dir is None: + from benchbox.core.expected_results.loader import _find_tpcds_answers_dir + + args.answers_dir = _find_tpcds_answers_dir() + prepared = prepare(args.answers_dir) + (args.output_dir / "inputs.json").write_text(json.dumps(prepared, indent=2), encoding="utf-8") + emit( + args.output_dir / "summary.jsonl", + { + "event": "preflight", + "status": "complete", + "queries": 99, + "statements": 103, + "raw_blocks_per_null_order": 104, + "parameter_values_hash": prepared["parameter_values_hash"], + }, + ) + return 0 + except Exception as exc: + emit(args.output_dir / "summary.jsonl", {"event": "error", "status": "failure", "detail": str(exc)}) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchbox/core/tpcds/queries.py b/benchbox/core/tpcds/queries.py index c3470c65ba..8797014456 100644 --- a/benchbox/core/tpcds/queries.py +++ b/benchbox/core/tpcds/queries.py @@ -175,7 +175,8 @@ def generate_with_parameters( Args: query_id: Query number (1-99) - parameters: Dictionary of parameter names to values + parameters: Values keyed by dsqgen's names as ``-LOG`` writes them (``YEAR.01``); see + ``DSQGenBinary.generate_with_parameters`` scale_factor: Scale factor for calculations dialect: SQL dialect diff --git a/benchbox/core/tpcds_obt/benchmark.py b/benchbox/core/tpcds_obt/benchmark.py index f08e499353..8029b9aa43 100644 --- a/benchbox/core/tpcds_obt/benchmark.py +++ b/benchbox/core/tpcds_obt/benchmark.py @@ -12,6 +12,7 @@ from benchbox.core.tpcds.generator import TPCDSDataGenerator from benchbox.core.tpcds_obt.etl.transformer import SUPPORTED_CHANNELS, TPCDSOBTTransformer from benchbox.core.tpcds_obt.queries import TPCDSOBTQueryManager +from benchbox.utils.cloud_storage import normalize_output_dir from benchbox.utils.path_utils import get_benchmark_runs_datagen_path logger = logging.getLogger(__name__) @@ -127,6 +128,13 @@ class TPCDSOBTBenchmark(BaseBenchmark): while OBT-specific transformations are stored separately. """ + # OBT generates its own output table from TPC-DS source data, so the CLI + # must not redirect its output_dir to the shared TPC-DS root: that would + # hide the generated OBT parquet from the cloud loader. + GENERATES_OWN_OUTPUT = True + # Every query reads this table; the TPC-DS source tables only feed its build. + REQUIRED_LOADED_TABLES = ("tpcds_sales_returns_obt",) + def __init__( self, scale_factor: float = 1.0, @@ -169,15 +177,17 @@ def __init__( # Determine standard paths via the shared helper so BENCHBOX_OUTPUT_DIR is # honored; falls back to Path.cwd()/benchmark_runs/datagen when unset. - # OBT output directory (for transformed OBT table) + # OBT output directory (for transformed OBT table). normalize_output_dir + # keeps a CloudStagingPath/DatabricksPath handler intact; Path(...) + # would stringify it to the local cache and drop the cloud target. if output_dir: - self.output_dir = Path(output_dir) + self.output_dir = normalize_output_dir(output_dir) else: self.output_dir = get_benchmark_runs_datagen_path("tpcds_obt", scale_factor) # TPC-DS source directory (for base TPC-DS data) if tpcds_source_dir: - self.tpcds_source_dir = Path(tpcds_source_dir) + self.tpcds_source_dir = normalize_output_dir(tpcds_source_dir) else: self.tpcds_source_dir = get_benchmark_runs_datagen_path("tpcds", scale_factor) @@ -510,27 +520,62 @@ def get_schema(self) -> dict[str, Any]: return {table.name: table} def get_create_tables_sql(self, dialect: str = "standard", tuning_config=None) -> str: - """Generate DDL for creating the OBT table. + """Generate DDL for creating the OBT table plus its TPC-DS source tables. Args: dialect: Target SQL dialect for the DDL. tuning_config: Optional tuning configuration (accepted for API compatibility, - not currently used by OBT benchmark). + forwarded to the TPC-DS source-table DDL; not used by the OBT table itself). Returns: - DDL SQL string for creating the OBT table. + DDL SQL string creating the 25 TPC-DS source tables (needed because + cloud loaders resolve data files from ``self.tables``, which holds + the per-table source files after datagen) followed by the OBT table. """ + from benchbox.core.tpcds.schema import get_create_all_tables_sql from benchbox.core.tpcds_obt import schema from benchbox.utils.dialect_utils import translate_sql_query + enable_primary_keys = tuning_config.primary_keys.enabled if tuning_config else False + enable_foreign_keys = tuning_config.foreign_keys.enabled if tuning_config else False + source_ddl = get_create_all_tables_sql( + enable_primary_keys=enable_primary_keys, + enable_foreign_keys=enable_foreign_keys, + ) + # Cloud runs reuse the TPC-DS database/schema via get_data_source_benchmark(), + # so plain CREATE TABLE would fail against already-loaded source tables. + # IF NOT EXISTS is accepted by every cloud target (BigQuery, Snowflake, + # Databricks, Redshift, Synapse) and keeps reruns idempotent. + source_ddl = re.sub( + r"(?im)^CREATE TABLE (?!IF NOT EXISTS )", + "CREATE TABLE IF NOT EXISTS ", + source_ddl, + ) # Note: tuning_config is accepted for API compatibility but OBT uses a fixed schema ddl = schema.get_obt_table(self.dimension_mode).get_create_table_sql() target = dialect.lower() if dialect else "duckdb" if target not in {"duckdb", "postgres", "ansi", "standard"}: + # Translate statement-by-statement: sqlglot.transpile returns only + # the first element for multi-statement input, which would drop + # 24 of the 25 source tables (datavault follows the same pattern). + source_statements = [stmt.strip() for stmt in source_ddl.split(";") if stmt.strip()] + source_ddl = ( + ";\n\n".join( + translate_sql_query( + stmt, + target_dialect=target, + source_dialect="standard", + identify=True, + scope="schema_ddl", + ) + for stmt in source_statements + ) + + ";" + ) ddl = translate_sql_query( ddl, target_dialect=target, source_dialect="standard", identify=True, scope="schema_ddl" ) - return ddl + return f"{source_ddl}\n\n{ddl}" def __enter__(self) -> TPCDSOBTBenchmark: return self diff --git a/benchbox/core/tpcds_obt/query_conversion.py b/benchbox/core/tpcds_obt/query_conversion.py index 1d70337990..8ffc5ae57c 100644 --- a/benchbox/core/tpcds_obt/query_conversion.py +++ b/benchbox/core/tpcds_obt/query_conversion.py @@ -16,7 +16,14 @@ from benchbox.core.tpcds_obt.schema import OBT_TABLE_NAME, get_column_lineage -TEMPLATE_DIR = Path(__file__).resolve().parents[3] / "_sources" / "tpc-ds" / "query_templates" + +def _resolve_template_dir() -> Path: + from benchbox.utils.tpc_compilation import get_tpc_templates_dir + + return get_tpc_templates_dir("tpc-ds") / "query_templates" + + +TEMPLATE_DIR = _resolve_template_dir() def _load_query_specs() -> dict[str, Any]: diff --git a/benchbox/core/tpch/benchmark.py b/benchbox/core/tpch/benchmark.py index 16891402e7..98251cbc2a 100644 --- a/benchbox/core/tpch/benchmark.py +++ b/benchbox/core/tpch/benchmark.py @@ -27,24 +27,45 @@ from benchbox.core.tpch.schema import TABLES from benchbox.core.tpch.streams import TPCHStreams -# TPC-H Reference Seeds for Validation -# The official TPC-H answer files at SF=1.0 were generated using seed 0o0101000000 (17039360 decimal) -# This seed must be used when performing exact validation against official answer files -TPCH_SF1_REFERENCE_SEED = 0o0101000000 # 17039360 decimal +TPCH_ANSWER_SET_SCALE_FACTOR = 1.0 -def get_reference_seed(scale_factor: float) -> int | None: - """Get the reference seed for exact validation at a given scale factor. +def has_answer_set(scale_factor: float) -> bool: + return float(scale_factor) == TPCH_ANSWER_SET_SCALE_FACTOR - Args: - scale_factor: Scale factor for the benchmark - Returns: - Reference seed for exact validation, or None if no reference exists - """ - if scale_factor == 1.0: - return TPCH_SF1_REFERENCE_SEED - return None +def binds_answer_set_parameters(seed: int | None) -> bool: + return seed is None + + +def power_stream_seed(seed: int | None, stream_id: int) -> int | None: + if seed is None: + return None + return seed + stream_id * 1000 + + +def describe_query_parameters( + seed: int | None, *, execution_type: str = "power", requested_phases: set[str] | None = None +) -> str: + if execution_type == "standard": + seed = None + elif execution_type == "throughput": + from benchbox.core.tpch.throughput_test import TPCHThroughputTestConfig + + base_seed = TPCHThroughputTestConfig().base_seed if seed is None else seed + return f"qgen -r ({base_seed} + 1001 * stream_id + query_position)" + elif execution_type == "maintenance": + return "not applicable (TPC-H refresh functions)" + elif execution_type == "combined": + phases = requested_phases or {"power", "throughput", "maintenance"} + return "; ".join( + f"{phase}: {describe_query_parameters(seed, execution_type=phase)}" + for phase in ("power", "throughput", "maintenance") + if phase in phases + ) + if seed is None: + return "qgen -d (TPC-H default substitution parameters)" + return f"qgen -r ({seed} + 1000 * stream_id)" # Data structures for test results diff --git a/benchbox/core/tpch/dataframe_queries.py b/benchbox/core/tpch/dataframe_queries.py index dc5843779e..5f72076cb7 100644 --- a/benchbox/core/tpch/dataframe_queries.py +++ b/benchbox/core/tpch/dataframe_queries.py @@ -23,6 +23,9 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar from csv import reader from datetime import date from typing import Any @@ -90,7 +93,9 @@ # Module-level parameter overrides. When set by the DataFrame run path before # query execution, get_tpch_parameters() merges these into the defaults. -_parameter_overrides: dict[int, dict[str, Any]] | None = None +_parameter_overrides: ContextVar[dict[int, dict[str, Any]] | None] = ContextVar( + "tpch_parameter_overrides", default=None +) # Module-level scale factor for scale-dependent parameter defaults. Canonical # TPC-H Q11 renders its value threshold as 0.0001 / SF (qgen does this); the @@ -99,7 +104,7 @@ # every scale, mirroring the SQL run path's {q11_fraction} rendering. Set by the # DataFrame run path (and the TPC-Havoc equivalence gate) before execution; # defaults to 1.0 (the qgen SF=1 rendering). -_scale_factor: float = 1.0 +_scale_factor: ContextVar[float] = ContextVar("tpch_scale_factor", default=1.0) def set_parameter_overrides(overrides: dict[int, dict[str, Any]] | None) -> None: @@ -111,8 +116,27 @@ def set_parameter_overrides(overrides: dict[int, dict[str, Any]] | None) -> None Args: overrides: Dict mapping query_id (1-22) to param dict, or None to clear. """ - global _parameter_overrides - _parameter_overrides = overrides + _parameter_overrides.set(overrides) + + +@contextmanager +def seeded_parameter_overrides(seed: int | None, scale_factor: float, stream_id: int) -> Iterator[None]: + scale_factor = float(scale_factor) + overrides = None + if seed is not None: + from benchbox.core.tpch.benchmark import power_stream_seed + from benchbox.core.tpch.parameter_extractor import get_tpch_extracted_parameters + + stream_seed = power_stream_seed(seed, stream_id) + assert stream_seed is not None + overrides = get_tpch_extracted_parameters(stream_seed, float(scale_factor)) + parameter_token = _parameter_overrides.set(overrides) + scale_token = _scale_factor.set(scale_factor) + try: + yield + finally: + _scale_factor.reset(scale_token) + _parameter_overrides.reset(parameter_token) def set_scale_factor(scale_factor: float | None) -> None: @@ -130,8 +154,7 @@ def set_scale_factor(scale_factor: float | None) -> None: Args: scale_factor: The run's scale factor, or None to reset to 1.0. """ - global _scale_factor - _scale_factor = 1.0 if scale_factor is None else float(scale_factor) + _scale_factor.set(1.0 if scale_factor is None else float(scale_factor)) # Benchmark ids whose DataFrame queries reuse this module's parameter seam @@ -179,14 +202,16 @@ def get_tpch_parameters(query_id: int) -> dict[str, Any]: Dict of parameter values for this query. """ params = dict(TPCH_DEFAULT_PARAMS.get(query_id, {})) - if query_id == 11 and "fraction" in params and _scale_factor > 0: + scale_factor = _scale_factor.get() + overrides = _parameter_overrides.get() + if query_id == 11 and "fraction" in params and scale_factor > 0: # Mirror canonical qgen's `0.0001 / SF` rendering exactly - including its # 10-decimal literal - so the unseeded default matches both the SQL run # path's {q11_fraction} token and the seeded extraction (which parses # that same literal). TPCH_DEFAULT_PARAMS holds the SF=1 base value. - params["fraction"] = float(f"{params['fraction'] / _scale_factor:.10f}") - if _parameter_overrides is not None and query_id in _parameter_overrides: - params.update(_parameter_overrides[query_id]) + params["fraction"] = float(f"{params['fraction'] / scale_factor:.10f}") + if overrides is not None and query_id in overrides: + params.update(overrides[query_id]) return params @@ -837,10 +862,15 @@ def q15_expression_impl(ctx: DataFrameContext) -> Any: # Find maximum revenue using optimized scalar extraction max_revenue = ctx.scalar(revenue.select(col("total_revenue").max().alias("max_rev"))) - # Join with suppliers having maximum revenue + # Join with suppliers having maximum revenue. Compare with a small relative + # tolerance instead of exact float equality: the stored per-supplier + # aggregate and the scalar max can differ in the last ulp after separate + # float summation paths (observed: ...660600001 vs ...6606), which would + # otherwise filter out the true top supplier. + tolerance = abs(max_revenue) * 1e-9 if max_revenue else 1e-9 return ( supplier.join(revenue, left_on="s_suppkey", right_on="supplier_no") - .filter(col("total_revenue") == lit(max_revenue)) + .filter((col("total_revenue") - lit(max_revenue)).abs() <= lit(tolerance)) .select("s_suppkey", "s_name", "s_address", "s_phone", "total_revenue") .sort("s_suppkey") ) @@ -896,13 +926,24 @@ def q17_expression_impl(ctx: DataFrameContext) -> Any: # Calculate average quantity per part avg_qty_per_part = lineitem.group_by("l_partkey").agg((col("l_quantity").mean() * lit(0.2)).alias("avg_qty")) - # Main query - return ( + # SQL SUM over an empty or all-NULL input returns NULL. Aggregate first, + # then project the conditional result so every backend infers its type + # from the native sum without constructing a NULL-only DataFrame. + filtered = ( part.filter((col("p_brand") == lit(brand)) & (col("p_container") == lit(container))) .join(lineitem, left_on="p_partkey", right_on="l_partkey") .join(avg_qty_per_part, left_on="p_partkey", right_on="l_partkey") .filter(col("l_quantity") < col("avg_qty")) - .select((col("l_extendedprice").sum() / lit(7.0)).alias("avg_yearly")) + ) + totals = filtered.select( + col("l_extendedprice").count().alias("__q17_price_count"), + col("l_extendedprice").sum().alias("__q17_price_sum"), + ) + return totals.select( + ctx.when(col("__q17_price_count") > lit(0)) + .then(col("__q17_price_sum") / lit(7.0)) + .otherwise(lit(None)) + .alias("avg_yearly") ) @@ -1749,7 +1790,11 @@ def q17_pandas_impl(ctx: DataFrameContext) -> Any: joined = joined[joined["l_quantity"] < joined["avg_qty"]] # Calculate result - # Note: compute() handles both lazy (Dask) and eager (Pandas) values + # Note: compute() handles both lazy (Dask) and eager (Pandas) values. + # SQL SUM over an empty set returns NULL, not 0: preserve that so the + # gate compares NULL-vs-NULL instead of manufacturing 0.0. + if len(joined) == 0: + return pd.DataFrame({"avg_yearly": [None]}) avg_yearly = joined["l_extendedprice"].sum() / 7.0 avg_yearly_val = avg_yearly.compute() if hasattr(avg_yearly, "compute") else avg_yearly diff --git a/benchbox/core/tpch/parameter_extractor.py b/benchbox/core/tpch/parameter_extractor.py index 92a0950fd0..d8884911f2 100644 --- a/benchbox/core/tpch/parameter_extractor.py +++ b/benchbox/core/tpch/parameter_extractor.py @@ -13,301 +13,227 @@ from __future__ import annotations -import logging import re from datetime import date, timedelta from typing import Any -logger = logging.getLogger(__name__) + +class ParameterExtractionError(ValueError): + pass def extract_tpch_parameters( seed: int, scale_factor: float = 1.0, ) -> dict[int, dict[str, Any]]: - """Extract TPC-H substitution parameters from qgen output for all 22 queries. - - Runs the qgen binary with the given seed/SF and parses the generated SQL - to extract parameter values. This is the authoritative source of truth - for TPC-H query parameters. - - Args: - seed: Random number generator seed for parameter generation. - scale_factor: Scale factor for parameter calculations. - - Returns: - Dict mapping query_id (1-22) to parameter dict. Each parameter dict - uses the same keys as TPCH_DEFAULT_PARAMS in dataframe_queries.py. - - Raises: - RuntimeError: If qgen binary is not available. - """ from benchbox.core.tpch.queries import QGenBinary qgen = QGenBinary() - params: dict[int, dict[str, Any]] = {} - - for query_id in range(1, 23): - try: - sql = qgen.generate(query_id, seed=seed, scale_factor=scale_factor) - extracted = _extract_query_params(query_id, sql) - if extracted: - params[query_id] = extracted - except Exception: - logger.warning("Failed to extract parameters for TPC-H Q%d with seed=%d", query_id, seed) - - return params + return { + query_id: _extract_query_params(query_id, qgen.generate(query_id, seed=seed, scale_factor=scale_factor)) + for query_id in range(1, 23) + } # -- Per-query extraction functions ---------------------------------------- -def _extract_query_params(query_id: int, sql: str) -> dict[str, Any] | None: +def _extract_query_params(query_id: int, sql: str) -> dict[str, Any]: """Dispatch to the appropriate extractor for a given query.""" extractor = _EXTRACTORS.get(query_id) if extractor is None: - return None + raise ParameterExtractionError(f"No parameter extractor for TPC-H query {query_id}") try: return extractor(sql) - except Exception: - logger.warning("Parameter extraction failed for TPC-H Q%d", query_id, exc_info=True) - return None + except ParameterExtractionError as exc: + raise ParameterExtractionError(f"TPC-H Q{query_id}: {exc}") from None + + +def _search(pattern: str, sql: str, flags: int = 0) -> re.Match[str]: + match = re.search(pattern, sql, flags) + if match is None: + raise ParameterExtractionError(f"pattern {pattern!r} not found in qgen output") + return match + + +def _date_after(column: str, operator: str, sql: str) -> date: + return _parse_date(_search(rf"{column}\s*{operator}\s*date\s+'(\d{{4}}-\d{{2}}-\d{{2}})'", sql).group(1)) def _q1(sql: str) -> dict[str, Any]: # l_shipdate <= date '1998-12-01' - interval '68' day - m = re.search(r"interval\s+'(\d+)'\s+day", sql, re.IGNORECASE) - delta = int(m.group(1)) if m else 90 - cutoff = date(1998, 12, 1) - cutoff_date = cutoff - timedelta(days=delta) - return {"delta": delta, "cutoff_date": cutoff_date} + delta = int(_search(r"interval\s+'(\d+)'\s+day", sql, re.IGNORECASE).group(1)) + return {"delta": delta, "cutoff_date": date(1998, 12, 1) - timedelta(days=delta)} def _q2(sql: str) -> dict[str, Any]: # p_size = 38 and p_type like '%STEEL' and r_name = 'ASIA' - size_m = re.search(r"p_size\s*=\s*(\d+)", sql) - type_m = re.search(r"p_type\s+like\s+'%(\w+)'", sql, re.IGNORECASE) - region_m = re.search(r"r_name\s*=\s*'([^']+)'", sql) return { - "size": int(size_m.group(1)) if size_m else 15, - "type_suffix": type_m.group(1) if type_m else "BRASS", - "region_name": region_m.group(1) if region_m else "EUROPE", + "size": int(_search(r"p_size\s*=\s*(\d+)", sql).group(1)), + "type_suffix": _search(r"p_type\s+like\s+'%(\w+)'", sql, re.IGNORECASE).group(1), + "region_name": _search(r"r_name\s*=\s*'([^']+)'", sql).group(1), } def _q3(sql: str) -> dict[str, Any]: # c_mktsegment = 'FURNITURE' and o_orderdate < date '1995-03-17' - seg_m = re.search(r"c_mktsegment\s*=\s*'([^']+)'", sql) - date_m = re.search(r"o_orderdate\s*<\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - order_date = _parse_date(date_m.group(1)) if date_m else date(1995, 3, 15) return { - "segment": seg_m.group(1) if seg_m else "BUILDING", - "order_date": order_date, + "segment": _search(r"c_mktsegment\s*=\s*'([^']+)'", sql).group(1), + "order_date": _date_after("o_orderdate", "<", sql), } def _q4(sql: str) -> dict[str, Any]: - # o_orderdate >= date '1995-07-01' - date_m = re.search(r"o_orderdate\s*>=\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - start_date = _parse_date(date_m.group(1)) if date_m else date(1993, 7, 1) - # End date is start + 3 months (interval in SQL) - end_date = _add_months(start_date, 3) - return {"start_date": start_date, "end_date": end_date} + start_date = _date_after("o_orderdate", ">=", sql) + return {"start_date": start_date, "end_date": _add_months(start_date, 3)} def _q5(sql: str) -> dict[str, Any]: - # r_name = 'AMERICA' and o_orderdate >= date '1993-01-01' - region_m = re.search(r"r_name\s*=\s*'([^']+)'", sql) - date_m = re.search(r"o_orderdate\s*>=\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - start_date = _parse_date(date_m.group(1)) if date_m else date(1994, 1, 1) - end_date = _add_years(start_date, 1) + start_date = _date_after("o_orderdate", ">=", sql) return { - "region_name": region_m.group(1) if region_m else "ASIA", + "region_name": _search(r"r_name\s*=\s*'([^']+)'", sql).group(1), "start_date": start_date, - "end_date": end_date, + "end_date": _add_years(start_date, 1), } def _q6(sql: str) -> dict[str, Any]: - # l_shipdate >= date '1993-01-01' ... l_discount between 0.07 - 0.01 and 0.07 + 0.01 ... l_quantity < 25 - date_m = re.search(r"l_shipdate\s*>=\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - disc_m = re.search(r"l_discount\s+between\s+([\d.]+)\s*-\s*[\d.]+\s+and\s+([\d.]+)\s*\+", sql, re.IGNORECASE) - qty_m = re.search(r"l_quantity\s*<\s*(\d+)", sql) - - start_date = _parse_date(date_m.group(1)) if date_m else date(1994, 1, 1) - end_date = _add_years(start_date, 1) - - if disc_m: - discount = float(disc_m.group(1)) - else: - discount = 0.06 - + start_date = _date_after("l_shipdate", ">=", sql) + discount = float( + _search(r"l_discount\s+between\s+([\d.]+)\s*-\s*[\d.]+\s+and\s+([\d.]+)\s*\+", sql, re.IGNORECASE).group(1) + ) return { "start_date": start_date, - "end_date": end_date, - "discount_low": discount - 0.01, - "discount_high": discount + 0.01, + "end_date": _add_years(start_date, 1), + "discount_low": round(discount - 0.01, 2), + "discount_high": round(discount + 0.01, 2), "discount": discount, - "quantity_limit": int(qty_m.group(1)) if qty_m else 24, + "quantity_limit": int(_search(r"l_quantity\s*<\s*(\d+)", sql).group(1)), } def _q7(sql: str) -> dict[str, Any]: # n1.n_name = 'MOZAMBIQUE' and n2.n_name = 'UNITED KINGDOM' - m = re.search(r"n1\.n_name\s*=\s*'([^']+)'\s+and\s+n2\.n_name\s*=\s*'([^']+)'", sql, re.IGNORECASE) - return { - "nation1": m.group(1) if m else "FRANCE", - "nation2": m.group(2) if m else "GERMANY", - } + m = _search(r"n1\.n_name\s*=\s*'([^']+)'\s+and\s+n2\.n_name\s*=\s*'([^']+)'", sql, re.IGNORECASE) + return {"nation1": m.group(1), "nation2": m.group(2)} def _q8(sql: str) -> dict[str, Any]: # nation = 'MOZAMBIQUE' ... r_name = 'AFRICA' ... p_type = 'PROMO POLISHED TIN' - nation_m = re.search(r"when\s+nation\s*=\s*'([^']+)'", sql, re.IGNORECASE) - region_m = re.search(r"r_name\s*=\s*'([^']+)'", sql) - type_m = re.search(r"p_type\s*=\s*'([^']+)'", sql) return { - "target_nation": nation_m.group(1) if nation_m else "BRAZIL", - "target_region": region_m.group(1) if region_m else "AMERICA", - "target_type": type_m.group(1) if type_m else "ECONOMY ANODIZED STEEL", + "target_nation": _search(r"when\s+nation\s*=\s*'([^']+)'", sql, re.IGNORECASE).group(1), + "target_region": _search(r"r_name\s*=\s*'([^']+)'", sql).group(1), + "target_type": _search(r"p_type\s*=\s*'([^']+)'", sql).group(1), } def _q9(sql: str) -> dict[str, Any]: # p_name like '%thistle%' - m = re.search(r"p_name\s+like\s+'%([^%]+)%'", sql, re.IGNORECASE) - return {"color": m.group(1) if m else "green"} + return {"color": _search(r"p_name\s+like\s+'%([^%]+)%'", sql, re.IGNORECASE).group(1)} def _q10(sql: str) -> dict[str, Any]: - # o_orderdate >= date '1993-11-01' - date_m = re.search(r"o_orderdate\s*>=\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - start_date = _parse_date(date_m.group(1)) if date_m else date(1993, 10, 1) - end_date = _add_months(start_date, 3) - return {"start_date": start_date, "end_date": end_date} + start_date = _date_after("o_orderdate", ">=", sql) + return {"start_date": start_date, "end_date": _add_months(start_date, 3)} def _q11(sql: str) -> dict[str, Any]: # n_name = 'JAPAN' ... sum(...) * 0.0001000000 - nation_m = re.search(r"n_name\s*=\s*'([^']+)'", sql) - frac_m = re.search(r"\*\s*([\d.]+)\s*$", sql, re.MULTILINE) return { - "nation_name": nation_m.group(1) if nation_m else "GERMANY", - "fraction": float(frac_m.group(1)) if frac_m else 0.0001, + "nation_name": _search(r"n_name\s*=\s*'([^']+)'", sql).group(1), + "fraction": float(_search(r"\*\s*([\d.]+)\s*$", sql, re.MULTILINE).group(1)), } def _q12(sql: str) -> dict[str, Any]: # l_shipmode in ('FOB', 'REG AIR') ... l_receiptdate >= date '1993-01-01' - modes_m = re.search(r"l_shipmode\s+in\s+\('([^']+)',\s*'([^']+)'\)", sql, re.IGNORECASE) - date_m = re.search(r"l_receiptdate\s*>=\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - start_date = _parse_date(date_m.group(1)) if date_m else date(1994, 1, 1) - end_date = _add_years(start_date, 1) + modes = _search(r"l_shipmode\s+in\s+\('([^']+)',\s*'([^']+)'\)", sql, re.IGNORECASE) + start_date = _date_after("l_receiptdate", ">=", sql) return { - "shipmode1": modes_m.group(1) if modes_m else "MAIL", - "shipmode2": modes_m.group(2) if modes_m else "SHIP", + "shipmode1": modes.group(1), + "shipmode2": modes.group(2), "start_date": start_date, - "end_date": end_date, + "end_date": _add_years(start_date, 1), } def _q13(sql: str) -> dict[str, Any]: # o_comment not like '%special%packages%' - m = re.search(r"o_comment\s+not\s+like\s+'%([^%]+)%([^%]+)%'", sql, re.IGNORECASE) - return { - "word1": m.group(1) if m else "special", - "word2": m.group(2) if m else "requests", - } + m = _search(r"o_comment\s+not\s+like\s+'%([^%]+)%([^%]+)%'", sql, re.IGNORECASE) + return {"word1": m.group(1), "word2": m.group(2)} def _q14(sql: str) -> dict[str, Any]: - # l_shipdate >= date '1993-04-01' - date_m = re.search(r"l_shipdate\s*>=\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - start_date = _parse_date(date_m.group(1)) if date_m else date(1995, 9, 1) - end_date = _add_months(start_date, 1) - return {"start_date": start_date, "end_date": end_date} + start_date = _date_after("l_shipdate", ">=", sql) + return {"start_date": start_date, "end_date": _add_months(start_date, 1)} def _q15(sql: str) -> dict[str, Any]: - # l_shipdate >= date '1995-07-01' - date_m = re.search(r"l_shipdate\s*>=\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - start_date = _parse_date(date_m.group(1)) if date_m else date(1996, 1, 1) - end_date = _add_months(start_date, 3) - return {"start_date": start_date, "end_date": end_date} + start_date = _date_after("l_shipdate", ">=", sql) + return {"start_date": start_date, "end_date": _add_months(start_date, 3)} def _q16(sql: str) -> dict[str, Any]: # p_brand <> 'Brand#41' and p_type not like 'MEDIUM BURNISHED%' and p_size in (4, 22, ...) - brand_m = re.search(r"p_brand\s*<>\s*'([^']+)'", sql) - type_m = re.search(r"p_type\s+not\s+like\s+'([^%]+)%'", sql, re.IGNORECASE) - sizes_m = re.search(r"p_size\s+in\s+\(([^)]+)\)", sql, re.IGNORECASE) - sizes = [int(s.strip()) for s in sizes_m.group(1).split(",")] if sizes_m else [49, 14, 23, 45, 19, 3, 36, 9] + sizes = _search(r"p_size\s+in\s+\(([^)]+)\)", sql, re.IGNORECASE).group(1) return { - "brand": brand_m.group(1) if brand_m else "Brand#45", - "type_prefix": type_m.group(1).rstrip() if type_m else "MEDIUM POLISHED", - "sizes": sizes, + "brand": _search(r"p_brand\s*<>\s*'([^']+)'", sql).group(1), + "type_prefix": _search(r"p_type\s+not\s+like\s+'([^%]+)%'", sql, re.IGNORECASE).group(1).rstrip(), + "sizes": [int(size.strip()) for size in sizes.split(",")], } def _q17(sql: str) -> dict[str, Any]: # p_brand = 'Brand#12' and p_container = 'SM BAG' - brand_m = re.search(r"p_brand\s*=\s*'([^']+)'", sql) - container_m = re.search(r"p_container\s*=\s*'([^']+)'", sql) return { - "brand": brand_m.group(1) if brand_m else "Brand#23", - "container": container_m.group(1) if container_m else "MED BOX", + "brand": _search(r"p_brand\s*=\s*'([^']+)'", sql).group(1), + "container": _search(r"p_container\s*=\s*'([^']+)'", sql).group(1), } def _q18(sql: str) -> dict[str, Any]: # sum(l_quantity) > 313 - m = re.search(r"sum\(l_quantity\)\s*>\s*(\d+)", sql) - return {"quantity_threshold": int(m.group(1)) if m else 300} + return {"quantity_threshold": int(_search(r"sum\(l_quantity\)\s*>\s*(\d+)", sql).group(1))} def _q19(sql: str) -> dict[str, Any]: # Three brand/quantity groups brands = re.findall(r"p_brand\s*=\s*'([^']+)'", sql) quantities = re.findall(r"l_quantity\s*>=\s*(\d+)", sql) + if len(brands) != 3 or len(quantities) != 3: + raise ParameterExtractionError( + f"expected 3 brands and 3 quantities in qgen output, found {len(brands)} and {len(quantities)}" + ) return { - "brand1": brands[0] if len(brands) > 0 else "Brand#12", - "brand2": brands[1] if len(brands) > 1 else "Brand#23", - "brand3": brands[2] if len(brands) > 2 else "Brand#34", - "quantity1": int(quantities[0]) if len(quantities) > 0 else 1, - "quantity2": int(quantities[1]) if len(quantities) > 1 else 10, - "quantity3": int(quantities[2]) if len(quantities) > 2 else 20, + "brand1": brands[0], + "brand2": brands[1], + "brand3": brands[2], + "quantity1": int(quantities[0]), + "quantity2": int(quantities[1]), + "quantity3": int(quantities[2]), } def _q20(sql: str) -> dict[str, Any]: # p_name like 'ivory%' ... n_name = 'KENYA' ... l_shipdate >= date '1996-01-01' - color_m = re.search(r"p_name\s+like\s+'([^%]+)%'", sql, re.IGNORECASE) - nation_m = re.search(r"n_name\s*=\s*'([^']+)'", sql) - date_m = re.search(r"l_shipdate\s*>=\s*date\s+'(\d{4}-\d{2}-\d{2})'", sql) - start_date = _parse_date(date_m.group(1)) if date_m else date(1994, 1, 1) - end_date = _add_years(start_date, 1) + start_date = _date_after("l_shipdate", ">=", sql) return { - "color_prefix": color_m.group(1) if color_m else "forest", - "nation_name": nation_m.group(1) if nation_m else "CANADA", + "color_prefix": _search(r"p_name\s+like\s+'([^%]+)%'", sql, re.IGNORECASE).group(1), + "nation_name": _search(r"n_name\s*=\s*'([^']+)'", sql).group(1), "start_date": start_date, - "end_date": end_date, + "end_date": _add_years(start_date, 1), } def _q21(sql: str) -> dict[str, Any]: # n_name = 'PERU' - m = re.search(r"n_name\s*=\s*'([^']+)'", sql) - return {"nation_name": m.group(1) if m else "SAUDI ARABIA"} + return {"nation_name": _search(r"n_name\s*=\s*'([^']+)'", sql).group(1)} def _q22(sql: str) -> dict[str, Any]: # substring(c_phone from 1 for 2) in ('24', '33', '31', '10', '15', '28', '23') - m = re.search(r"for\s+2\)\s+in\s*\n?\s*\(([^)]+)\)", sql, re.IGNORECASE) - if m: - codes = re.findall(r"'(\d+)'", m.group(1)) - else: - codes = ["13", "31", "23", "29", "30", "18", "17"] - return {"country_codes": codes} + codes = _search(r"for\s+2\)\s+in\s*\n?\s*\(([^)]+)\)", sql, re.IGNORECASE).group(1) + return {"country_codes": re.findall(r"'(\d+)'", codes)} # -- Helpers --------------------------------------------------------------- diff --git a/benchbox/core/tpch/power_test.py b/benchbox/core/tpch/power_test.py index c896178e66..0ff245612b 100644 --- a/benchbox/core/tpch/power_test.py +++ b/benchbox/core/tpch/power_test.py @@ -20,6 +20,7 @@ from benchbox.core.plan_capture_phase import ( propagate_query_execution_metadata, ) +from benchbox.core.tpch.benchmark import binds_answer_set_parameters, has_answer_set, power_stream_seed from benchbox.core.validation.query_validation import ( clear_reference_seed_context, set_reference_seed_context, @@ -139,41 +140,29 @@ def __init__( if verbose: self.logger.setLevel(logging.INFO) - # Import reference seed function - from benchbox.core.tpch.benchmark import get_reference_seed - - # Determine seed and validation mode based on user input and reference seed availability - reference_seed = get_reference_seed(scale_factor) - # Stashed for run()'s per-query reference-seed context (see - # benchbox.core.validation.query_validation.set_reference_seed_context) -- - # avoids recomputing get_reference_seed() on every query. - self.reference_seed = reference_seed user_provided_seed = seed is not None actual_seed = seed # None = use qgen defaults mode (-d flag) actual_validation_mode = validation_mode or "exact" - # Seed selection and validation mode logic if user_provided_seed: - # User provided a custom seed actual_seed = seed - if validation and reference_seed and seed != reference_seed: - # Custom seed conflicts with exact validation + if validation and has_answer_set(scale_factor): if validation_mode is None: # Auto-switch to loose validation actual_validation_mode = "loose" if verbose: self.logger.warning( - f"⚠️ Custom seed {seed} with validation enabled.\n" - f" Reference seed for SF={scale_factor} is {reference_seed}.\n" - f" Switching to LOOSE validation (±50% tolerance)." + f"⚠️ Seed {seed} with validation enabled.\n" + " The answer files hold results for qgen's default parameters (no seed).\n" + " Switching to LOOSE validation (±50% tolerance)." ) elif validation_mode == "exact": - # User explicitly requested exact mode with wrong seed - warn but honor request if verbose: self.logger.warning( - f"⚠️ Custom seed {seed} with EXACT validation mode.\n" - f" Reference seed for SF={scale_factor} is {reference_seed}.\n" - f" Validation will likely FAIL due to parameter mismatch." + f"⚠️ Seed {seed} with EXACT validation mode.\n" + " The answer files hold results for qgen's default parameters (no seed).\n" + " Queries whose row count depends on the parameters are checked against\n" + " their allowed range instead of the answer-file count." ) else: # No seed provided - use qgen defaults mode (seed=None → -d flag) @@ -296,12 +285,7 @@ def run(self) -> TPCHPowerTestResult: f"Executing Query {query_id} (position {position + 1}/{len(query_permutation)})" ) - # Get the query with proper stream-aware parameters - # Use stream-specific seed as per TPC-H specification - # NOTE: All queries in a stream use the SAME seed (base_seed + stream_id * 1000) - # The position only determines query execution ORDER via permutation matrix - # When seed is None, use qgen defaults mode (-d flag) for all streams - stream_seed = None if self.config.seed is None else self.config.seed + self.config.stream_id * 1000 + stream_seed = power_stream_seed(self.config.seed, self.config.stream_id) query_text = self.benchmark.get_query( query_id, seed=stream_seed, @@ -313,21 +297,10 @@ def run(self) -> TPCHPowerTestResult: # Execute the actual query against the database label = f"Position_{position + 1}_Query_{query_id}" try: - # Set query context for validation - # NOTE: Answer files are only available for stream 0 (seed 17039360 for SF=1.0) - # Other streams use different seeds and will have different expected row counts if hasattr(self.connection, "set_query_context"): self.connection.set_query_context(query_id, stream_id=self.config.stream_id) - # Tell QueryValidator whether THIS query's parameters match the - # pinned reference seed (None/qgen-defaults counts as reference- - # equivalent, matching the __init__ seed-selection logic above). - # This context is authoritative for the parameter-sensitive queries - # (Q11/16/18/20): a reference match keeps their EXACT answer-file - # check, a non-reference seed relaxes them to their RANGE/LOOSE - # bounds. Cleared in the finally below regardless of outcome so it - # never leaks into unrelated validate_query_result() calls on this thread. - set_reference_seed_context(stream_seed is None or stream_seed == self.reference_seed) + set_reference_seed_context(binds_answer_set_parameters(stream_seed)) cursor = self.connection.execute(query_text) @@ -480,9 +453,7 @@ def get_all_queries(self) -> dict[str, str]: query_permutation = TPCHStreams.PERMUTATION_MATRIX[stream_id % len(TPCHStreams.PERMUTATION_MATRIX)] queries = {} - # All queries in a stream use the SAME seed (base_seed + stream_id * 1000) - # When seed is None, use qgen defaults mode (-d flag) - stream_seed = None if self.config.seed is None else self.config.seed + self.config.stream_id * 1000 + stream_seed = power_stream_seed(self.config.seed, self.config.stream_id) for position, query_id in enumerate(query_permutation): try: query_text = self.benchmark.get_query( @@ -503,9 +474,7 @@ def _preflight_validate_generation(self, query_permutation: list[int]) -> None: Raises RuntimeError with details if any query generation fails. """ failures = [] - # All queries in a stream use the SAME seed (base_seed + stream_id * 1000) - # When seed is None, use qgen defaults mode (-d flag) - stream_seed = None if self.config.seed is None else self.config.seed + self.config.stream_id * 1000 + stream_seed = power_stream_seed(self.config.seed, self.config.stream_id) for position, query_id in enumerate(query_permutation): try: _ = self.benchmark.get_query( diff --git a/benchbox/core/tpch/throughput_test.py b/benchbox/core/tpch/throughput_test.py index 35068aed8c..d2d90bc4ec 100644 --- a/benchbox/core/tpch/throughput_test.py +++ b/benchbox/core/tpch/throughput_test.py @@ -60,17 +60,6 @@ class TPCHThroughputTestConfig: def _derive_query_seed(seed: int, stream_id: int, position: int) -> int: - """Per-query qgen seed for one throughput stream position. - - ``seed`` is the per-stream base seed StreamRunner hands to - ``_execute_stream`` (``config.base_seed + stream_id`` -- see - ``benchbox.core.throughput.runner``). The SINGLE definition of the - per-position formula: query generation (pre-generation pass and the - ``_resolve_query_text`` inline fallback) and the reference-seed - validation context in ``_execute_stream`` must all derive the seed - through this helper so validation can never silently desynchronize - from the SQL that was actually generated. - """ return seed + stream_id * 1000 + position @@ -450,18 +439,6 @@ def _execute_stream( # in the background. See runner.py's module docstring. cancel_event = self._resolve_cancel_event(config, stream_id) - # Reference seed for this scale factor (None if scale_factor has no - # pinned reference, e.g. != 1.0) -- used below to tell QueryValidator - # whether EACH query's derived seed (seed + stream_id*1000 + position, - # same formula _resolve_query_text() uses to generate the SQL; not - # re-derived here, just re-read for validation-context purposes) - # matches the reference answer set. This context is authoritative for - # the parameter-sensitive queries: a reference match keeps their EXACT - # answer-file check, a non-reference seed relaxes them to RANGE/LOOSE. - from benchbox.core.tpch.benchmark import get_reference_seed - - reference_seed = get_reference_seed(config.scale_factor) - cancelled = False for position, query_id in enumerate(query_permutation): if self._cooperative_cancel_requested( @@ -495,18 +472,7 @@ def _execute_stream( # expected results are designed and registered. connection.set_query_context(query_id) - # Tell QueryValidator whether THIS query's derived seed - # (_derive_query_seed -- the same single definition query - # generation uses, so validation can never desynchronize - # from the generated SQL) matches the pinned reference - # seed. This context is authoritative for the parameter- - # sensitive queries: a reference match keeps their EXACT - # answer-file check, a non-reference seed relaxes them to - # RANGE/LOOSE. Cleared in the finally below regardless of - # outcome so it never leaks into unrelated - # validate_query_result() calls on this thread. - stream_seed = _derive_query_seed(seed, stream_id, position) - set_reference_seed_context(stream_seed == reference_seed) + set_reference_seed_context(False) cursor = connection.execute(query_text) diff --git a/benchbox/core/tpch_skew/generator.py b/benchbox/core/tpch_skew/generator.py index f5aace0e17..ea257daa7c 100644 --- a/benchbox/core/tpch_skew/generator.py +++ b/benchbox/core/tpch_skew/generator.py @@ -19,8 +19,9 @@ import logging import shutil import tempfile +from datetime import datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Iterator, Sequence import numpy as np import yaml @@ -33,6 +34,7 @@ UniformDistribution, ZipfianDistribution, ) +from benchbox.utils.cloud_storage import normalize_output_dir from benchbox.utils.verbosity import VerbosityMixin, compute_verbosity if TYPE_CHECKING: @@ -92,7 +94,10 @@ def __init__( **kwargs: Additional arguments passed to base generator """ self.scale_factor = scale_factor - self.output_dir = Path(output_dir) if output_dir else Path.cwd() / "tpch_skew_data" + # normalize_output_dir keeps a CloudStagingPath/DatabricksPath handler + # intact; the wrapper delegates mkdir/truediv to its local cache, so + # generation behavior is unchanged while the cloud target survives. + self.output_dir = normalize_output_dir(output_dir) or Path.cwd() / "tpch_skew_data" # Initialize verbosity verbosity_settings = compute_verbosity(verbose, quiet) @@ -352,37 +357,45 @@ def _normalize_shards(path_or_paths: Path | list[Path], table_name: str) -> Path return merged return path_or_paths - def _read_tbl_file(self, path: Path) -> list[list[str]]: - """Read a .tbl file into list of rows. - - Args: - path: Path to .tbl file - - Returns: - List of rows, each row is a list of field values - """ - rows = [] - with open(path, encoding="utf-8") as f: - reader = csv.reader(f, delimiter="|") - for row in reader: - # Remove trailing empty element from TPC-H format + def _iter_tbl_rows(self, path: Path) -> Iterator[list[str]]: + """Yield normalized TPC-H rows without retaining the table in memory.""" + with path.open(encoding="utf-8") as handle: + for row in csv.reader(handle, delimiter="|"): if row and row[-1] == "": row = row[:-1] if row: - rows.append(row) - return rows + yield row - def _write_tbl_file(self, rows: list[list[str]], path: Path) -> None: - """Write rows to a .tbl file. + def _stream_tbl_file( + self, + input_path: Path, + output_path: Path, + replacements: dict[int, tuple[np.ndarray, Sequence[str] | None]], + ) -> None: + """Apply pre-drawn skew values in row order while retaining only one row.""" + with output_path.open("w", encoding="utf-8", newline="") as output: + for index, row in enumerate(self._iter_tbl_rows(input_path)): + for column, (values, choices) in replacements.items(): + value = int(values[index]) + row[column] = choices[value] if choices is not None else str(value) + output.write("|".join(row) + "\n") + + def _count_tbl_rows(self, path: Path) -> int: + return sum(1 for _ in self._iter_tbl_rows(path)) + + def _temporal_day_offsets(self, num_rows: int, skew_factor: float) -> np.ndarray: + """Draw the same date offsets as the in-memory temporal transform.""" + total_days = (datetime(1998, 12, 31) - datetime(1992, 1, 1)).days + dist = ExponentialDistribution(rate=0.5 + skew_factor * 4) + samples = dist.sample(num_rows, self.rng) + concentrated = 1 - samples + return (concentrated * total_days).astype(int) - Args: - rows: List of rows to write - path: Output path - """ - with open(path, "w", encoding="utf-8", newline="") as f: - for row in rows: - # Match native dbgen output format: fields separated by |, NO trailing | - f.write("|".join(row) + "\n") + @staticmethod + def _date_choices() -> list[str]: + start_date = datetime(1992, 1, 1) + total_days = (datetime(1998, 12, 31) - start_date).days + return [(start_date + timedelta(days=day)).strftime("%Y-%m-%d") for day in range(total_days + 1)] def _generate_skewed_values( self, @@ -430,19 +443,18 @@ def _transform_customer(self, input_path: Path, output_path: Path) -> None: - c_nationkey: Customer nationality distribution - c_mktsegment: Market segment distribution """ - rows = self._read_tbl_file(input_path) attr_config = self.skew_config.attribute_skew + num_rows = self._count_tbl_rows(input_path) + replacements: dict[int, tuple[np.ndarray, Sequence[str] | None]] = {} if attr_config.customer_nation_skew > 0 or attr_config.customer_segment_skew > 0: - num_rows = len(rows) nation_col = _COLUMN_INDICES["customer"]["c_nationkey"] segment_col = _COLUMN_INDICES["customer"]["c_mktsegment"] # Skew nationkey (0-24) if attr_config.customer_nation_skew > 0: skewed_nations = self._generate_skewed_values(num_rows, 0, 24, attr_config.customer_nation_skew) - for i, row in enumerate(rows): - row[nation_col] = str(skewed_nations[i]) + replacements[nation_col] = (skewed_nations, None) # Skew market segment if attr_config.customer_segment_skew > 0: @@ -450,10 +462,9 @@ def _transform_customer(self, input_path: Path, output_path: Path) -> None: skewed_indices = self._generate_skewed_values( num_rows, 0, len(segments) - 1, attr_config.customer_segment_skew ) - for i, row in enumerate(rows): - row[segment_col] = segments[skewed_indices[i]] + replacements[segment_col] = (skewed_indices, segments) - self._write_tbl_file(rows, output_path) + self._stream_tbl_file(input_path, output_path, replacements) def _transform_supplier(self, input_path: Path, output_path: Path) -> None: """Apply skew to supplier table. @@ -461,18 +472,17 @@ def _transform_supplier(self, input_path: Path, output_path: Path) -> None: Skews applied: - s_nationkey: Supplier nationality distribution """ - rows = self._read_tbl_file(input_path) attr_config = self.skew_config.attribute_skew + num_rows = self._count_tbl_rows(input_path) + replacements: dict[int, tuple[np.ndarray, Sequence[str] | None]] = {} if attr_config.supplier_nation_skew > 0: - num_rows = len(rows) nation_col = _COLUMN_INDICES["supplier"]["s_nationkey"] skewed_nations = self._generate_skewed_values(num_rows, 0, 24, attr_config.supplier_nation_skew) - for i, row in enumerate(rows): - row[nation_col] = str(skewed_nations[i]) + replacements[nation_col] = (skewed_nations, None) - self._write_tbl_file(rows, output_path) + self._stream_tbl_file(input_path, output_path, replacements) def _transform_part(self, input_path: Path, output_path: Path) -> None: """Apply skew to part table. @@ -482,20 +492,19 @@ def _transform_part(self, input_path: Path, output_path: Path) -> None: - p_type: Part type distribution - p_container: Container type distribution """ - rows = self._read_tbl_file(input_path) attr_config = self.skew_config.attribute_skew - num_rows = len(rows) + num_rows = self._count_tbl_rows(input_path) brand_col = _COLUMN_INDICES["part"]["p_brand"] type_col = _COLUMN_INDICES["part"]["p_type"] container_col = _COLUMN_INDICES["part"]["p_container"] + replacements: dict[int, tuple[np.ndarray, Sequence[str] | None]] = {} # Brands: Brand#11 through Brand#55 if attr_config.part_brand_skew > 0: brands = [f"Brand#{i}{j}" for i in range(1, 6) for j in range(1, 6)] skewed_indices = self._generate_skewed_values(num_rows, 0, len(brands) - 1, attr_config.part_brand_skew) - for i, row in enumerate(rows): - row[brand_col] = brands[skewed_indices[i]] + replacements[brand_col] = (skewed_indices, brands) # Part types: combinations of type, metal, finish if attr_config.part_type_skew > 0: @@ -522,8 +531,7 @@ def _transform_part(self, input_path: Path, output_path: Path) -> None: "PROMO PLATED BRASS", ] skewed_indices = self._generate_skewed_values(num_rows, 0, len(types) - 1, attr_config.part_type_skew) - for i, row in enumerate(rows): - row[type_col] = types[skewed_indices[i]] + replacements[type_col] = (skewed_indices, types) # Container types if attr_config.part_container_skew > 0: @@ -557,10 +565,9 @@ def _transform_part(self, input_path: Path, output_path: Path) -> None: skewed_indices = self._generate_skewed_values( num_rows, 0, len(containers) - 1, attr_config.part_container_skew ) - for i, row in enumerate(rows): - row[container_col] = containers[skewed_indices[i]] + replacements[container_col] = (skewed_indices, containers) - self._write_tbl_file(rows, output_path) + self._stream_tbl_file(input_path, output_path, replacements) def _transform_partsupp(self, input_path: Path, output_path: Path) -> None: """Apply skew to partsupp table. @@ -580,15 +587,15 @@ def _transform_orders(self, input_path: Path, output_path: Path) -> None: - o_orderpriority: Order priority distribution - o_orderdate: Temporal skew (if enabled) """ - rows = self._read_tbl_file(input_path) attr_config = self.skew_config.attribute_skew join_config = self.skew_config.join_skew temporal_config = self.skew_config.temporal_skew - num_rows = len(rows) + num_rows = self._count_tbl_rows(input_path) custkey_col = _COLUMN_INDICES["orders"]["o_custkey"] priority_col = _COLUMN_INDICES["orders"]["o_orderpriority"] orderdate_col = _COLUMN_INDICES["orders"]["o_orderdate"] + replacements: dict[int, tuple[np.ndarray, Sequence[str] | None]] = {} # Calculate max customer key max_custkey = int(150_000 * self.scale_factor) @@ -596,8 +603,7 @@ def _transform_orders(self, input_path: Path, output_path: Path) -> None: # Apply customer ordering frequency skew (join skew) if join_config.customer_order_skew > 0 and self.skew_config.enable_join_skew: skewed_custkeys = self._generate_skewed_values(num_rows, 1, max_custkey, join_config.customer_order_skew) - for i, row in enumerate(rows): - row[custkey_col] = str(skewed_custkeys[i]) + replacements[custkey_col] = (skewed_custkeys, None) # Apply order priority skew if attr_config.order_priority_skew > 0 and self.skew_config.enable_attribute_skew: @@ -605,14 +611,16 @@ def _transform_orders(self, input_path: Path, output_path: Path) -> None: skewed_indices = self._generate_skewed_values( num_rows, 0, len(priorities) - 1, attr_config.order_priority_skew ) - for i, row in enumerate(rows): - row[priority_col] = priorities[skewed_indices[i]] + replacements[priority_col] = (skewed_indices, priorities) # Apply temporal skew to order dates if temporal_config.order_date_skew > 0 and self.skew_config.enable_temporal_skew: - self._apply_temporal_skew_to_dates(rows, orderdate_col, temporal_config.order_date_skew) + replacements[orderdate_col] = ( + self._temporal_day_offsets(num_rows, temporal_config.order_date_skew), + self._date_choices(), + ) - self._write_tbl_file(rows, output_path) + self._stream_tbl_file(input_path, output_path, replacements) def _transform_lineitem(self, input_path: Path, output_path: Path) -> None: """Apply skew to lineitem table. @@ -624,17 +632,17 @@ def _transform_lineitem(self, input_path: Path, output_path: Path) -> None: - l_returnflag: Return flag distribution - l_shipdate: Temporal skew (if enabled) """ - rows = self._read_tbl_file(input_path) attr_config = self.skew_config.attribute_skew join_config = self.skew_config.join_skew temporal_config = self.skew_config.temporal_skew - num_rows = len(rows) + num_rows = self._count_tbl_rows(input_path) partkey_col = _COLUMN_INDICES["lineitem"]["l_partkey"] suppkey_col = _COLUMN_INDICES["lineitem"]["l_suppkey"] shipmode_col = _COLUMN_INDICES["lineitem"]["l_shipmode"] returnflag_col = _COLUMN_INDICES["lineitem"]["l_returnflag"] shipdate_col = _COLUMN_INDICES["lineitem"]["l_shipdate"] + replacements: dict[int, tuple[np.ndarray, Sequence[str] | None]] = {} # Calculate max keys max_partkey = int(200_000 * self.scale_factor) @@ -643,66 +651,34 @@ def _transform_lineitem(self, input_path: Path, output_path: Path) -> None: # Apply part popularity skew (join skew) if join_config.part_popularity_skew > 0 and self.skew_config.enable_join_skew: skewed_partkeys = self._generate_skewed_values(num_rows, 1, max_partkey, join_config.part_popularity_skew) - for i, row in enumerate(rows): - row[partkey_col] = str(skewed_partkeys[i]) + replacements[partkey_col] = (skewed_partkeys, None) # Apply supplier volume skew (join skew) if join_config.supplier_volume_skew > 0 and self.skew_config.enable_join_skew: skewed_suppkeys = self._generate_skewed_values(num_rows, 1, max_suppkey, join_config.supplier_volume_skew) - for i, row in enumerate(rows): - row[suppkey_col] = str(skewed_suppkeys[i]) + replacements[suppkey_col] = (skewed_suppkeys, None) # Apply ship mode skew if attr_config.shipmode_skew > 0 and self.skew_config.enable_attribute_skew: shipmodes = ["REG AIR", "AIR", "RAIL", "SHIP", "TRUCK", "MAIL", "FOB"] skewed_indices = self._generate_skewed_values(num_rows, 0, len(shipmodes) - 1, attr_config.shipmode_skew) - for i, row in enumerate(rows): - row[shipmode_col] = shipmodes[skewed_indices[i]] + replacements[shipmode_col] = (skewed_indices, shipmodes) # Apply return flag skew (most items not returned) if attr_config.returnflag_skew > 0 and self.skew_config.enable_attribute_skew: # Return flags: N (not returned), R (returned), A (accepted) flags = ["N", "R", "A"] skewed_indices = self._generate_skewed_values(num_rows, 0, len(flags) - 1, attr_config.returnflag_skew) - for i, row in enumerate(rows): - row[returnflag_col] = flags[skewed_indices[i]] + replacements[returnflag_col] = (skewed_indices, flags) # Apply temporal skew to ship dates if temporal_config.ship_date_seasonality > 0 and self.skew_config.enable_temporal_skew: - self._apply_temporal_skew_to_dates(rows, shipdate_col, temporal_config.ship_date_seasonality) - - self._write_tbl_file(rows, output_path) - - def _apply_temporal_skew_to_dates(self, rows: list[list[str]], date_col: int, skew_factor: float) -> None: - """Apply temporal skew to date column. - - Concentrates dates toward more recent periods. - - Args: - rows: Data rows to modify - date_col: Index of date column - skew_factor: Intensity of temporal skew - """ - from datetime import datetime, timedelta - - # TPC-H date range: 1992-01-01 to 1998-12-31 - start_date = datetime(1992, 1, 1) - end_date = datetime(1998, 12, 31) - total_days = (end_date - start_date).days - - # Generate skewed day offsets - num_rows = len(rows) - dist = ExponentialDistribution(rate=0.5 + skew_factor * 4) - samples = dist.sample(num_rows, self.rng) - - # Concentrate toward end of range (recent dates) - # samples in [0,1] -> map to concentrate at high values - concentrated = 1 - samples # Flip to concentrate at 1 - day_offsets = (concentrated * total_days).astype(int) + replacements[shipdate_col] = ( + self._temporal_day_offsets(num_rows, temporal_config.ship_date_seasonality), + self._date_choices(), + ) - for i, row in enumerate(rows): - new_date = start_date + timedelta(days=int(day_offsets[i])) - row[date_col] = new_date.strftime("%Y-%m-%d") + self._stream_tbl_file(input_path, output_path, replacements) def get_skew_statistics(self) -> dict: """Get statistics about the applied skew. diff --git a/benchbox/core/tpchavoc/benchmark.py b/benchbox/core/tpchavoc/benchmark.py index 8dad8975b7..ec5903252a 100644 --- a/benchbox/core/tpchavoc/benchmark.py +++ b/benchbox/core/tpchavoc/benchmark.py @@ -18,9 +18,11 @@ from typing import Any, Union from benchbox.core.tpch.benchmark import TPCHBenchmark +from benchbox.core.tpchavoc.dialect_compat import rewrite_dialect_variant from benchbox.core.tpchavoc.queries import TPCHavocQueryManager from benchbox.core.tpchavoc.validation import ResultValidator, ValidationReport from benchbox.sql_compat.rules.execution_filter.clickhouse_tpchavoc import CLICKHOUSE_TPCHAVOC_SKIPS +from benchbox.sql_compat.rules.execution_filter.cloud_tpchavoc import CLOUD_TPCHAVOC_SKIPS from benchbox.sql_compat.rules.execution_filter.datafusion_tpchavoc import DATAFUSION_TPCHAVOC_SKIPS from benchbox.sql_compat.rules.execution_filter.lakesail_tpchavoc import LAKESAIL_TPCHAVOC_SKIPS from benchbox.sql_compat.rules.execution_filter.postgres_tpchavoc import POSTGRES_TPCHAVOC_SKIPS @@ -139,14 +141,24 @@ def get_platform_skip_queries(self, platform_name: str) -> list[str]: return list(CLICKHOUSE_TPCHAVOC_SKIPS) if platform in {"pg-duckdb", "pg-mooncake", "timescaledb"}: return list(POSTGRES_TPCHAVOC_SKIPS) + if platform in CLOUD_TPCHAVOC_SKIPS: + return list(CLOUD_TPCHAVOC_SKIPS[platform]) return [] + def get_queries(self, dialect: str | None = None, base_dialect: str | None = None) -> dict[str, str]: + """Return variants with target rewrites after the shared SQL translation.""" + queries = super().get_queries(dialect=dialect, base_dialect=base_dialect) + target = (dialect or "").lower() + return {query_id: rewrite_dialect_variant(query_id, query, target) for query_id, query in queries.items()} + def get_query( self, query_id, *, seed: int | None = None, scale_factor: float | None = None, + dialect: str | None = None, + base_dialect: str | None = None, **kwargs, ) -> str: """Get TPC-Havoc query by ID. @@ -167,12 +179,17 @@ def get_query( ValueError: If the query_id format is invalid TypeError: If parameters have wrong types """ - return self.query_manager.get_query( + query = self.query_manager.get_query( query_id, seed=seed, scale_factor=scale_factor or self.scale_factor, **kwargs, ) + if dialect is None: + return query + target = dialect.lower() + translated = self.translate_query_text(query, (base_dialect or "netezza").lower(), target) + return rewrite_dialect_variant(str(query_id), translated, target) def get_query_variant(self, query_id: int, variant_id: int, params: dict[str, Any] | None = None) -> str: """Get a specific TPC-Havoc query variant. diff --git a/benchbox/core/tpchavoc/cloud_compat.py b/benchbox/core/tpchavoc/cloud_compat.py new file mode 100644 index 0000000000..760c586ddc --- /dev/null +++ b/benchbox/core/tpchavoc/cloud_compat.py @@ -0,0 +1,188 @@ +"""Targeted SQL rewrites for TPC-Havoc cloud query variants.""" + +from __future__ import annotations + +import sqlglot +from sqlglot import exp + +_BIGQUERY_FROMLESS_IDS = frozenset({"7_v8", "12_v8", "13_v8", "18_v4"}) +_BIGQUERY_HAVING_COLUMNS = {"5_v4": "revenue", "11_v4": "value"} +_SNOWFLAKE_EMPTY_GROUP_IDS = frozenset({"6_v2", "14_v2"}) +_ARRAY_AGGREGATION_ID = "1_v7" +BIGQUERY_FILTER_IDS = frozenset( + { + "1_v6", + "2_v7", + "3_v7", + "4_v7", + "5_v7", + "6_v7", + "7_v7", + "8_v7", + "9_v7", + "10_v7", + "11_v7", + "12_v7", + "13_v7", + "14_v7", + "15_v7", + "16_v7", + "17_v7", + "18_v7", + } +) + + +def rewrite_cloud_variant(query_id: str, query: str, target_dialect: str) -> str: + """Preserve variant semantics where the target rejects translated syntax.""" + if query_id == _ARRAY_AGGREGATION_ID: + return _rewrite_array_aggregation(query, target_dialect) + if target_dialect == "databricks": + from benchbox.core.tpchavoc.spark_query_transformer import SparkTPCHavocQueryTransformer + + if query_id in SparkTPCHavocQueryTransformer.known_variant_ids(): + return SparkTPCHavocQueryTransformer().transform(query, query_id) + return query + if target_dialect == "snowflake" and query_id in _SNOWFLAKE_EMPTY_GROUP_IDS: + return query.replace(" GROUP BY ()", "", 1) + if target_dialect != "bigquery": + return query + + has_filter = " FILTER(" in query + if not (has_filter or query_id in _BIGQUERY_FROMLESS_IDS or query_id in _BIGQUERY_HAVING_COLUMNS): + if query_id == "3_v9": + return query.replace("ORDER BY `l_orderkey` NULLS LAST", "ORDER BY `l_orderkey`") + return query + + tree = sqlglot.parse_one(query, read="bigquery") + if has_filter: + tree = tree.transform(_rewrite_bigquery_filter) + if query_id in _BIGQUERY_FROMLESS_IDS: + one_row_source = sqlglot.parse_one("SELECT 1 FROM UNNEST([1])", read="bigquery").args["from_"] + for select in tree.find_all(exp.Select): + if select.args.get("where") is not None and select.args.get("from_") is None: + select.set("from_", one_row_source.copy()) + if column_name := _BIGQUERY_HAVING_COLUMNS.get(query_id): + for having in tree.find_all(exp.Having): + for column in having.find_all(exp.Column): + if column.name == column_name and not column.table: + column.set("table", exp.to_identifier("combined", quoted=True)) + return tree.sql(dialect="bigquery") + + +def _rewrite_array_aggregation(query: str, target_dialect: str) -> str: + """Render q1_v7's collected arrays with functions native to each cloud engine. + + The Snowflake leg is intentionally unimplemented: Snowflake SQL has no + lambda-based array REDUCE, so emitting REDUCE would fail live compilation + (a sqlglot parse check cannot catch that engine-side limit). Snowflake + input falls through unchanged and stays skipped until a native reduction + is proven. + """ + if target_dialect not in {"bigquery", "databricks"}: + return query + + tree = sqlglot.parse_one(query, read=target_dialect) + tree = tree.transform( + lambda node: _ordered_array(node.expressions[0], target_dialect) if isinstance(node, exp.List) else node + ) + + projection_sql = _array_projection_sql(target_dialect) + projections = [ + sqlglot.parse_one(f"SELECT {projection}", read=target_dialect).expressions[0] for projection in projection_sql + ] + tree.set("expressions", [*tree.expressions[:2], *projections]) + return tree.sql(dialect=target_dialect) + + +def _ordered_array(value: exp.Expression, target_dialect: str) -> exp.Expression: + """Collect values in lineitem key order so separately reduced arrays stay aligned.""" + rendered = value.sql(dialect=target_dialect) + if target_dialect == "databricks": + expression = ( + "TRANSFORM(SORT_ARRAY(COLLECT_LIST(NAMED_STRUCT(" + f"'orderkey', l_orderkey, 'linenumber', l_linenumber, 'value', {rendered}))), row -> row.value)" + ) + else: + expression = f"ARRAY_AGG({rendered} ORDER BY l_orderkey, l_linenumber)" + return sqlglot.parse_one(f"SELECT {expression}", read=target_dialect).expressions[0] + + +def _array_projection_sql(target_dialect: str) -> tuple[str, ...]: + if target_dialect == "snowflake": + raise NotImplementedError("No native Snowflake array reduction is proven; 1_v7 stays skipped on Snowflake.") + elif target_dialect == "databricks": + zero = "CAST(0 AS DOUBLE)" + + def reduce_array(expression: str) -> str: + return f"AGGREGATE({expression}, {zero}, (acc, x) -> acc + x)" + + sum_qty = reduce_array("TRANSFORM(quantities, x -> CAST(x AS DOUBLE))") + sum_price = reduce_array("TRANSFORM(prices, x -> CAST(x AS DOUBLE))") + sum_disc_price = reduce_array( + "ZIP_WITH(prices, discounts, (price, discount) -> CAST(price AS DOUBLE) * (1 - CAST(discount AS DOUBLE)))" + ) + sum_charge = reduce_array( + "ZIP_WITH(ZIP_WITH(prices, discounts, " + "(price, discount) -> CAST(price AS DOUBLE) * (1 - CAST(discount AS DOUBLE))), taxes, " + "(discounted, tax) -> discounted * (1 + CAST(tax AS DOUBLE)))" + ) + size = "SIZE" + else: + sum_qty = "(SELECT SUM(value) FROM UNNEST(quantities) AS value)" + sum_price = "(SELECT SUM(value) FROM UNNEST(prices) AS value)" + sum_disc_price = ( + "(SELECT SUM(price * (1 - discounts[SAFE_OFFSET(position)])) " + "FROM UNNEST(prices) AS price WITH OFFSET AS position)" + ) + sum_charge = ( + "(SELECT SUM(price * (1 - discounts[SAFE_OFFSET(position)]) * " + "(1 + taxes[SAFE_OFFSET(position)])) " + "FROM UNNEST(prices) AS price WITH OFFSET AS position)" + ) + size = "ARRAY_LENGTH" + + if target_dialect == "bigquery": + avg_qty = "(SELECT AVG(value) FROM UNNEST(quantities) AS value)" + avg_price = "(SELECT AVG(value) FROM UNNEST(prices) AS value)" + avg_disc = "(SELECT AVG(value) FROM UNNEST(discounts) AS value)" + else: + avg_qty = f"{sum_qty} / NULLIF({size}(quantities), 0)" + avg_price = f"{sum_price} / NULLIF({size}(prices), 0)" + avg_disc_sum = ( + reduce_array("TRANSFORM(discounts, x -> CAST(x AS DOUBLE))") + if target_dialect == "databricks" + else reduce_array("discounts") + ) + avg_disc = f"{avg_disc_sum} / NULLIF({size}(discounts), 0)" + + return ( + f"{sum_qty} AS sum_qty", + f"{sum_price} AS sum_base_price", + f"{sum_disc_price} AS sum_disc_price", + f"{sum_charge} AS sum_charge", + f"{avg_qty} AS avg_qty", + f"{avg_price} AS avg_price", + f"{avg_disc} AS avg_disc", + f"{size}(quantities) AS count_order", + ) + + +def _rewrite_bigquery_filter(node: exp.Expression) -> exp.Expression: + if not isinstance(node, exp.Filter): + return node + aggregate = node.this.copy() + condition = node.expression.this.copy() + value = aggregate.this + if isinstance(aggregate, exp.Count) and isinstance(value, exp.Star): + return exp.CountIf(this=condition) + if isinstance(value, exp.Distinct): + distinct = value.copy() + distinct.set( + "expressions", + [exp.If(this=condition, true=expression.copy(), false=exp.Null()) for expression in distinct.expressions], + ) + aggregate.set("this", distinct) + else: + aggregate.set("this", exp.If(this=condition, true=value.copy(), false=exp.Null())) + return aggregate diff --git a/benchbox/core/tpchavoc/dataframe_equivalence.py b/benchbox/core/tpchavoc/dataframe_equivalence.py index f16ceafc2a..f5107899b6 100644 --- a/benchbox/core/tpchavoc/dataframe_equivalence.py +++ b/benchbox/core/tpchavoc/dataframe_equivalence.py @@ -152,8 +152,7 @@ def find_dataframe_divergences( Reuses :meth:`TPCHavocBenchmark.validate_variant_equivalence` and the shared :func:`find_surface_divergences` loop. """ - from benchbox.core.tpch import dataframe_queries as tpch_dataframe_queries - from benchbox.core.tpch.dataframe_queries import set_parameter_overrides, set_scale_factor + from benchbox.core.tpch.dataframe_queries import seeded_parameter_overrides ids = query_ids if query_ids is not None else benchmark.get_implemented_queries() registry = benchmark.get_dataframe_queries() @@ -186,11 +185,7 @@ def check( # unseeded production run path, so no gate-local fraction override is needed: # clear any ambient parameter overrides and declare the gate's scale through # the same product seam the run path uses. Restore both afterwards. - previous_overrides = tpch_dataframe_queries._parameter_overrides - previous_scale_factor = tpch_dataframe_queries._scale_factor - set_parameter_overrides(None) - set_scale_factor(benchmark.scale_factor) - try: + with seeded_parameter_overrides(None, benchmark.scale_factor, 0): return find_surface_divergences( ids, reference_rows=reference_rows, @@ -198,9 +193,6 @@ def check( validation_error=ValidationError, reference_failure_cell="v0:canonical", ) - finally: - set_parameter_overrides(previous_overrides) - set_scale_factor(previous_scale_factor) def main() -> int: diff --git a/benchbox/core/tpchavoc/dataframe_queries/loader.py b/benchbox/core/tpchavoc/dataframe_queries/loader.py index d587dd1e8b..65f91dc129 100644 --- a/benchbox/core/tpchavoc/dataframe_queries/loader.py +++ b/benchbox/core/tpchavoc/dataframe_queries/loader.py @@ -41,8 +41,18 @@ def build_variants( impl_pairs: Sequence[tuple[VariantImpl, VariantImpl]], descriptions: Sequence[str], categories: Sequence[QueryCategory], + *, + expected_row_count: int | None = None, + scale_factor_dependent: bool = False, + timeout_seconds: float | None = None, + skip_platforms: Sequence[str] | None = None, ) -> list[DataFrameQuery]: - """Build TPC-Havoc DataFrame variants from already-resolved impl pairs.""" + """Build TPC-Havoc DataFrame variants from already-resolved impl pairs. + + Query metadata (expected row counts, timeouts, skipped platforms) is copied + from the canonical query by the caller so rebuilt variants keep the same + validation surface as the replay builder they replace. + """ return [ DataFrameQuery( query_id=f"Q{query_number}v{variant}", @@ -51,6 +61,10 @@ def build_variants( categories=list(categories), expression_impl=expr_impl, pandas_impl=pandas_impl, + expected_row_count=expected_row_count, + scale_factor_dependent=scale_factor_dependent, + timeout_seconds=timeout_seconds, + skip_platforms=list(skip_platforms) if skip_platforms else [], ) for variant, (expr_impl, pandas_impl) in enumerate(impl_pairs, start=1) ] diff --git a/benchbox/core/tpchavoc/dataframe_queries/q16.py b/benchbox/core/tpchavoc/dataframe_queries/q16.py index 501ee8ccc2..8d4b22733d 100644 --- a/benchbox/core/tpchavoc/dataframe_queries/q16.py +++ b/benchbox/core/tpchavoc/dataframe_queries/q16.py @@ -1,13 +1,311 @@ -"""TPC-Havoc DataFrame variants for Q16.""" +"""TPC-Havoc DataFrame variants for Q16. + +Q16 filters parts, joins partsupp, excludes complaint suppliers via an +anti-join, then groups by brand/type/size with a distinct-supplier count. +The variants keep the canonical output while varying the anti-join shape, +filter pushdown, prefiltering, column pruning, and aggregation structure. +""" from __future__ import annotations -from benchbox.core.dataframe.query import DataFrameQuery -from benchbox.core.tpchavoc.dataframe_queries._delegating_variants import build_result_replay_variants +from collections.abc import Callable +from typing import Any + +from benchbox.core.dataframe.compat import _to_list +from benchbox.core.dataframe.context import DataFrameContext +from benchbox.core.tpch.dataframe_queries import ( + get_query as get_tpch_query, + get_tpch_parameters, + q16_expression_impl as _q16_expr_base, + q16_pandas_impl as _q16_pandas_base, +) +from benchbox.core.tpchavoc.dataframe_queries.loader import build_variants + +VariantImpl = Callable[[DataFrameContext], Any] + +_DESCRIPTIONS = [ + "Baseline: direct delegation to TPC-H Q16 implementation", + "Anti-join alternatives: complaint keys via distinct before the anti-join", + "Semi-join pruning: partsupp semi-filtered to filtered part keys before the join", + "Part-side prefilter: brand/type/size filter on part before the join", + "Column prune: select only needed columns before the join", + "Per-predicate branches: one filtered branch per size, concatenated after", + "Late filtering: part predicates applied after the partsupp join instead of before", + "Complaint-first ordering: anti-join before the part filter", + "Two-stage aggregation: distinct group-supplier pairs counted before the final sort", + "Group-first ordering: group keys projected before aggregation", +] + +_GROUP_KEYS = ["p_brand", "p_type", "p_size"] +_SORT_KEYS = ["supplier_cnt", "p_brand", "p_type", "p_size"] +_SORT_DESC = [True, False, False, False] + + +def _q16_expr_parts(col: Any, lit: Any, params: dict[str, Any]) -> Any: + return ( + (col("p_brand") != lit(params["brand"])) + & ~col("p_type").str.starts_with(params["type_prefix"]) + & col("p_size").is_in(params["sizes"]) + ) + + +def _q16_expr_complaint_keys(ctx: DataFrameContext, col: Any) -> Any: + return ctx.get_table("supplier").filter(col("s_comment").str.contains("Customer.*Complaints")).select("s_suppkey") + + +def _q16_expr_aggregate(filtered: Any, col: Any) -> Any: + return ( + filtered.group_by(*_GROUP_KEYS) + .agg(col("ps_suppkey").n_unique().alias("supplier_cnt")) + .sort(_SORT_KEYS, descending=_SORT_DESC) + ) + + +def _make_q16_expression_impl(variant: int) -> VariantImpl: + def impl(ctx: DataFrameContext) -> Any: + if variant == 1: + return _q16_expr_base(ctx) + + col = ctx.col + lit = ctx.lit + params = get_tpch_parameters(16) + partsupp = ctx.get_table("partsupp") + part = ctx.get_table("part") + + if variant == 2: + complaint_keys = _q16_expr_complaint_keys(ctx, col).distinct() + joined = part.filter(_q16_expr_parts(col, lit, params)).join( + partsupp, left_on="p_partkey", right_on="ps_partkey" + ) + return _q16_expr_aggregate( + joined.join(complaint_keys, left_on="ps_suppkey", right_on="s_suppkey", how="anti"), col + ) + + if variant == 3: + # Semi-join pruning: partsupp is first reduced to the rows whose + # part key survives the part filter, via a distinct-key semi-join, + # before the main part/partsupp join runs. + filtered = part.filter(_q16_expr_parts(col, lit, params)) + part_keys = filtered.select("p_partkey").distinct() + pruned = partsupp.join(part_keys, left_on="ps_partkey", right_on="p_partkey", how="semi") + joined = filtered.join(pruned, left_on="p_partkey", right_on="ps_partkey") + return _q16_expr_aggregate( + joined.join(_q16_expr_complaint_keys(ctx, col), left_on="ps_suppkey", right_on="s_suppkey", how="anti"), + col, + ) + + if variant == 4: + part_branch = part.filter( + (col("p_brand") != lit(params["brand"])) & ~col("p_type").str.starts_with(params["type_prefix"]) + ).filter(col("p_size").is_in(params["sizes"])) + joined = part_branch.join(partsupp, left_on="p_partkey", right_on="ps_partkey") + return _q16_expr_aggregate( + joined.join(_q16_expr_complaint_keys(ctx, col), left_on="ps_suppkey", right_on="s_suppkey", how="anti"), + col, + ) + + if variant == 5: + part_cols = part.select("p_partkey", "p_brand", "p_type", "p_size").filter( + _q16_expr_parts(col, lit, params) + ) + joined = part_cols.join(partsupp, left_on="p_partkey", right_on="ps_partkey") + return _q16_expr_aggregate( + joined.join(_q16_expr_complaint_keys(ctx, col), left_on="ps_suppkey", right_on="s_suppkey", how="anti"), + col, + ) + + if variant == 6: + branches = [ + part.filter( + (col("p_brand") != lit(params["brand"])) + & ~col("p_type").str.starts_with(params["type_prefix"]) + & (col("p_size") == lit(size)) + ) + for size in params["sizes"] + ] + joined = ctx.concat( + [branch.join(partsupp, left_on="p_partkey", right_on="ps_partkey") for branch in branches] + ) + return _q16_expr_aggregate( + joined.join(_q16_expr_complaint_keys(ctx, col), left_on="ps_suppkey", right_on="s_suppkey", how="anti"), + col, + ) + + if variant == 7: + # Late filtering: part and partsupp join unfiltered, and the + # brand/type/size predicates run on the joined rows instead of on + # part before the join, swapping the canonical filter order. + joined = part.join(partsupp, left_on="p_partkey", right_on="ps_partkey").filter( + _q16_expr_parts(col, lit, params) + ) + return _q16_expr_aggregate( + joined.join(_q16_expr_complaint_keys(ctx, col), left_on="ps_suppkey", right_on="s_suppkey", how="anti"), + col, + ) + + if variant == 8: + complaint_keys = _q16_expr_complaint_keys(ctx, col) + partsupp_clean = partsupp.join(complaint_keys, left_on="ps_suppkey", right_on="s_suppkey", how="anti") + joined = part.filter(_q16_expr_parts(col, lit, params)).join( + partsupp_clean, left_on="p_partkey", right_on="ps_partkey" + ) + return _q16_expr_aggregate(joined, col) + + if variant == 9: + joined = part.filter(_q16_expr_parts(col, lit, params)).join( + partsupp, left_on="p_partkey", right_on="ps_partkey" + ) + clean = joined.join( + _q16_expr_complaint_keys(ctx, col), left_on="ps_suppkey", right_on="s_suppkey", how="anti" + ) + pairs = clean.select(*_GROUP_KEYS, "ps_suppkey").distinct() + return ( + pairs.group_by(*_GROUP_KEYS) + .agg(col("ps_suppkey").count().alias("supplier_cnt")) + .sort(_SORT_KEYS, descending=_SORT_DESC) + ) + + projected = ( + part.filter(_q16_expr_parts(col, lit, params)) + .join(partsupp, left_on="p_partkey", right_on="ps_partkey") + .join(_q16_expr_complaint_keys(ctx, col), left_on="ps_suppkey", right_on="s_suppkey", how="anti") + .select(*_GROUP_KEYS, "ps_suppkey") + ) + return _q16_expr_aggregate(projected, col) + + impl.__name__ = f"q16_v{variant}_expression_impl" + impl.__qualname__ = impl.__name__ + return impl + + +def _q16_pandas_complaint_keys(supplier: Any) -> Any: + return supplier[supplier["s_comment"].str.contains("Customer.*Complaints", regex=True, na=False)][ + "s_suppkey" + ].drop_duplicates() + + +def _q16_pandas_parts_mask(part: Any, params: dict[str, Any]) -> Any: + return ( + (part["p_brand"] != params["brand"]) + & (~part["p_type"].str.startswith(params["type_prefix"])) + & (part["p_size"].isin(params["sizes"])) + ) + + +def _q16_pandas_aggregate(joined: Any) -> Any: + return ( + joined.groupby(["p_brand", "p_type", "p_size"], as_index=False) + .agg(supplier_cnt=("ps_suppkey", "nunique")) + .sort_values(["supplier_cnt", "p_brand", "p_type", "p_size"], ascending=[False, True, True, True]) + ) + + +def _make_q16_pandas_impl(variant: int) -> VariantImpl: + def impl(ctx: DataFrameContext) -> Any: + if variant == 1: + return _q16_pandas_base(ctx) + + partsupp = ctx.get_table("partsupp") + part = ctx.get_table("part") + supplier = ctx.get_table("supplier") + params = get_tpch_parameters(16) + + if variant == 2: + complaint_keys = _q16_pandas_complaint_keys(supplier).drop_duplicates() + joined = part[_q16_pandas_parts_mask(part, params)].merge( + partsupp, left_on="p_partkey", right_on="ps_partkey" + ) + return _q16_pandas_aggregate(joined[~joined["ps_suppkey"].isin(_to_list(complaint_keys))]) + + if variant == 3: + # Semi-join pruning mirror: partsupp reduced to surviving part keys first. + filtered = part[_q16_pandas_parts_mask(part, params)] + part_keys = _to_list(filtered[["p_partkey"]].drop_duplicates()["p_partkey"]) + pruned = partsupp[partsupp["ps_partkey"].isin(part_keys)] + joined = filtered.merge(pruned, left_on="p_partkey", right_on="ps_partkey") + complaint_keys = _q16_pandas_complaint_keys(supplier) + return _q16_pandas_aggregate(joined[~joined["ps_suppkey"].isin(_to_list(complaint_keys))]) + + if variant == 4: + part_branch = part[ + (part["p_brand"] != params["brand"]) & (~part["p_type"].str.startswith(params["type_prefix"])) + ] + part_branch = part_branch[part_branch["p_size"].isin(params["sizes"])] + joined = part_branch.merge(partsupp, left_on="p_partkey", right_on="ps_partkey") + complaint_keys = _q16_pandas_complaint_keys(supplier) + return _q16_pandas_aggregate(joined[~joined["ps_suppkey"].isin(_to_list(complaint_keys))]) + + if variant == 5: + part_cols = part[["p_partkey", "p_brand", "p_type", "p_size"]] + part_cols = part_cols[_q16_pandas_parts_mask(part_cols, params)] + joined = part_cols.merge(partsupp, left_on="p_partkey", right_on="ps_partkey") + complaint_keys = _q16_pandas_complaint_keys(supplier) + return _q16_pandas_aggregate(joined[~joined["ps_suppkey"].isin(_to_list(complaint_keys))]) + + if variant == 6: + branches = [ + part[ + (part["p_brand"] != params["brand"]) + & (~part["p_type"].str.startswith(params["type_prefix"])) + & (part["p_size"] == size) + ] + for size in params["sizes"] + ] + joined = ctx.concat( + [branch.merge(partsupp, left_on="p_partkey", right_on="ps_partkey") for branch in branches] + ) + complaint_keys = _q16_pandas_complaint_keys(supplier) + return _q16_pandas_aggregate(joined[~joined["ps_suppkey"].isin(_to_list(complaint_keys))]) + + if variant == 7: + # Late filtering mirror: join first, filter the joined rows after. + joined = part.merge(partsupp, left_on="p_partkey", right_on="ps_partkey") + filtered = joined[_q16_pandas_parts_mask(joined, params)] + complaint_keys = _q16_pandas_complaint_keys(supplier) + return _q16_pandas_aggregate(filtered[~filtered["ps_suppkey"].isin(_to_list(complaint_keys))]) + + if variant == 8: + complaint_keys = _q16_pandas_complaint_keys(supplier) + partsupp_clean = partsupp[~partsupp["ps_suppkey"].isin(_to_list(complaint_keys))] + joined = part[_q16_pandas_parts_mask(part, params)].merge( + partsupp_clean, left_on="p_partkey", right_on="ps_partkey" + ) + return _q16_pandas_aggregate(joined) + + if variant == 9: + joined = part[_q16_pandas_parts_mask(part, params)].merge( + partsupp, left_on="p_partkey", right_on="ps_partkey" + ) + complaint_keys = _q16_pandas_complaint_keys(supplier) + clean = joined[~joined["ps_suppkey"].isin(_to_list(complaint_keys))] + pairs = clean[["p_brand", "p_type", "p_size", "ps_suppkey"]].drop_duplicates() + return ( + pairs.groupby(["p_brand", "p_type", "p_size"], as_index=False) + .agg(supplier_cnt=("ps_suppkey", "count")) + .sort_values(["supplier_cnt", "p_brand", "p_type", "p_size"], ascending=[False, True, True, True]) + ) + + joined = part[_q16_pandas_parts_mask(part, params)].merge(partsupp, left_on="p_partkey", right_on="ps_partkey") + complaint_keys = _q16_pandas_complaint_keys(supplier) + projected = joined[~joined["ps_suppkey"].isin(_to_list(complaint_keys))][ + ["p_brand", "p_type", "p_size", "ps_suppkey"] + ] + return _q16_pandas_aggregate(projected) + + impl.__name__ = f"q16_v{variant}_pandas_impl" + impl.__qualname__ = impl.__name__ + return impl + + +_Q16_BASE = get_tpch_query("Q16") -Q16_VARIANTS: list[DataFrameQuery] = build_result_replay_variants( +Q16_VARIANTS = build_variants( 16, - result_columns=["p_brand", "p_type", "p_size", "supplier_cnt"], - sort_columns=["supplier_cnt", "p_brand", "p_type", "p_size"], - descending=[True, False, False, False], + [(_make_q16_expression_impl(v), _make_q16_pandas_impl(v)) for v in range(1, 11)], + _DESCRIPTIONS, + _Q16_BASE.categories, + expected_row_count=_Q16_BASE.expected_row_count, + scale_factor_dependent=_Q16_BASE.scale_factor_dependent, + timeout_seconds=_Q16_BASE.timeout_seconds, + skip_platforms=_Q16_BASE.skip_platforms, ) diff --git a/benchbox/core/tpchavoc/dataframe_queries/q17.py b/benchbox/core/tpchavoc/dataframe_queries/q17.py index 0a2250c6bf..9ecd163273 100644 --- a/benchbox/core/tpchavoc/dataframe_queries/q17.py +++ b/benchbox/core/tpchavoc/dataframe_queries/q17.py @@ -1,8 +1,322 @@ -"""TPC-Havoc DataFrame variants for Q17.""" +"""TPC-Havoc DataFrame variants for Q17. + +Q17 joins filtered parts to lineitem, compares each line's quantity to a +per-part average (0.2x mean), and sums revenue. The variants keep the +canonical output while varying the average-quantity materialization, the +semi-join ordering, prefiltering, column pruning, and revenue formulation. +""" from __future__ import annotations -from benchbox.core.dataframe.query import DataFrameQuery -from benchbox.core.tpchavoc.dataframe_queries._delegating_variants import build_result_replay_variants +from collections.abc import Callable +from typing import Any + +from benchbox.core.dataframe.context import DataFrameContext +from benchbox.core.tpch.dataframe_queries import ( + get_query as get_tpch_query, + get_tpch_parameters, + q17_expression_impl as _q17_expr_base, + q17_pandas_impl as _q17_pandas_base, +) +from benchbox.core.tpchavoc.dataframe_queries.loader import build_variants + +VariantImpl = Callable[[DataFrameContext], Any] + +_DESCRIPTIONS = [ + "Baseline: direct delegation to TPC-H Q17 implementation", + "Average materialization: precompute per-part average table before the join", + "Late average join: lineitem joined before the per-part average table", + "Part-side prefilter: brand/container predicates applied before the join", + "Column prune: select only needed columns before the join", + "Size-band branches: part-size bands joined and filtered independently, summed after", + "Anti-null reformulation: left-join the average table with an is-null exclusion", + "Commuted formula: per-row revenue divided by 7.0 before the final sum", + "Revenue materialization: with_columns revenue before the final sum", + "Threshold-first ordering: threshold survivors selected before the part join", +] + + +def _q17_expr_avg(col: Any, lit: Any) -> Any: + return (col("l_quantity").mean() * lit(0.2)).alias("avg_qty") + + +# Q17v6 splits the filtered parts into disjoint part-size bands before the +# lineitem join so each branch performs its own join work. Sizes are drawn +# from the TPC-H domain [1, 50]; the midpoint split keeps both branches +# non-empty, and the per-part threshold filter after the concat preserves +# semantics for any partition of the parts. +_Q17_SIZE_SPLIT = 25 + + +def _make_q17_expression_impl(variant: int) -> VariantImpl: + def impl(ctx: DataFrameContext) -> Any: + if variant == 1: + return _q17_expr_base(ctx) + + col = ctx.col + lit = ctx.lit + params = get_tpch_parameters(17) + lineitem = ctx.get_table("lineitem") + part = ctx.get_table("part") + part_filter = (col("p_brand") == lit(params["brand"])) & (col("p_container") == lit(params["container"])) + + if variant == 2: + avg_table = lineitem.group_by("l_partkey").agg(_q17_expr_avg(col, lit)) + joined = ( + part.filter(part_filter) + .join(lineitem, left_on="p_partkey", right_on="l_partkey") + .join(avg_table, left_on="p_partkey", right_on="l_partkey") + ) + return joined.filter(col("l_quantity") < col("avg_qty")).select( + (col("l_extendedprice").sum() / lit(7.0)).alias("avg_yearly") + ) + + if variant == 3: + # Late average join: the big part/lineitem join runs first and + # the per-part average is derived FROM the joined frame (rather + # than precomputed from lineitem), so the average leg reads the + # joined rows instead of the base table. + joined = part.filter(part_filter).join(lineitem, left_on="p_partkey", right_on="l_partkey") + avg_table = joined.group_by("p_partkey").agg(_q17_expr_avg(col, lit)) + joined = joined.join(avg_table, left_on="p_partkey", right_on="p_partkey") + return joined.filter(col("l_quantity") < col("avg_qty")).select( + (col("l_extendedprice").sum() / lit(7.0)).alias("avg_yearly") + ) + + if variant == 4: + part_branch = part.filter(col("p_brand") == lit(params["brand"])).filter( + col("p_container") == lit(params["container"]) + ) + avg_table = lineitem.group_by("l_partkey").agg(_q17_expr_avg(col, lit)) + joined = part_branch.join(lineitem, left_on="p_partkey", right_on="l_partkey").join( + avg_table, left_on="p_partkey", right_on="l_partkey" + ) + return joined.filter(col("l_quantity") < col("avg_qty")).select( + (col("l_extendedprice").sum() / lit(7.0)).alias("avg_yearly") + ) + + if variant == 5: + lineitem_cols = lineitem.select("l_partkey", "l_quantity", "l_extendedprice") + part_cols = part.select("p_partkey", "p_brand", "p_container").filter(part_filter) + avg_table = lineitem.group_by("l_partkey").agg(_q17_expr_avg(col, lit)) + joined = part_cols.join(lineitem_cols, left_on="p_partkey", right_on="l_partkey").join( + avg_table, left_on="p_partkey", right_on="l_partkey" + ) + return joined.filter(col("l_quantity") < col("avg_qty")).select( + (col("l_extendedprice").sum() / lit(7.0)).alias("avg_yearly") + ) + + if variant == 6: + # Size-band branches: the filtered parts are partitioned by size + # and each band joins lineitem and the average table on its own; + # the per-part threshold filter after the concat keeps semantics. + filtered_parts = part.filter(part_filter) + avg_table = lineitem.group_by("l_partkey").agg(_q17_expr_avg(col, lit)) + + def _branch(band: Any) -> Any: + return band.join(lineitem, left_on="p_partkey", right_on="l_partkey").join( + avg_table, left_on="p_partkey", right_on="l_partkey" + ) + + small_band = filtered_parts.filter(col("p_size") <= lit(_Q17_SIZE_SPLIT)) + large_band = filtered_parts.filter(col("p_size") > lit(_Q17_SIZE_SPLIT)) + return ( + ctx.concat([_branch(small_band), _branch(large_band)]) + .filter(col("l_quantity") < col("avg_qty")) + .select((col("l_extendedprice").sum() / lit(7.0)).alias("avg_yearly")) + ) + + if variant == 7: + # Anti-null reformulation: the average table is left-joined and + # rows missing an average are excluded via is-null instead of the + # canonical inner join (every lineitem part key has an average, so + # the exclusion removes nothing but changes the join operator). + avg_table = lineitem.group_by("l_partkey").agg(_q17_expr_avg(col, lit)) + joined = ( + part.filter(part_filter) + .join(lineitem, left_on="p_partkey", right_on="l_partkey") + .join(avg_table, left_on="p_partkey", right_on="l_partkey", how="left") + .filter(col("avg_qty").is_not_null()) + ) + return joined.filter(col("l_quantity") < col("avg_qty")).select( + (col("l_extendedprice").sum() / lit(7.0)).alias("avg_yearly") + ) + + if variant == 8: + avg_table = lineitem.group_by("l_partkey").agg(_q17_expr_avg(col, lit)) + joined = ( + part.filter(part_filter) + .join(lineitem, left_on="p_partkey", right_on="l_partkey") + .join(avg_table, left_on="p_partkey", right_on="l_partkey") + ) + return joined.filter(col("l_quantity") < col("avg_qty")).select( + ((col("l_extendedprice") / lit(7.0)).sum()).alias("avg_yearly") + ) + + if variant == 9: + avg_table = lineitem.group_by("l_partkey").agg(_q17_expr_avg(col, lit)) + joined = ( + part.filter(part_filter) + .join(lineitem, left_on="p_partkey", right_on="l_partkey") + .join(avg_table, left_on="p_partkey", right_on="l_partkey") + ) + return ( + joined.filter(col("l_quantity") < col("avg_qty")) + .with_columns((col("l_extendedprice") / lit(7.0)).alias("revenue")) + .select(col("revenue").sum().alias("avg_yearly")) + ) + + # Variant 10 (threshold-first): join lineitem to the average table and + # apply the quantity threshold BEFORE the part join, so the part join + # processes only threshold survivors instead of the full lineitem. + avg_table = lineitem.group_by("l_partkey").agg(_q17_expr_avg(col, lit)) + survivors = lineitem.join(avg_table, left_on="l_partkey", right_on="l_partkey").filter( + col("avg_qty") > col("l_quantity") + ) + joined = part.filter(part_filter).join(survivors, left_on="p_partkey", right_on="l_partkey") + return joined.select((col("l_extendedprice").sum() / lit(7.0)).alias("avg_yearly")) + + impl.__name__ = f"q17_v{variant}_expression_impl" + impl.__qualname__ = impl.__name__ + return impl + + +def _q17_pandas_avg(lineitem: Any) -> Any: + avg_qty = lineitem.groupby("l_partkey", as_index=False).agg(avg_qty=("l_quantity", "mean")) + avg_qty["avg_qty"] = avg_qty["avg_qty"] * 0.2 + return avg_qty + + +def _q17_pandas_sum(filtered: Any) -> Any: + import pandas as pd + + avg_yearly = filtered["l_extendedprice"].sum() / 7.0 + value = avg_yearly.compute() if hasattr(avg_yearly, "compute") else avg_yearly + return pd.DataFrame({"avg_yearly": [value]}) + + +def _make_q17_pandas_impl(variant: int) -> VariantImpl: + def impl(ctx: DataFrameContext) -> Any: + if variant == 1: + return _q17_pandas_base(ctx) + + lineitem = ctx.get_table("lineitem") + part = ctx.get_table("part") + params = get_tpch_parameters(17) + mask = (part["p_brand"] == params["brand"]) & (part["p_container"] == params["container"]) + + if variant == 2: + avg_table = _q17_pandas_avg(lineitem) + joined = ( + part[mask].merge(lineitem, left_on="p_partkey", right_on="l_partkey").merge(avg_table, on="l_partkey") + ) + return _q17_pandas_sum(joined[joined["l_quantity"] < joined["avg_qty"]]) + + if variant == 3: + # Late average join mirror: the average is derived from the + # merged part/lineitem frame instead of precomputed lineitem. + merged = part[mask].merge(lineitem, left_on="p_partkey", right_on="l_partkey") + avg_table = merged.groupby("p_partkey", as_index=False).agg(avg_qty=("l_quantity", "mean")) + avg_table["avg_qty"] = avg_table["avg_qty"] * 0.2 + joined = merged.merge(avg_table, left_on="p_partkey", right_on="p_partkey") + return _q17_pandas_sum(joined[joined["l_quantity"] < joined["avg_qty"]]) + + if variant == 4: + part_branch = part[part["p_brand"] == params["brand"]] + part_branch = part_branch[part_branch["p_container"] == params["container"]] + avg_table = _q17_pandas_avg(lineitem) + joined = part_branch.merge(lineitem, left_on="p_partkey", right_on="l_partkey").merge( + avg_table, on="l_partkey" + ) + return _q17_pandas_sum(joined[joined["l_quantity"] < joined["avg_qty"]]) + + if variant == 5: + lineitem_cols = lineitem[["l_partkey", "l_quantity", "l_extendedprice"]] + part_cols = part[mask][["p_partkey", "p_brand", "p_container"]] + avg_table = _q17_pandas_avg(lineitem) + joined = part_cols.merge(lineitem_cols, left_on="p_partkey", right_on="l_partkey").merge( + avg_table, on="l_partkey" + ) + return _q17_pandas_sum(joined[joined["l_quantity"] < joined["avg_qty"]]) + + if variant == 6: + # Size-band branches mirror: filtered parts split by size, each + # band merged with lineitem and the average table independently. + avg_table = _q17_pandas_avg(lineitem) + filtered_parts = part[mask] + + def _branch(band: Any) -> Any: + return band.merge(lineitem, left_on="p_partkey", right_on="l_partkey").merge( + avg_table, left_on="p_partkey", right_on="l_partkey" + ) + + small_band = filtered_parts[filtered_parts["p_size"] <= _Q17_SIZE_SPLIT] + large_band = filtered_parts[filtered_parts["p_size"] > _Q17_SIZE_SPLIT] + merged = ctx.concat([_branch(small_band), _branch(large_band)]) + return _q17_pandas_sum(merged[merged["l_quantity"] < merged["avg_qty"]]) + + if variant == 7: + # Anti-null reformulation mirror: left merge plus notna filter. + avg_table = _q17_pandas_avg(lineitem) + joined = ( + part[mask] + .merge(lineitem, left_on="p_partkey", right_on="l_partkey") + .merge(avg_table, left_on="p_partkey", right_on="l_partkey", how="left") + ) + joined = joined[joined["avg_qty"].notna()] + return _q17_pandas_sum(joined[joined["l_quantity"] < joined["avg_qty"]]) + + if variant == 8: + # Commuted formula: divide each row's extended price by 7.0 first, + # then sum, instead of summing first and dividing once. + import pandas as pd + + avg_table = _q17_pandas_avg(lineitem) + joined = ( + part[mask] + .merge(lineitem, left_on="p_partkey", right_on="l_partkey") + .merge(avg_table, left_on="p_partkey", right_on="l_partkey") + ) + filtered = joined[joined["l_quantity"] < joined["avg_qty"]] + value = (filtered["l_extendedprice"] / 7.0).sum() + value = value.compute() if hasattr(value, "compute") else value + return pd.DataFrame({"avg_yearly": [value]}) + + if variant == 9: + avg_table = _q17_pandas_avg(lineitem) + joined = ( + part[mask].merge(lineitem, left_on="p_partkey", right_on="l_partkey").merge(avg_table, on="l_partkey") + ) + filtered = joined[joined["l_quantity"] < joined["avg_qty"]].copy() + filtered["revenue"] = filtered["l_extendedprice"] / 7.0 + import pandas as pd + + value = filtered["revenue"].sum() + value = value.compute() if hasattr(value, "compute") else value + return pd.DataFrame({"avg_yearly": [value]}) + + # Variant 10 (threshold-first): threshold survivors are selected before + # the part join so the part join processes the reduced row set. + avg_table = _q17_pandas_avg(lineitem) + survivors = lineitem.merge(avg_table, on="l_partkey") + survivors = survivors[survivors["avg_qty"] > survivors["l_quantity"]] + joined = part[mask].merge(survivors, left_on="p_partkey", right_on="l_partkey") + return _q17_pandas_sum(joined) + + impl.__name__ = f"q17_v{variant}_pandas_impl" + impl.__qualname__ = impl.__name__ + return impl + + +_Q17_BASE = get_tpch_query("Q17") -Q17_VARIANTS: list[DataFrameQuery] = build_result_replay_variants(17, result_columns=["avg_yearly"]) +Q17_VARIANTS = build_variants( + 17, + [(_make_q17_expression_impl(v), _make_q17_pandas_impl(v)) for v in range(1, 11)], + _DESCRIPTIONS, + _Q17_BASE.categories, + expected_row_count=_Q17_BASE.expected_row_count, + scale_factor_dependent=_Q17_BASE.scale_factor_dependent, + timeout_seconds=_Q17_BASE.timeout_seconds, + skip_platforms=_Q17_BASE.skip_platforms, +) diff --git a/benchbox/core/tpchavoc/dataframe_queries/q18.py b/benchbox/core/tpchavoc/dataframe_queries/q18.py index 50276e05ea..aa231b2f8f 100644 --- a/benchbox/core/tpchavoc/dataframe_queries/q18.py +++ b/benchbox/core/tpchavoc/dataframe_queries/q18.py @@ -1,13 +1,377 @@ -"""TPC-Havoc DataFrame variants for Q18.""" +"""TPC-Havoc DataFrame variants for Q18. + +Q18 finds large orders via a quantity aggregate, semi-joins them against +customer/orders, re-joins lineitem, and groups with a top-100 sort. The +variants keep the canonical output while varying the large-order +materialization, the semi-join ordering, prefiltering, column pruning, +and aggregation structure. +""" from __future__ import annotations -from benchbox.core.dataframe.query import DataFrameQuery -from benchbox.core.tpchavoc.dataframe_queries._delegating_variants import build_result_replay_variants +from collections.abc import Callable +from typing import Any + +from benchbox.core.dataframe.compat import _to_list +from benchbox.core.dataframe.context import DataFrameContext +from benchbox.core.tpch.dataframe_queries import ( + get_query as get_tpch_query, + get_tpch_parameters, + q18_expression_impl as _q18_expr_base, + q18_pandas_impl as _q18_pandas_base, +) +from benchbox.core.tpchavoc.dataframe_queries.loader import build_variants + +VariantImpl = Callable[[DataFrameContext], Any] + +_DESCRIPTIONS = [ + "Baseline: direct delegation to TPC-H Q18 implementation", + "Distinct-key join: deduplicated large-order keys inner-joined instead of a semi-join", + "Late filtering: lineitem joined before the large-order semi-filter", + "Order-key prefilter: lineitem semi-pruned to qualifying keys before the quantity aggregate", + "Column prune: select only needed columns before the joins", + "Nation-band branches: large orders split by customer nation range, joined per branch, concatenated after", + "Marker reformulation: left-join large-order keys with a null-marker exclusion instead of a semi-join", + "Deferred enrichment: per-order quantity sums aggregated before joining customer and order columns", + "Top-100 pushdown: qualifying orders ranked by total price before the lineitem join", + "Filter-first ordering: large-order keys applied before customer join", +] + +_GROUP_KEYS = ["c_name", "c_custkey", "o_orderkey", "o_orderdate", "o_totalprice"] +_SORT_KEYS = ["o_totalprice", "o_orderdate"] +_SORT_DESC = [True, False] + + +def _q18_expr_large_orders(lineitem: Any, col: Any, lit: Any, threshold: Any) -> Any: + return ( + lineitem.group_by("l_orderkey") + .agg(col("l_quantity").sum().alias("total_qty")) + .filter(col("total_qty") > lit(threshold)) + .select("l_orderkey") + ) + + +def _q18_expr_aggregate(joined: Any, col: Any) -> Any: + return ( + joined.group_by(*_GROUP_KEYS) + .agg(col("l_quantity").sum().alias("sum_qty")) + .sort(_SORT_KEYS, descending=_SORT_DESC) + .limit(100) + ) + + +def _make_q18_expression_impl(variant: int) -> VariantImpl: + def impl(ctx: DataFrameContext) -> Any: + if variant == 1: + return _q18_expr_base(ctx) + + col = ctx.col + lit = ctx.lit + params = get_tpch_parameters(18) + threshold = params["quantity_threshold"] + customer = ctx.get_table("customer") + orders = ctx.get_table("orders") + lineitem = ctx.get_table("lineitem") + + if variant == 2: + # Distinct-key join: the large-order keys are deduplicated and + # joined as an inner join instead of the canonical semi-join. + # The aggregate emits one row per order key, so distinct() only + # changes the join operator, not the row set. + large_orders = _q18_expr_large_orders(lineitem, col, lit, threshold).distinct() + joined = ( + customer.join(orders, left_on="c_custkey", right_on="o_custkey") + .join(large_orders, left_on="o_orderkey", right_on="l_orderkey") + .join(lineitem, left_on="o_orderkey", right_on="l_orderkey") + ) + return _q18_expr_aggregate(joined, col) + + if variant == 3: + # Late filtering: lineitem is joined to customer/orders first and + # the large-order semi-filter runs after the big join instead of + # before it, reversing the canonical filter order. + large_orders = _q18_expr_large_orders(lineitem, col, lit, threshold) + joined = customer.join(orders, left_on="c_custkey", right_on="o_custkey").join( + lineitem, left_on="o_orderkey", right_on="l_orderkey" + ) + return _q18_expr_aggregate( + joined.join(large_orders, left_on="o_orderkey", right_on="l_orderkey", how="semi"), col + ) + + if variant == 4: + # Order-key prefilter: lineitem is first semi-pruned to the rows + # of orders whose line count could reach the threshold (at least + # two lines), so the quantity aggregate scans the reduced set. + # The count gate is sound only when a single line cannot reach + # the threshold (TPC-H l_quantity <= 50 < default 300); the + # prune is skipped for small thresholds where one line qualifies. + if threshold > 50: + multi_line_keys = ( + lineitem.group_by("l_orderkey") + .agg(col("l_orderkey").count().alias("line_cnt")) + .filter(col("line_cnt") > lit(1)) + .select("l_orderkey") + ) + pruned = lineitem.join(multi_line_keys, left_on="l_orderkey", right_on="l_orderkey", how="semi") + else: + pruned = lineitem + large_orders = _q18_expr_large_orders(pruned, col, lit, threshold) + joined = ( + customer.join(orders, left_on="c_custkey", right_on="o_custkey") + .join(large_orders, left_on="o_orderkey", right_on="l_orderkey", how="semi") + .join(lineitem, left_on="o_orderkey", right_on="l_orderkey") + ) + return _q18_expr_aggregate(joined, col) + + if variant == 5: + customer_cols = customer.select("c_custkey", "c_name") + orders_cols = orders.select("o_orderkey", "o_custkey", "o_orderdate", "o_totalprice") + lineitem_cols = lineitem.select("l_orderkey", "l_quantity") + large_orders = _q18_expr_large_orders(lineitem, col, lit, threshold) + joined = ( + customer_cols.join(orders_cols, left_on="c_custkey", right_on="o_custkey") + .join(large_orders, left_on="o_orderkey", right_on="l_orderkey", how="semi") + .join(lineitem_cols, left_on="o_orderkey", right_on="l_orderkey") + ) + return _q18_expr_aggregate(joined, col) + + if variant == 6: + # Nation-band branches: large orders are partitioned by the + # customer's nation key range before the customer/orders/lineitem + # joins, so each branch joins a disjoint key set and the concat + # restores the full set. Both bands are non-empty on TPC-H data + # (large orders span the nation domain). + order_totals = ( + lineitem.group_by("l_orderkey") + .agg(col("l_quantity").sum().alias("total_qty")) + .filter(col("total_qty") > lit(threshold)) + .select("l_orderkey") + ) + + def _nation_branch(nation_gate: Any) -> Any: + base = customer.filter(nation_gate).join(orders, left_on="c_custkey", right_on="o_custkey") + large = base.join(order_totals, left_on="o_orderkey", right_on="l_orderkey", how="semi") + return large.join(lineitem, left_on="o_orderkey", right_on="l_orderkey") + + low_band = _nation_branch(col("c_nationkey") < lit(12)) + high_band = _nation_branch(col("c_nationkey") >= lit(12)) + return _q18_expr_aggregate(ctx.concat([low_band, high_band]), col) + + if variant == 7: + # Marker reformulation: the large-order keys carry a constant + # marker through a left join, and rows with a null marker are + # excluded instead of the canonical semi-join. + large_orders = ( + lineitem.group_by("l_orderkey") + .agg(col("l_quantity").sum().alias("total_qty")) + .filter(col("total_qty") > lit(threshold)) + .select("l_orderkey") + .with_columns(lit(1).alias("is_large")) + ) + keyed = ( + customer.join(orders, left_on="c_custkey", right_on="o_custkey") + .join(large_orders, left_on="o_orderkey", right_on="l_orderkey", how="left") + .filter(col("is_large").is_not_null()) + .drop("is_large") + ) + return _q18_expr_aggregate(keyed.join(lineitem, left_on="o_orderkey", right_on="l_orderkey"), col) + + if variant == 8: + # Deferred enrichment: the per-order quantity sums are aggregated + # from lineitem first, then customer and order columns are joined + # onto the pre-aggregated rows instead of grouping the full join. + large_orders = _q18_expr_large_orders(lineitem, col, lit, threshold) + order_sums = ( + lineitem.group_by("l_orderkey") + .agg(col("l_quantity").sum().alias("sum_qty")) + .join(large_orders, left_on="l_orderkey", right_on="l_orderkey", how="semi") + ) + joined = customer.join(orders, left_on="c_custkey", right_on="o_custkey").join( + order_sums, left_on="o_orderkey", right_on="l_orderkey" + ) + return ( + joined.group_by(*_GROUP_KEYS) + .agg(col("sum_qty").first().alias("sum_qty")) + .sort(_SORT_KEYS, descending=_SORT_DESC) + .limit(100) + ) + + if variant == 9: + # Top-100 pushdown: qualifying orders are ranked by total price + # before the lineitem join, so the join only fans out the orders + # that can appear in the final top-100. + large_orders = _q18_expr_large_orders(lineitem, col, lit, threshold) + ranked = ( + customer.join(orders, left_on="c_custkey", right_on="o_custkey") + .join(large_orders, left_on="o_orderkey", right_on="l_orderkey", how="semi") + .sort(_SORT_KEYS, descending=_SORT_DESC) + .limit(100) + ) + joined = ranked.join(lineitem, left_on="o_orderkey", right_on="l_orderkey") + return ( + joined.group_by(*_GROUP_KEYS) + .agg(col("l_quantity").sum().alias("sum_qty")) + .sort(_SORT_KEYS, descending=_SORT_DESC) + .limit(100) + ) + + large_orders = _q18_expr_large_orders(lineitem, col, lit, threshold) + filtered_orders = orders.join(large_orders, left_on="o_orderkey", right_on="l_orderkey", how="semi") + joined = customer.join(filtered_orders, left_on="c_custkey", right_on="o_custkey").join( + lineitem, left_on="o_orderkey", right_on="l_orderkey" + ) + return _q18_expr_aggregate(joined, col) + + impl.__name__ = f"q18_v{variant}_expression_impl" + impl.__qualname__ = impl.__name__ + return impl + + +def _q18_pandas_large_orders(lineitem: Any, threshold: Any) -> Any: + order_qty = lineitem.groupby("l_orderkey", as_index=False).agg(total_qty=("l_quantity", "sum")) + return _to_list(order_qty[order_qty["total_qty"] > threshold]["l_orderkey"]) + + +def _q18_pandas_aggregate(joined: Any) -> Any: + return ( + joined.groupby(["c_name", "c_custkey", "o_orderkey", "o_orderdate", "o_totalprice"], as_index=False) + .agg(sum_qty=("l_quantity", "sum")) + .sort_values(["o_totalprice", "o_orderdate"], ascending=[False, True]) + .head(100) + ) + + +def _make_q18_pandas_impl(variant: int) -> VariantImpl: + def impl(ctx: DataFrameContext) -> Any: + if variant == 1: + return _q18_pandas_base(ctx) + + customer = ctx.get_table("customer") + orders = ctx.get_table("orders") + lineitem = ctx.get_table("lineitem") + params = get_tpch_parameters(18) + threshold = params["quantity_threshold"] + + if variant == 2: + # Distinct-key join mirror: deduplicated keys, inner merge. + import pandas as pd + + order_qty = lineitem.groupby("l_orderkey", as_index=False).agg(total_qty=("l_quantity", "sum")) + large_orders = _to_list( + order_qty[order_qty["total_qty"] > threshold].drop_duplicates("l_orderkey")["l_orderkey"] + ) + joined = customer.merge(orders, left_on="c_custkey", right_on="o_custkey") + keyed = joined.merge( + pd.DataFrame({"l_orderkey": large_orders}), + left_on="o_orderkey", + right_on="l_orderkey", + ).drop(columns=["l_orderkey"]) + return _q18_pandas_aggregate(keyed.merge(lineitem, left_on="o_orderkey", right_on="l_orderkey")) + + if variant == 3: + # Late filtering mirror: lineitem merged before the large-order filter. + large_orders = _q18_pandas_large_orders(lineitem, threshold) + joined = customer.merge(orders, left_on="c_custkey", right_on="o_custkey").merge( + lineitem, left_on="o_orderkey", right_on="l_orderkey" + ) + return _q18_pandas_aggregate(joined[joined["o_orderkey"].isin(large_orders)]) + + if variant == 4: + # Order-key prefilter mirror: multi-line orders only. Sound only + # when a single line cannot reach the threshold (l_quantity <= 50). + if threshold > 50: + line_counts = lineitem.groupby("l_orderkey", as_index=False).agg(line_cnt=("l_orderkey", "count")) + multi_line = _to_list(line_counts[line_counts["line_cnt"] > 1]["l_orderkey"]) + pruned = lineitem[lineitem["l_orderkey"].isin(multi_line)] + else: + pruned = lineitem + large_orders = _q18_pandas_large_orders(pruned, threshold) + joined = customer.merge(orders, left_on="c_custkey", right_on="o_custkey") + joined = joined[joined["o_orderkey"].isin(large_orders)] + return _q18_pandas_aggregate(joined.merge(lineitem, left_on="o_orderkey", right_on="l_orderkey")) + + if variant == 5: + customer_cols = customer[["c_custkey", "c_name"]] + orders_cols = orders[["o_orderkey", "o_custkey", "o_orderdate", "o_totalprice"]] + lineitem_cols = lineitem[["l_orderkey", "l_quantity"]] + large_orders = _q18_pandas_large_orders(lineitem, threshold) + joined = customer_cols.merge(orders_cols, left_on="c_custkey", right_on="o_custkey") + joined = joined[joined["o_orderkey"].isin(large_orders)] + return _q18_pandas_aggregate(joined.merge(lineitem_cols, left_on="o_orderkey", right_on="l_orderkey")) + + if variant == 6: + # Nation-band branches mirror: large-order keys per nation range, + # joined per branch through customer/orders, concatenated after. + order_qty = lineitem.groupby("l_orderkey", as_index=False).agg(total_qty=("l_quantity", "sum")) + large_set = set(_to_list(order_qty[order_qty["total_qty"] > threshold]["l_orderkey"])) + cust_orders = customer.merge(orders, left_on="c_custkey", right_on="o_custkey") + + def _nation_frame(gate: Any) -> Any: + band = cust_orders[gate(cust_orders)] + band = band[band["o_orderkey"].isin(large_set)] + return band.merge(lineitem, left_on="o_orderkey", right_on="l_orderkey") + + merged = ctx.concat( + [ + _nation_frame(lambda frame: frame["c_nationkey"] < 12), + _nation_frame(lambda frame: frame["c_nationkey"] >= 12), + ] + ) + return _q18_pandas_aggregate(merged) + + if variant == 7: + # Marker reformulation mirror: left merge plus null-marker filter. + order_qty = lineitem.groupby("l_orderkey", as_index=False).agg(total_qty=("l_quantity", "sum")) + keys = order_qty[order_qty["total_qty"] > threshold][["l_orderkey"]].copy() + keys["is_large"] = 1 + joined = customer.merge(orders, left_on="c_custkey", right_on="o_custkey").merge( + keys, left_on="o_orderkey", right_on="l_orderkey", how="left" + ) + joined = joined[joined["is_large"].notna()].drop(columns=["is_large", "l_orderkey"]) + return _q18_pandas_aggregate(joined.merge(lineitem, left_on="o_orderkey", right_on="l_orderkey")) + + if variant == 8: + # Deferred enrichment mirror: per-order sums first, then enrich. + large_orders = _q18_pandas_large_orders(lineitem, threshold) + order_sums = lineitem.groupby("l_orderkey", as_index=False).agg(sum_qty=("l_quantity", "sum")) + order_sums = order_sums[order_sums["l_orderkey"].isin(large_orders)] + joined = customer.merge(orders, left_on="c_custkey", right_on="o_custkey").merge( + order_sums, left_on="o_orderkey", right_on="l_orderkey" + ) + return ( + joined.groupby(["c_name", "c_custkey", "o_orderkey", "o_orderdate", "o_totalprice"], as_index=False) + .agg(sum_qty=("sum_qty", "first")) + .sort_values(["o_totalprice", "o_orderdate"], ascending=[False, True]) + .head(100) + ) + + if variant == 9: + # Top-100 pushdown mirror: rank qualifying orders before fanning out. + large_orders = _q18_pandas_large_orders(lineitem, threshold) + ranked = customer.merge(orders, left_on="c_custkey", right_on="o_custkey") + ranked = ranked[ranked["o_orderkey"].isin(large_orders)] + ranked = ranked.sort_values(["o_totalprice", "o_orderdate"], ascending=[False, True]).head(100) + return _q18_pandas_aggregate(ranked.merge(lineitem, left_on="o_orderkey", right_on="l_orderkey")) + + large_orders = _q18_pandas_large_orders(lineitem, threshold) + filtered_orders = orders[orders["o_orderkey"].isin(large_orders)] + joined = customer.merge(filtered_orders, left_on="c_custkey", right_on="o_custkey") + return _q18_pandas_aggregate(joined.merge(lineitem, left_on="o_orderkey", right_on="l_orderkey")) + + impl.__name__ = f"q18_v{variant}_pandas_impl" + impl.__qualname__ = impl.__name__ + return impl + + +_Q18_BASE = get_tpch_query("Q18") -Q18_VARIANTS: list[DataFrameQuery] = build_result_replay_variants( +Q18_VARIANTS = build_variants( 18, - result_columns=["c_name", "c_custkey", "o_orderkey", "o_orderdate", "o_totalprice", "sum_qty"], - sort_columns=["o_totalprice", "o_orderdate"], - descending=[True, False], + [(_make_q18_expression_impl(v), _make_q18_pandas_impl(v)) for v in range(1, 11)], + _DESCRIPTIONS, + _Q18_BASE.categories, + expected_row_count=_Q18_BASE.expected_row_count, + scale_factor_dependent=_Q18_BASE.scale_factor_dependent, + timeout_seconds=_Q18_BASE.timeout_seconds, + skip_platforms=_Q18_BASE.skip_platforms, ) diff --git a/benchbox/core/tpchavoc/dialect_compat.py b/benchbox/core/tpchavoc/dialect_compat.py new file mode 100644 index 0000000000..aad5469f91 --- /dev/null +++ b/benchbox/core/tpchavoc/dialect_compat.py @@ -0,0 +1,128 @@ +"""Targeted cross-dialect rewrites for TPC-Havoc query variants.""" + +from __future__ import annotations + +import sqlglot +from sqlglot import exp + +from benchbox.core.tpchavoc.cloud_compat import rewrite_cloud_variant + +POSTGRES_ALIAS_VARIANT_IDS = frozenset({"3_v7", "4_v7", "5_v7", "7_v7", "9_v7", "10_v7", "11_v7"}) +POSTGRES_QUALIFIED_COLUMNS: dict[str, dict[str, str]] = { + "2_v5": {"ps_partkey": "partsupp", "ps_supplycost": "partsupp"}, + "17_v2": {"l_partkey": "lineitem", "l_quantity": "lineitem", "l_extendedprice": "lineitem"}, +} +POSTGRES_DUAL_VARIANT_IDS = frozenset({"17_v4"}) +CLICKHOUSE_FILTER_VARIANT_IDS = frozenset({"1_v6", "12_v7"}) +DATAFUSION_EMPTY_GROUP_VARIANT_IDS = frozenset({"6_v2", "14_v2"}) +_CLOUD_DIALECTS = frozenset({"bigquery", "databricks", "snowflake"}) +_POSTGRES_DIALECTS = frozenset({"postgres", "postgresql"}) + + +def rewrite_dialect_variant(query_id: str, query: str, target_dialect: str) -> str: + """Preserve variant semantics where a target dialect rejects the translated shape.""" + target = target_dialect.lower() + if target in _CLOUD_DIALECTS: + return rewrite_cloud_variant(query_id, query, target) + if target == "clickhouse" and query_id in CLICKHOUSE_FILTER_VARIANT_IDS: + return _rewrite_clickhouse_filters(query) + if target == "datafusion" and query_id in DATAFUSION_EMPTY_GROUP_VARIANT_IDS: + return _drop_empty_grouping(query) + if target in _POSTGRES_DIALECTS: + if query_id in POSTGRES_ALIAS_VARIANT_IDS: + query = _inline_postgres_select_aliases(query) + if columns := POSTGRES_QUALIFIED_COLUMNS.get(query_id): + query = _qualify_postgres_columns(query, columns) + if query_id in POSTGRES_DUAL_VARIANT_IDS: + query = _rewrite_postgres_dual(query) + return query + + +def _inline_postgres_select_aliases(query: str) -> str: + """Replace PostgreSQL-illegal HAVING/WHERE select aliases with their expressions.""" + tree = sqlglot.parse_one(query, read="postgres") + for select in tree.find_all(exp.Select): + aliases = { + projection.alias: projection.this + for projection in select.expressions + if isinstance(projection, exp.Alias) and projection.alias + } + if not aliases: + continue + for clause_name in ("having", "where"): + clause = select.args.get(clause_name) + if clause is None: + continue + for column in list(clause.find_all(exp.Column)): + if column.table or column.name not in aliases: + continue + if column.find_ancestor(exp.Select) is select: + column.replace(aliases[column.name].copy()) + return tree.sql(dialect="postgres") + + +def _qualify_postgres_columns(query: str, columns: dict[str, str]) -> str: + """Qualify variant columns whose unqualified names collide after translation.""" + tree = sqlglot.parse_one(query, read="postgres") + for column in tree.find_all(exp.Column): + if not column.table and (table := columns.get(column.name)): + column.set("table", exp.to_identifier(table)) + return tree.sql(dialect="postgres") + + +def _rewrite_postgres_dual(query: str) -> str: + """Replace the generated one-row SELECT source with an explicit VALUES relation.""" + tree = sqlglot.parse_one(query, read="postgres") + replacement = sqlglot.parse_one("SELECT * FROM (VALUES (1)) AS dual(dual_col)", read="postgres").args["from_"].this + for subquery in tree.find_all(exp.Subquery): + inner = subquery.this + if subquery.alias != "dual" or not isinstance(inner, exp.Select) or len(inner.expressions) != 1: + continue + expression = inner.expressions[0] + if isinstance(expression, exp.Literal) and expression.this == "1" and not expression.is_string: + subquery.replace(replacement.copy()) + return tree.sql(dialect="postgres") + + +def _rewrite_clickhouse_filters(query: str) -> str: + """Replace aggregate FILTER clauses with ClickHouse aggregate combinators.""" + tree = sqlglot.parse_one(query, read="clickhouse") + return tree.transform(_clickhouse_filter_node).sql(dialect="clickhouse") + + +def _clickhouse_filter_node(node: exp.Expression) -> exp.Expression: + if not isinstance(node, exp.Filter): + return node + aggregate = node.this + condition = node.expression.this.copy() + if isinstance(aggregate, exp.Count) and isinstance(aggregate.this, exp.Star): + return exp.Anonymous(this="countIf", expressions=[condition]) + combinator = {exp.Sum: "sumOrNullIf", exp.Avg: "avgOrNullIf"}.get(type(aggregate)) + if combinator is None: + raise NotImplementedError( + f"ClickHouse FILTER rewrite handles only COUNT(*), SUM, and AVG; got {type(aggregate).__name__}. " + "Extend the combinator map before scoping this variant." + ) + return exp.Anonymous(this=combinator, expressions=[aggregate.this.copy(), condition]) + + +def _drop_empty_grouping(query: str) -> str: + """Drop GROUP BY () while retaining global aggregate and HAVING semantics.""" + tree = sqlglot.parse_one(query, read="postgres") + for select in tree.find_all(exp.Select): + group = select.args.get("group") + if group and len(group.expressions) == 1: + only = group.expressions[0] + if isinstance(only, exp.Tuple) and not only.expressions: + select.set("group", None) + return tree.sql(dialect="postgres") + + +__all__ = [ + "CLICKHOUSE_FILTER_VARIANT_IDS", + "DATAFUSION_EMPTY_GROUP_VARIANT_IDS", + "POSTGRES_ALIAS_VARIANT_IDS", + "POSTGRES_DUAL_VARIANT_IDS", + "POSTGRES_QUALIFIED_COLUMNS", + "rewrite_dialect_variant", +] diff --git a/benchbox/core/tpchavoc/equivalence.py b/benchbox/core/tpchavoc/equivalence.py index 278254861e..21f1d64dd3 100644 --- a/benchbox/core/tpchavoc/equivalence.py +++ b/benchbox/core/tpchavoc/equivalence.py @@ -152,8 +152,8 @@ # excluded via POSTGRES_TPCHAVOC_SKIPS, never marked equivalent. # # Empty: the Postgres sample (SF=0.1, both canonical and variants translated to -# the postgres dialect through the same seam) found zero divergences over the -# 203 executable variants - the translation layer is faithful and no variant is +# the postgres dialect through the same seam) found zero divergences across all +# executable variants. The translation layer is faithful and no variant is # silently wrong on PostgreSQL while green on DuckDB. POSTGRES_KNOWN_DIVERGENCES: dict[str, str] = {} @@ -165,8 +165,8 @@ # # Empty: the DataFusion sample (SF=0.1, both canonical and variants translated to # the datafusion dialect - which the seam normalizes to postgres - through the -# same path) found zero *result* divergences over the 206 executable variants. -# DataFusion's gaps are confined to which variants it can plan at all (the 14 +# same path) found zero *result* divergences over every executable variant. +# DataFusion's gaps are confined to which variants it can plan at all (the # DATAFUSION_TPCHAVOC_SKIPS), a DIFFERENT gap profile from PostgreSQL's; on the # variants it does execute the translation layer is just as faithful. Three # engines now agree: translation divergence is systematic-zero, not engine-specific. @@ -228,6 +228,7 @@ def find_divergences( *, query_ids: list[int] | None = None, translate_variant: Callable[[str], str] | None = None, + variant_query: Callable[[int, int], str] | None = None, skip_variants: Collection[str] | None = None, execute_transform: Callable[[str], str] | None = None, char_padding_columns: Mapping[int, Collection[int]] | None = None, @@ -258,6 +259,10 @@ def find_divergences( query_ids: Subset of query ids to check; defaults to all implemented. translate_variant: Optional callable rendering a variant's SQL into the target engine's dialect. Defaults to identity (DuckDB-native). + variant_query: Optional callable returning an already-rendered variant + for a query and variant id. This takes precedence over + ``translate_variant`` and lets the sample reuse query-id-aware + production rewrites. skip_variants: Variant keys ("_v") to exclude from the sweep entirely - e.g. POSTGRES_TPCHAVOC_SKIPS, which the engine cannot execute. Excluded variants are neither run nor counted as @@ -329,9 +334,11 @@ def find_divergences( if f"{query_id}_v{variant_id}" in excluded: continue try: - variant_sql = transform_for_engine( - render_variant(strip_top_n(benchmark.get_query(f"{query_id}_v{variant_id}"))) - ) + if variant_query is None: + rendered = render_variant(strip_top_n(benchmark.get_query(f"{query_id}_v{variant_id}"))) + else: + rendered = strip_top_n(variant_query(query_id, variant_id)) + variant_sql = transform_for_engine(rendered) variant_rows = normalize(connection.execute(variant_sql).fetchall()) benchmark.validate_variant_equivalence(query_id, variant_id, original, variant_rows) except ValidationError as exc: @@ -621,6 +628,9 @@ def _dialect_sample_divergences( lambda q: tpch.get_query(q, dialect=target_dialect), query_ids=query_ids, translate_variant=lambda sql: tpchavoc.translate_query_text(sql, "netezza", target_dialect), + variant_query=lambda query_id, variant_id: tpchavoc.get_query( + f"{query_id}_v{variant_id}", dialect=target_dialect + ), skip_variants=set(skip_variants), **sweep_kwargs, ) @@ -787,6 +797,9 @@ def _canonical_sql(query_id: int) -> str: _canonical_sql, query_ids=query_ids, translate_variant=lambda sql: tpchavoc.translate_query_text(sql, "netezza", DATAFUSION_TARGET_DIALECT), + variant_query=lambda query_id, variant_id: tpchavoc.get_query( + f"{query_id}_v{variant_id}", dialect=DATAFUSION_TARGET_DIALECT + ), skip_variants=set(DATAFUSION_TPCHAVOC_SKIPS), ) @@ -1026,13 +1039,16 @@ def run_clickhouse_sample() -> int: sample). Schema, load (incl. the row-count guard), translation, and sweep failures all propagate as real failures. """ + import os import tempfile from benchbox.sql_compat.rules.execution_filter.clickhouse_tpchavoc import CLICKHOUSE_TPCHAVOC_SKIPS + original_cwd = os.getcwd() try: import chdb # noqa: F401 except ImportError as exc: + os.chdir(original_cwd) print(f"ClickHouse equivalence sample SKIPPED - chDB (clickhouse-local) not installed: {exc}") return 0 diff --git a/benchbox/core/tpchavoc/spark_query_transformer.py b/benchbox/core/tpchavoc/spark_query_transformer.py new file mode 100644 index 0000000000..063bec0599 --- /dev/null +++ b/benchbox/core/tpchavoc/spark_query_transformer.py @@ -0,0 +1,241 @@ +"""Query transformation utilities for Spark SQL TPC-Havoc compatibility. + +Spark's analyzer rejects two SQL shapes used by TPC-Havoc variants: + +- Correlated scalar subqueries in the SELECT list of an aggregated query + (``SCALAR_SUBQUERY_IS_IN_GROUP_BY_OR_AGGREGATE_FUNCTION``). The scalar is + correlated on the GROUP BY keys, so it is constant per group; wrapping it in + ``first()`` preserves semantics while satisfying the analyzer. +- Empty grouping (``GROUP BY ()``), which the Spark parser rejects outright. + Dropping the empty grouping keeps the single-group aggregate semantics + (``HAVING`` without ``GROUP BY`` is valid on both Spark and DuckDB). +- The ``(SELECT 1) AS dual`` leg of the Q17 UNION ALL variant, which has no + explicit column alias. Adding one hardens the variant across Spark versions + and Spark-Connect engines. + +The Spark adapter keeps its lazy compatibility import at +``benchbox.platforms.spark_query_transformer``; the implementation lives here +so core query generation can reuse it without importing an adapter layer. + +Copyright 2026 Joe Harris / BenchBox Project +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +from __future__ import annotations + +import logging + +import sqlglot +from sqlglot import exp + +logger = logging.getLogger(__name__) + +# Variant IDs using a correlated scalar subquery in the SELECT list of a +# GROUP BY query. Verified against Spark 4.2 +# (``SCALAR_SUBQUERY_IS_IN_GROUP_BY_OR_AGGREGATE_FUNCTION``). +SCALAR_GROUP_BY_VARIANT_IDS: frozenset[str] = frozenset( + { + "1_v1", + "3_v1", + "7_v1", + "8_v1", + "9_v1", + "10_v1", + "12_v1", + "16_v1", + } +) + +# Variant IDs using empty grouping (``GROUP BY ()``), which the Spark parser +# rejects with ``PARSE_SYNTAX_ERROR``. +GROUP_BY_EMPTY_VARIANT_IDS: frozenset[str] = frozenset({"6_v2", "14_v2"}) + +# Variant IDs using the ``(SELECT 1) AS dual`` UNION ALL leg. +DUAL_VARIANT_IDS: frozenset[str] = frozenset({"17_v4"}) + + +class SparkTPCHavocQueryTransformer: + """Transform TPC-Havoc variant SQL for Spark compatibility. + + Uses variant-ID-based dispatch to apply targeted rewrites for the specific + TPC-Havoc variants Spark rejects. Follows the same pattern as + DataFusionQueryTransformer. All other queries pass through unchanged. + """ + + def __init__(self, verbose: bool = False): + """Initialize query transformer. + + Args: + verbose: Enable verbose logging of transformations + """ + self.verbose = verbose + self.transformations_applied: list[str] = [] + + def transform(self, query: str, query_id: str | None = None) -> str: + """Apply Spark-specific transformations to a TPC-Havoc variant query. + + Args: + query: Original SQL query + query_id: Optional variant identifier (e.g., "1_v1", "Q17_V4") + + Returns: + Transformed SQL query compatible with Spark + """ + self.transformations_applied = [] + + normalized_id = self.normalize_query_id(query_id) + + if normalized_id in SCALAR_GROUP_BY_VARIANT_IDS: + query = self._rewrite_scalar_group_by(query) + elif normalized_id in GROUP_BY_EMPTY_VARIANT_IDS: + query = self._rewrite_group_by_empty(query) + elif normalized_id in DUAL_VARIANT_IDS: + query = self._rewrite_dual(query) + + if self.verbose and self.transformations_applied: + logger.debug(f"Applied transformations: {', '.join(self.transformations_applied)}") + + return query + + def get_transformations_applied(self) -> list[str]: + """Return list of transformations applied to last query. + + Returns: + List of transformation names + """ + return self.transformations_applied + + @classmethod + def known_variant_ids(cls) -> frozenset[str]: + """All TPC-Havoc variant IDs this transformer rewrites.""" + return SCALAR_GROUP_BY_VARIANT_IDS | GROUP_BY_EMPTY_VARIANT_IDS | DUAL_VARIANT_IDS + + def normalize_query_id(self, query_id: str | None) -> str | None: + """Normalize a TPC-Havoc variant ID to "_v" form. + + Args: + query_id: Raw variant ID (e.g., "Q1_V1", "1-v1", "1_v1", "Q01_V1") + + Returns: + Normalized ID (e.g., "1_v1") or None + """ + if query_id is None: + return None + qid = str(query_id).strip().lower() + if qid.startswith("q"): + qid = qid[1:] + qid = qid.replace("-", "_") + if "_v" in qid: + q_num, _, v_num = qid.partition("_v") + qid = f"{q_num.lstrip('0') or '0'}_v{v_num}" + return qid + + # Backward-compatible alias. + _normalize_query_id = normalize_query_id + + def _rewrite_scalar_group_by(self, query: str) -> str: + """Wrap bare SELECT-list scalar subqueries in ``first()``. + + Spark requires a correlated scalar subquery in an aggregated query to + appear in GROUP BY or inside an aggregate function. Each rewritten + scalar is correlated on the GROUP BY keys, hence constant per group, + so ``first()`` preserves semantics. + """ + tree = sqlglot.parse_one(query, read="spark") + applied = False + for select in tree.find_all(exp.Select): + if not select.args.get("group"): + continue + new_projections = [] + select_changed = False + for projection in select.expressions: + new_projection = self._wrap_bare_scalars(projection) + new_projections.append(new_projection) + select_changed |= new_projection is not projection + if select_changed: + select.set("expressions", new_projections) + applied = True + if not applied: + return query + self.transformations_applied.append("scalar_group_by_first") + return tree.sql(dialect="spark") + + def _wrap_bare_scalars(self, node): # type: ignore[no-untyped-def] + """Wrap scalar-subquery nodes not already inside an aggregate. + + Stops at subquery, aggregate, membership-test, and nested-SELECT + boundaries so inner-query shapes are never rewritten. + """ + from sqlglot import exp + + if isinstance(node, exp.Subquery): + return exp.First(this=node) + if isinstance(node, (exp.AggFunc, exp.Exists, exp.In, exp.Any, exp.All, exp.Window)): + return node + if isinstance(node, exp.Select): + return node + args: dict = {} + changed = False + for key, value in node.args.items(): + if isinstance(value, exp.Expr): + new_value = self._wrap_bare_scalars(value) + args[key] = new_value + changed |= new_value is not value + elif isinstance(value, list): + new_list = [self._wrap_bare_scalars(item) if isinstance(item, exp.Expr) else item for item in value] + args[key] = new_list + changed |= any(new is not old for new, old in zip(new_list, value)) + else: + args[key] = value + if not changed: + return node + new_node = node.__class__(**args) + new_node.comments = node.comments + return new_node + + def _rewrite_group_by_empty(self, query: str) -> str: + """Drop empty grouping (``GROUP BY ()``) Spark cannot parse. + + With no GROUP BY the aggregate still computes a single group, so the + accompanying ``HAVING`` keeps its semantics on Spark and DuckDB. + """ + tree = sqlglot.parse_one(query, read="spark") + applied = False + for select in tree.find_all(exp.Select): + group = select.args.get("group") + if group and len(group.expressions) == 1: + only = group.expressions[0] + if isinstance(only, exp.Tuple) and not only.expressions: + select.set("group", None) + applied = True + if not applied: + return query + self.transformations_applied.append("group_by_empty_drop") + return tree.sql(dialect="spark") + + def _rewrite_dual(self, query: str) -> str: + """Give the ``(SELECT 1) AS dual`` leg an explicit column alias.""" + tree = sqlglot.parse_one(query, read="spark") + applied = False + for subquery in tree.find_all(exp.Subquery): + inner = subquery.this + if not isinstance(inner, exp.Select) or len(inner.expressions) != 1: + continue + only = inner.expressions[0] + if isinstance(only, exp.Alias): + continue + if isinstance(only, exp.Literal) and only.this == "1" and not only.args.get("is_string"): + only.replace(exp.alias_(only.copy(), "dual_col")) + applied = True + if not applied: + return query + self.transformations_applied.append("dual_column_alias") + return tree.sql(dialect="spark") + + +__all__ = [ + "SparkTPCHavocQueryTransformer", + "SCALAR_GROUP_BY_VARIANT_IDS", + "GROUP_BY_EMPTY_VARIANT_IDS", + "DUAL_VARIANT_IDS", +] diff --git a/benchbox/core/tpchavoc/validation.py b/benchbox/core/tpchavoc/validation.py index 9cd17f17d0..2ed98efbec 100644 --- a/benchbox/core/tpchavoc/validation.py +++ b/benchbox/core/tpchavoc/validation.py @@ -267,13 +267,19 @@ def validate_results_exact( # sort on a None-safe surrogate rather than the raw row (a bare ``sorted`` # would raise TypeError, which a caller would then mislabel as an execution # "error:" instead of a clean MISMATCH/match - see _row_sort_key). - original_sorted = sorted(original_results, key=self._row_sort_key) - variant_sorted = sorted(variant_results, key=self._row_sort_key) + original_sorted = sorted(original_results, key=self._pairing_sort_key) + variant_sorted = sorted(variant_results, key=self._pairing_sort_key) detail = self._first_positional_mismatch(original_sorted, variant_sorted, query_id, variant_id) if detail is None: return True + paired_variant = self._pair_variant_rows(original_sorted, variant_sorted) + if paired_variant is not None: + detail = self._first_positional_mismatch(original_sorted, paired_variant, query_id, variant_id) + if detail is None: + return True + if tie_aware and self._is_boundary_tie_equivalent(original_results, variant_results, query_id, variant_id): return True @@ -399,8 +405,8 @@ def _validate_order_aware( if i == last_index and tie_aware and final_key_tied_beyond_limit: continue detail = self._first_positional_mismatch( - sorted(orig_rows, key=self._row_sort_key), - sorted(var_rows, key=self._row_sort_key), + sorted(orig_rows, key=self._pairing_sort_key), + sorted(var_rows, key=self._pairing_sort_key), query_id, variant_id, ) @@ -441,13 +447,17 @@ def _multisets_equal(self, left: list[tuple[Any, ...]], right: list[tuple[Any, . Sorts both on the None-safe :meth:`_row_sort_key` (so a NULL-mixed column never raises) and compares positionally with :meth:`_values_equal`, so the - float tolerance and NULL handling stay identical to the strict path. + float tolerance and NULL handling stay identical to the strict path. If + positional ordering differs because of float noise or tie reshuffling within the + multiset, pairs rows using the validator's comparison semantics. """ if len(left) != len(right): return False - left_sorted = sorted(left, key=self._row_sort_key) - right_sorted = sorted(right, key=self._row_sort_key) - return all(self._order_keys_equal(lhs, rhs) for lhs, rhs in zip(left_sorted, right_sorted)) + left_sorted = sorted(left, key=self._pairing_sort_key) + right_sorted = sorted(right, key=self._pairing_sort_key) + if all(self._order_keys_equal(lhs, rhs) for lhs, rhs in zip(left_sorted, right_sorted)): + return True + return self._pair_variant_rows(left_sorted, right_sorted) is not None def _row_sort_key(self, row: tuple[Any, ...]) -> tuple[Any, ...]: """A total, never-raising sort key for a result row. @@ -476,6 +486,16 @@ def _row_sort_key(self, row: tuple[Any, ...]) -> tuple[Any, ...]: return tuple(self._cell_sort_key(value) for value in row) return _row_sort_key(row) + def _pairing_sort_key(self, row: tuple[Any, ...]) -> tuple[Any, ...]: + """A total sort key for ordering result rows ahead of pairing. + + Delegates to :meth:`_row_sort_key` so sorting is deterministic and total. Rows + that differ by float noise within the comparator's tolerance are paired under + :meth:`_pair_variant_rows`. The value digest (:func:`calculate_checksum`) keeps + using the exact :func:`_row_sort_key`. + """ + return self._row_sort_key(row) + def _cell_sort_key(self, value: Any) -> tuple[Any, ...]: """A None-safe, type-safe sort surrogate for one cell (see _row_sort_key). @@ -537,6 +557,109 @@ def _also_columns_suffix(mismatched: list[int]) -> str: extra = mismatched[1:] return f"; also columns {extra}" if extra else "" + def _rows_match( + self, + orig_row: tuple[Any, ...], + var_row: tuple[Any, ...], + aggregation_columns: Optional[Sequence[int]] = None, + ) -> bool: + """True if orig_row and var_row match across all columns under the comparator's rules.""" + if len(orig_row) != len(var_row): + return False + return not self._row_value_mismatch_columns(orig_row, var_row, aggregation_columns) + + def _pair_variant_rows( + self, + original_sorted: list[tuple[Any, ...]], + variant_sorted: list[tuple[Any, ...]], + aggregation_columns: Optional[Sequence[int]] = None, + ) -> list[tuple[Any, ...]] | None: + """Pair variant rows to matching original rows under the configured comparison semantics. + + Two query engines can compute identical aggregations in different internal orders and + differ by float noise within the comparator's tolerance. When otherwise-tied rows (such + as detail and subtotal rows produced by ROLLUP in Q36) sort in different orders on the + two sides, a purely positional zip pairs the wrong rows. + + This pairs variant rows to matching original rows using `self._rows_match` (which + respects `self.tolerance` rather than any fixed decimal bucket). Fast paths check the + positional diagonal and nearby neighbors first (sorted order clusters ties). Returns + the reordered variant rows aligned with original_sorted if an exact 1-to-1 matching + exists, or None if the rows cannot be matched. + """ + n = len(original_sorted) + if n != len(variant_sorted): + return None + if n == 0: + return [] + + # Fast path: already 100% positionally matched + if all(self._rows_match(original_sorted[i], variant_sorted[i], aggregation_columns) for i in range(n)): + return variant_sorted + + var_match = [-1] * n + + # Greedy pass: check diagonal first, then nearby neighbors (sorted order clusters ties) + for u in range(n): + if var_match[u] == -1 and self._rows_match(original_sorted[u], variant_sorted[u], aggregation_columns): + var_match[u] = u + continue + matched = False + for offset in (1, -1, 2, -2, 3, -3, 4, -4, 5, -5): + v = u + offset + if ( + 0 <= v < n + and var_match[v] == -1 + and self._rows_match(original_sorted[u], variant_sorted[v], aggregation_columns) + ): + var_match[v] = u + matched = True + break + if matched: + continue + for v in range(n): + if var_match[v] == -1 and self._rows_match(original_sorted[u], variant_sorted[v], aggregation_columns): + var_match[v] = u + break + + if not self._augmenting_path_match(original_sorted, variant_sorted, var_match, aggregation_columns): + return None + + paired: list[Optional[tuple[Any, ...]]] = [None] * n + for v, u in enumerate(var_match): + paired[u] = variant_sorted[v] + return paired # type: ignore[return-value] + + def _augmenting_path_match( + self, + original_sorted: list[tuple[Any, ...]], + variant_sorted: list[tuple[Any, ...]], + var_match: list[int], + aggregation_columns: Optional[Sequence[int]] = None, + ) -> bool: + """Find augmenting paths for remaining unmatched rows.""" + n = len(original_sorted) + matched_u = {u for u in var_match if u != -1} + for u in range(n): + if u in matched_u: + continue + visited = [False] * n + + def dfs(curr_u: int) -> bool: + for v in range(n): + if visited[v]: + continue + if self._rows_match(original_sorted[curr_u], variant_sorted[v], aggregation_columns): + visited[v] = True + if var_match[v] == -1 or dfs(var_match[v]): + var_match[v] = curr_u + return True + return False + + if not dfs(u): + return False + return True + def _first_positional_mismatch( self, original_sorted: list[tuple[Any, ...]], @@ -762,8 +885,11 @@ def validate_aggregation_results( # Sort both result sets on the None-safe surrogate so a NULL-bearing or # mixed-type column never raises TypeError (see _row_sort_key). - original_sorted = sorted(original_results, key=self._row_sort_key) - variant_sorted = sorted(variant_results, key=self._row_sort_key) + original_sorted = sorted(original_results, key=self._pairing_sort_key) + variant_sorted = sorted(variant_results, key=self._pairing_sort_key) + paired_variant = self._pair_variant_rows(original_sorted, variant_sorted, aggregation_columns) + if paired_variant is not None: + variant_sorted = paired_variant agg_columns = set(aggregation_columns or ()) for i, (orig_row, var_row) in enumerate(zip(original_sorted, variant_sorted)): diff --git a/benchbox/core/transaction_primitives/benchmark.py b/benchbox/core/transaction_primitives/benchmark.py index 317b772672..10a72c622f 100644 --- a/benchbox/core/transaction_primitives/benchmark.py +++ b/benchbox/core/transaction_primitives/benchmark.py @@ -20,14 +20,24 @@ from benchbox.core.connection import DatabaseConnection from benchbox.core.primitives_benchmark_utils import ( build_tpch_staging_tables_sql, - quote_identifier, + failed_platform_error, + fetch_count_probe, + quote_identifier_for_dialect, + replace_table_sql, + replaces_tables_in_place, summarize_validation_failures, - table_exists, ) from benchbox.core.transaction_primitives.generator import TransactionPrimitivesDataGenerator from benchbox.core.transaction_primitives.operations import TransactionOperationsManager from benchbox.core.transaction_primitives.schema import STAGING_TABLES, get_all_staging_tables_sql, get_create_table_sql from benchbox.core.transactional.benchmark_base import TransactionalBenchmarkBase +from benchbox.sql_compat.rules.execution_filter.cloud_transaction_primitives import ( + BIGQUERY_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, + SNOWFLAKE_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, +) +from benchbox.sql_compat.rules.execution_filter.databricks_transaction_primitives import ( + DATABRICKS_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, +) from benchbox.sql_compat.rules.execution_filter.duckdb_transaction_primitives import ( DUCKDB_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, ) @@ -38,6 +48,7 @@ TIMESCALEDB_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, ) from benchbox.utils.clock import elapsed_seconds, mono_time +from benchbox.utils.cloud_storage import normalize_output_dir from benchbox.utils.path_utils import get_benchmark_runs_datagen_path @@ -154,12 +165,14 @@ def __init__( self._version = "1.0" self._description = "Transaction Primitives benchmark - Testing fundamental write operations using TPC-H schema" - # Setup directories + # Setup directories. normalize_output_dir keeps an orchestrator-resolved + # CloudStagingPath/DatabricksPath handler intact instead of leaving a + # raw cloud URI string for downstream Path(...) calls to stringify. if output_dir is None: # Reuse the canonical TPC-H datagen directory output_dir = get_benchmark_runs_datagen_path("tpch", scale_factor) - self.output_dir = output_dir + self.output_dir = normalize_output_dir(output_dir) # Initialize components self.operations_manager = TransactionOperationsManager() @@ -194,13 +207,16 @@ def _acquire_setup_lock( # Create lock table if it doesn't exist (atomic operation) try: - connection.execute(""" + lock_res = connection.execute(""" CREATE TABLE IF NOT EXISTS transaction_primitives_setup_lock ( lock_name VARCHAR(255) PRIMARY KEY, holder_info VARCHAR(1000), acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + if (err := failed_platform_error(lock_res)) is not None: + self.log_verbose(f"Warning: Could not create lock table: {err}") + return False except Exception as e: self.log_verbose(f"Warning: Could not create lock table: {e}") return False @@ -220,10 +236,19 @@ def _acquire_setup_lock( escaped_lock_name = lock_name.replace("'", "''") escaped_holder_info = holder_info.replace("'", "''") - connection.execute( + ins_res = connection.execute( f"INSERT INTO transaction_primitives_setup_lock (lock_name, holder_info) " f"VALUES ('{escaped_lock_name}', '{escaped_holder_info}')" ) + if (err := failed_platform_error(ins_res)) is not None: + error_msg = err.lower() + if "unique" in error_msg or "duplicate" in error_msg or "constraint" in error_msg: + # Lock held by another process - wait and retry + time.sleep(0.5) + continue + else: + self.log_verbose(f"Unexpected error acquiring lock: {err}") + return False self.log_verbose(f"Acquired setup lock (waited {elapsed_seconds(start_time):.1f}s)") return True except Exception as e: @@ -272,7 +297,10 @@ def _quote_identifier(self, identifier: str) -> str: """Quote SQL identifier to prevent SQL injection. Uses double quotes (SQL standard) which work in DuckDB, PostgreSQL, SQLite. - For compatibility, validates identifier first. + Uses backticks for BigQuery, where double quotes denote string literals. + For compatibility, validates identifier first. Uppercases for dialects + whose catalogs are uppercase (Snowflake, BigQuery) so setup probes + resolve the adapter-created tables. Args: identifier: Table, column, or schema name @@ -288,31 +316,7 @@ def _quote_identifier(self, identifier: str) -> str: - Quotes with double quotes (SQL standard for identifiers) - Escapes any existing double quotes by doubling them """ - return quote_identifier(identifier) - - def _table_exists(self, connection: DatabaseConnection, table_name: str) -> bool: - """Check if a table exists in the database. - - Uses a platform-agnostic approach that attempts to query the table - with LIMIT 0, which should work across most SQL databases without - requiring INFORMATION_SCHEMA access. - - Args: - connection: Database connection - table_name: Name of table to check (will be quoted for safety) - - Returns: - True if table exists, False otherwise - - Note: - This method catches exceptions to distinguish between: - - Table doesn't exist (expected, returns False) - - Other errors (logged, returns False for safety) - - Security: - Table name is quoted using _quote_identifier() to prevent SQL injection. - """ - return table_exists(connection, table_name, self.log_verbose) + return quote_identifier_for_dialect(identifier, self._setup_dialect) def _populate_staging_table(self, connection: DatabaseConnection, staging_table: str, source_table: str) -> None: """Populate staging table from TPC-H source table. @@ -330,9 +334,63 @@ def _populate_staging_table(self, connection: DatabaseConnection, staging_table: SELECT * FROM {source_table} """ - connection.execute(populate_sql) + populate_res = connection.execute(populate_sql) + if (err := failed_platform_error(populate_res)) is not None: + raise RuntimeError(f"Failed to populate {staging_table} from {source_table}: {err}") self.log_verbose(f"{staging_table} populated successfully") + def _ensure_staging_table_populated( + self, + connection: DatabaseConnection, + table_name: str, + ) -> int: + """Ensure a staging table is populated and return its row count.""" + if table_name not in ["txn_orders", "txn_lineitem", "txn_customer"]: + try: + quoted_empty = self._quote_identifier(table_name) + return fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted_empty}") + except Exception: + return 0 + + source_table = "orders" if "orders" in table_name else ("lineitem" if "lineitem" in table_name else "customer") + quoted_table = self._quote_identifier(table_name) + + try: + current_count = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted_table}") + except Exception: + current_count = 0 + + if current_count > 0: + self.log_verbose(f"Table {table_name} already populated ({current_count} rows)") + return current_count + + # Validate source table has data before copying + try: + quoted_source = self._quote_identifier(source_table) + source_count = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted_source}") + except Exception as e: + raise RuntimeError( + f"Cannot validate source table '{source_table}' before populating '{table_name}': {e}" + ) from e + + if source_count == 0: + raise RuntimeError( + f"Source table '{source_table}' is empty (0 rows). " + f"Cannot populate staging table '{table_name}'. " + f"Please ensure TPC-H data is loaded before running setup()." + ) + + self.log_verbose(f"Populating {table_name} from {source_table} ({source_count} rows)...") + self._populate_staging_table(connection, table_name, source_table) + final_count = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted_table}") + self.log_verbose(f"✅ Populated {table_name} with {final_count} rows") + + if final_count != source_count: + self.log_verbose( + f"⚠️ Warning: Row count mismatch after population. Source: {source_count}, Destination: {final_count}" + ) + return final_count + def setup(self, connection: DatabaseConnection, force: bool = False, dialect: str = "standard") -> dict[str, Any]: """Setup benchmark for execution. @@ -354,11 +412,17 @@ def setup(self, connection: DatabaseConnection, force: bool = False, dialect: st """ self.log_verbose("Setting up Transaction Primitives benchmark...") + # Set dialect first so any downstream call to _quote_identifier() + # matches the catalog's identifier case. + self._setup_dialect = dialect + # Validate TPC-H base tables exist required_tables = ["orders", "lineitem", "customer"] for table in required_tables: try: - connection.execute(f"SELECT 1 FROM {table} LIMIT 1") + probe_res = connection.execute(f"SELECT 1 FROM {table} LIMIT 1") + if (err := failed_platform_error(probe_res)) is not None: + raise RuntimeError(f"Source table check failed: {err}") except Exception as e: raise RuntimeError( f"Required TPC-H table '{table}' not found. " @@ -387,10 +451,13 @@ def setup(self, connection: DatabaseConnection, force: bool = False, dialect: st rebuild = force or not self._staging_manifest_matches(connection, required_tables) # Drop existing staging tables when rebuilding (done once before loop) + replace_in_place = rebuild and replaces_tables_in_place(dialect) if rebuild: reason = "force mode" if force else "stale/absent staging manifest" self._drop_legacy_staging_manifests(connection) - for table_name in STAGING_TABLES: + if replace_in_place: + self._invalidate_staging_manifest(connection) + for table_name in [] if replace_in_place else STAGING_TABLES: try: connection.execute(f"DROP TABLE IF EXISTS {table_name}") self.log_verbose(f"Dropped existing {table_name} ({reason})") @@ -407,9 +474,15 @@ def setup(self, connection: DatabaseConnection, force: bool = False, dialect: st table_existed = self._table_exists(connection, table_name) # Create table if not exists (atomic operation) - create_sql = get_create_table_sql(table_name, dialect=dialect, if_not_exists=True) + create_sql = ( + replace_table_sql(get_create_table_sql(table_name, dialect=dialect)) + if replace_in_place + else get_create_table_sql(table_name, dialect=dialect, if_not_exists=True) + ) try: - connection.execute(create_sql) + create_res = connection.execute(create_sql) + if (err := failed_platform_error(create_res)) is not None: + raise RuntimeError(f"Failed to create {table_name}: {err}") # Track newly created tables (didn't exist before) if not table_existed: @@ -420,62 +493,7 @@ def setup(self, connection: DatabaseConnection, force: bool = False, dialect: st except Exception as e: raise RuntimeError(f"Failed to create {table_name}: {e}") from e - # Populate staging tables that are based on TPC-H tables - if table_name in ["txn_orders", "txn_lineitem", "txn_customer"]: - source_table = ( - "orders" if "orders" in table_name else ("lineitem" if "lineitem" in table_name else "customer") - ) - - # Check if table needs population - try: - quoted_table = self._quote_identifier(table_name) - result = connection.execute(f"SELECT COUNT(*) FROM {quoted_table}").fetchone() - current_count = result[0] if result else 0 - except Exception: - current_count = 0 - - if current_count == 0: - # Validate source table has data before copying - try: - quoted_source = self._quote_identifier(source_table) - source_result = connection.execute(f"SELECT COUNT(*) FROM {quoted_source}").fetchone() - source_count = source_result[0] if source_result else 0 - except Exception as e: - raise RuntimeError( - f"Cannot validate source table '{source_table}' before populating '{table_name}': {e}" - ) from e - - if source_count == 0: - raise RuntimeError( - f"Source table '{source_table}' is empty (0 rows). " - f"Cannot populate staging table '{table_name}'. " - f"Please ensure TPC-H data is loaded before running setup()." - ) - - # Table is empty - populate it (lock prevents concurrent population) - self.log_verbose(f"Populating {table_name} from {source_table} ({source_count} rows)...") - self._populate_staging_table(connection, table_name, source_table) - result = connection.execute(f"SELECT COUNT(*) FROM {quoted_table}").fetchone() - status[table_name] = result[0] if result else 0 - self.log_verbose(f"✅ Populated {table_name} with {status[table_name]} rows") - - # Verify row count matches (detect copy failures) - if status[table_name] != source_count: - self.log_verbose( - f"⚠️ Warning: Row count mismatch after population. " - f"Source: {source_count}, Destination: {status[table_name]}" - ) - else: - # Table already has data - status[table_name] = current_count - self.log_verbose(f"Table {table_name} already populated ({current_count} rows)") - else: - # Other staging tables start empty - just count rows - try: - result = connection.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone() - status[table_name] = result[0] if result else 0 - except Exception: - status[table_name] = 0 + status[table_name] = self._ensure_staging_table_populated(connection, table_name) # Record this setup()'s provenance (benchmark, scale, spec version, # source digest) so is_setup() can require an exact match rather @@ -593,10 +611,12 @@ def is_setup(self, connection: DatabaseConnection) -> bool: matches this run's provenance. """ try: - # Check that key staging tables exist and have data + # Check that key staging tables exist and have data. Quoted for + # the dialect so uppercase catalogs (Snowflake, BigQuery) resolve. for table_name in ["txn_orders", "txn_lineitem", "txn_customer"]: - result = connection.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone() - if not result or result[0] == 0: + quoted = self._quote_identifier(table_name) + count = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted}") + if count <= 0: return False return self._staging_manifest_matches(connection, ["orders", "lineitem", "customer"]) except Exception: @@ -704,7 +724,10 @@ def execute_operation( try: platform_operation_skips = { + "bigquery": BIGQUERY_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, + "databricks": DATABRICKS_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, "duckdb": DUCKDB_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, + "snowflake": SNOWFLAKE_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, "pg_duckdb": PG_DUCKDB_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, "timescaledb": TIMESCALEDB_TRANSACTION_PRIMITIVES_OPERATION_SKIPS, } @@ -761,6 +784,12 @@ def execute_operation( write_start = time.perf_counter() write_result = connection.execute(write_sql) write_duration_ms = (time.perf_counter() - write_start) * 1000 + # Adapters that report failures as a FAILED result payload do + # not raise here: surface the failure instead of letting a + # no-op write flow into validation (see the write_primitives + # guard for the live Snowflake case that motivated this). + if (write_error := failed_platform_error(write_result)) is not None: + raise RuntimeError(f"Transaction SQL failed on platform: {write_error}") # Get rows affected (platform-specific) rows_affected = getattr(write_result, "rowcount", None) @@ -779,7 +808,25 @@ def execute_operation( for val_query in operation.validation_queries: val_sql = self._replace_placeholders(val_query.sql) - val_result = connection.execute(val_sql).fetchall() + val_cursor = connection.execute(val_sql) + # A failed validation SELECT surfaces as an empty row set, + # which vacuous COUNT(*) checks would accept. Fail with the + # adapter-reported error instead. + if (val_error := failed_platform_error(val_cursor)) is not None: + validation_passed = False + validation_results.append( + { + "query_id": val_query.id, + "sql": val_query.sql, + "expected_rows": val_query.expected_rows, + "actual_rows": 0, + "passed": False, + "error": val_error, + "sample": [], + } + ) + continue + val_result = val_cursor.fetchall() actual_rows = len(val_result) expected_rows = val_query.expected_rows @@ -820,7 +867,9 @@ def execute_operation( if operation.cleanup_sql: # Execute cleanup SQL try: - connection.execute(operation.cleanup_sql) + cleanup_res = connection.execute(operation.cleanup_sql) + if (cleanup_err := failed_platform_error(cleanup_res)) is not None: + raise RuntimeError(cleanup_err) except Exception as e: cleanup_error = str(e) self.log_verbose(f"Cleanup SQL failed for {operation_id}: {cleanup_error}") diff --git a/benchbox/core/transaction_primitives/catalog/operations.yaml b/benchbox/core/transaction_primitives/catalog/operations.yaml index 337114e9a9..8ffa84373b 100644 --- a/benchbox/core/transaction_primitives/catalog/operations.yaml +++ b/benchbox/core/transaction_primitives/catalog/operations.yaml @@ -235,6 +235,9 @@ operations: expected_rows: 1 cleanup_sql: "DELETE FROM txn_orders WHERE o_orderkey BETWEEN 9990001 AND 9990010\n" expected_rows_affected: 10 + platform_overrides: + snowflake: "BEGIN TRANSACTION;\nINSERT INTO txn_orders\nWITH order_data AS (\n SELECT 9990000 + n AS o_orderkey,\n 1 AS o_custkey,\n 'O' AS o_orderstatus,\n 1000.0 * n AS o_totalprice,\n DATE '1998-01-01' AS o_orderdate,\n '5-LOW' AS o_orderpriority,\n 'Clerk#000000001' AS o_clerk,\n 0 AS o_shippriority,\n 'cte_test' AS o_comment\n FROM (SELECT unnest(generate_series(1, 10)) AS n) t\n)\nSELECT * FROM order_data;\nUPDATE txn_orders SET o_totalprice = o_totalprice * 2 WHERE o_orderkey BETWEEN 9990001 AND 9990010;\nCOMMIT;\n" + bigquery: "BEGIN TRANSACTION;\nINSERT INTO txn_orders\nWITH order_data AS (\n SELECT 9990000 + n AS o_orderkey,\n 1 AS o_custkey,\n 'O' AS o_orderstatus,\n 1000.0 * n AS o_totalprice,\n DATE '1998-01-01' AS o_orderdate,\n '5-LOW' AS o_orderpriority,\n 'Clerk#000000001' AS o_clerk,\n 0 AS o_shippriority,\n 'cte_test' AS o_comment\n FROM (SELECT unnest(generate_series(1, 10)) AS n) t\n)\nSELECT * FROM order_data;\nUPDATE txn_orders SET o_totalprice = o_totalprice * 2 WHERE o_orderkey BETWEEN 9990001 AND 9990010;\nCOMMIT;\n" - id: transaction_nested_subquery_updates category: advanced description: Tests UPDATE with nested subqueries within transaction diff --git a/benchbox/core/transaction_primitives/schema.py b/benchbox/core/transaction_primitives/schema.py index b68d3f2cb5..c2199d7a61 100644 --- a/benchbox/core/transaction_primitives/schema.py +++ b/benchbox/core/transaction_primitives/schema.py @@ -52,6 +52,33 @@ def _load_schema_specs() -> dict[str, Any]: STAGING_TABLES = {entry["key"]: globals()[symbol] for symbol, entry in _STAGING_DEFS.items()} +def _requires_catalog_managed_staging(dialect: str) -> bool: + """Return whether staging DDL must declare the catalogManaged Delta feature.""" + import benchbox.sql_compat.rules.ddl_optimize.databricks_ddl_rewrites # noqa: F401 + from benchbox.sql_compat.actions import CompatAction + from benchbox.sql_compat.context import CompatibilityContext, Phase + from benchbox.sql_compat.registry import REGISTRY + + ctx = CompatibilityContext( + platform=dialect.lower(), + platform_version=None, + benchmark="transaction_primitives", + query_id=None, + phase=Phase.DDL_OPTIMIZE, + mode="sql", + dialect=dialect, + ) + registry_decision = REGISTRY.resolve(ctx) + + if registry_decision is not None: + return ( + registry_decision.action == CompatAction.REWRITE_DDL + and registry_decision.rule_id + == "ddl_optimize.databricks.transaction_primitives.txn_staging_catalog_managed" + ) + return False # no rule: default to plain DDL + + def _supports_primary_keys(dialect: str) -> bool: """Return whether the target SQL dialect supports PRIMARY KEY in CREATE TABLE.""" import benchbox.sql_compat.rules.schema_emit.pk_capability_txn # noqa: F401 @@ -113,7 +140,15 @@ def get_create_table_sql(table_name: str, dialect: str = "standard", if_not_exis if_not_exists_clause = " IF NOT EXISTS" if if_not_exists else "" sql = f"CREATE TABLE{if_not_exists_clause} {table['name']} (\n" sql += ",\n".join(f" {col}" for col in columns) - sql += "\n);" + sql += "\n)" + if _requires_catalog_managed_staging(dialect): + # Databricks multi-statement transactions only write to Delta tables + # with the catalogManaged feature (TRANSACTION_NOT_SUPPORTED. + # WRITE_NON_CATALOG_MANAGED_TABLE otherwise). Staging tables are the + # only tables these transactions write. Governed by DDL_OPTIMIZE rule + # ddl_optimize.databricks.transaction_primitives.txn_staging_catalog_managed. + sql += " USING DELTA TBLPROPERTIES ('delta.feature.catalogManaged' = 'supported')" + sql += ";" return sql diff --git a/benchbox/core/transactional/benchmark_base.py b/benchbox/core/transactional/benchmark_base.py index f92b74c4f8..28d0405427 100644 --- a/benchbox/core/transactional/benchmark_base.py +++ b/benchbox/core/transactional/benchmark_base.py @@ -19,12 +19,31 @@ from benchbox.base import BaseBenchmark, GeneratorOutputDirMixin from benchbox.core.connection import DatabaseConnection from benchbox.core.operations import OperationExecutor -from benchbox.core.primitives_benchmark_utils import quote_identifier +from benchbox.core.primitives_benchmark_utils import ( + failed_platform_error, + quote_identifier_for_dialect, + table_exists, +) from benchbox.core.transactional.operations_registry_base import OperationsRegistryBase ResultT = TypeVar("ResultT") _POSTGRES_SERIES_DIALECTS = frozenset({"postgres", "postgresql"}) +# Dialects whose driver rollback() is a no-op under autocommit, so an explicit +# SQL ROLLBACK is needed to close a transaction opened by catalog BEGIN SQL. +_SQL_ROLLBACK_AFTER_ERROR_DIALECTS = frozenset({"databricks"}) _UNNEST_GENERATE_SERIES_RE = re.compile(r"unnest\(\s*generate_series\((?P[^()]*)\)\s*\)", re.IGNORECASE) +# The catalog writes integer series as ``(SELECT unnest(generate_series(a, b)) AS n) t``. +# Snowflake and BigQuery have neither function (verified live: "Unknown functions +# GENERATE_SERIES, UNNEST" and "Unexpected keyword UNNEST"); rewrite the whole +# derived-table body into each engine's series generator. +_UNNEST_SERIES_SELECT_RE = re.compile( + r"SELECT\s+unnest\(\s*generate_series\(\s*(?P[^,()]+?)\s*,\s*(?P[^,()]+?)\s*\)\s*\)\s+AS\s+(?P\w+)", + re.IGNORECASE, +) +_SERIES_SELECT_TEMPLATES = { + "snowflake": "SELECT value::INT AS {alias} FROM TABLE(FLATTEN(ARRAY_GENERATE_RANGE({lo}, {hi} + 1)))", + "bigquery": "SELECT {alias} FROM UNNEST(GENERATE_ARRAY({lo}, {hi})) AS {alias}", +} _SET_THEN_BEGIN_ISOLATION_RE = re.compile( r"SET\s+TRANSACTION\s+ISOLATION\s+LEVEL\s+(?PREPEATABLE\s+READ|SERIALIZABLE|READ\s+COMMITTED)\s*;\s*" r"BEGIN\s+TRANSACTION\s*;", @@ -61,6 +80,7 @@ class TransactionalBenchmarkBase(GeneratorOutputDirMixin, BaseBenchmark, Operati operations_manager: OperationsRegistryBase[Any] data_generator: Any tables: dict[str, Path] + _setup_dialect: str = "standard" # ------------------------------------------------------------------ # Abstract hooks implemented spec-locally @@ -283,6 +303,16 @@ def _prepare_operation( operation = self.operations_manager.get_operation(operation_id) + # Seed the quoting dialect before the reuse probe: is_setup() + # quotes staging probes through _setup_dialect, which setup() only + # copies from platform_key afterwards. On a fresh benchmark object + # against an already-initialized cloud database the probe would + # otherwise use the default "standard" quoting (double-quoted + # source-case names), miss backticked UPPERCASE tables on BigQuery + # or quoted-uppercase tables on Snowflake, and rerun setup — + # including lock/table DDL a reuse principal may not run. + if platform_key: + self._setup_dialect = platform_key if operation.requires_setup and not self.is_setup(connection): self.log_verbose("Staging tables not initialized - running setup() automatically...") try: @@ -319,18 +349,37 @@ def _lookup_platform_override( def _rewrite_transactional_sql_for_platform(self, sql: str, platform_key: str | None) -> str: """Apply narrow transactional catalog rewrites for the active SQL dialect.""" - if (platform_key or "").lower() not in _POSTGRES_SERIES_DIALECTS: + dialect = (platform_key or "").lower() + template = _SERIES_SELECT_TEMPLATES.get(dialect) + if template is not None: + return _UNNEST_SERIES_SELECT_RE.sub( + lambda m: template.format(lo=m.group("lo"), hi=m.group("hi"), alias=m.group("alias")), sql + ) + if dialect not in _POSTGRES_SERIES_DIALECTS: return sql sql = _UNNEST_GENERATE_SERIES_RE.sub(r"generate_series(\g)", sql) return _SET_THEN_BEGIN_ISOLATION_RE.sub(r"BEGIN TRANSACTION ISOLATION LEVEL \g;", sql) def _rollback_connection_after_error(self, connection: DatabaseConnection) -> None: - """Clear aborted transaction state on drivers that require explicit rollback after errors.""" + """Clear aborted transaction state after an operation error. + + Catalog operations open transactions with explicit ``BEGIN TRANSACTION`` + SQL. Under autocommit, a DB-API ``rollback()`` is a no-op on some + drivers (Databricks SQL), so a statement that fails between BEGIN and + COMMIT leaves the SQL transaction open and every later statement on the + session fails. Issue a SQL ROLLBACK as well; engines with no open + transaction treat it as a no-op or raise, and either is ignored. + """ try: if hasattr(connection, "rollback"): connection.rollback() except Exception as exc: self.log_verbose(f"Warning: rollback after operation error failed: {exc}") + if (getattr(self, "_setup_dialect", None) or "").lower() in _SQL_ROLLBACK_AFTER_ERROR_DIALECTS: + try: + connection.execute("ROLLBACK") + except Exception as exc: + self.log_verbose(f"SQL ROLLBACK after operation error was not needed or failed: {exc}") # ------------------------------------------------------------------ # Staging provenance manifest @@ -353,25 +402,61 @@ def _rollback_connection_after_error(self, connection: DatabaseConnection) -> No #: write_primitives sharing one physical database do not clobber each #: other's row. #: - #: The ``_v2`` generation is load-bearing, not cosmetic. The first release - #: of this manifest wrote a row unconditionally at the end of ``setup()``, - #: including on the path that skipped repopulation because the staging - #: tables were already non-empty. Those rows are *internally consistent* -- - #: correct scale, correct spec version, and a digest of the live source - #: tables -- while the staging data they describe is stale. Reusing the - #: original table name would therefore match them, ``is_setup()`` would - #: short-circuit, and every database that generation already mis-certified - #: would stay silently wrong forever. A new name makes them unmatchable, so - #: those databases take the missing-manifest path and rebuild once. - _STAGING_MANIFEST_TABLE = "benchbox_staging_manifest_v2" - - #: Superseded manifest table, dropped on rebuild so a database does not + #: The ``_v3`` generation is load-bearing, not cosmetic, as was the + #: ``_v2`` bump before it. The first release of this manifest wrote a row + #: unconditionally at the end of ``setup()``, including on the path that + #: skipped repopulation because the staging tables were already non-empty. + #: Those rows are *internally consistent* -- correct scale, correct spec + #: version, and a digest of the live source tables -- while the staging + #: data they describe is stale; the ``_v2`` name made them unmatchable so + #: those databases rebuilt once. The ``_v2`` generation in turn can certify + #: staging tables created before the Databricks catalogManaged DDL + #: requirement: again internally consistent rows describing plain + #: (non-catalog-managed) Delta tables that Databricks multi-statement + #: transactions cannot write + #: (TRANSACTION_NOT_SUPPORTED.WRITE_NON_CATALOG_MANAGED_TABLE). Reusing the + #: ``_v2`` name would therefore match them, ``is_setup()`` would + #: short-circuit, ``CREATE TABLE IF NOT EXISTS`` would leave the legacy + #: tables in place, and every affected database would keep failing its + #: transaction operations. A new name makes them unmatchable, so those + #: databases take the missing-manifest path and rebuild once with the + #: catalogManaged DDL. + _STAGING_MANIFEST_TABLE = "benchbox_staging_manifest_v3" + + #: Superseded manifest tables, dropped on rebuild so a database does not #: carry a stale generation's rows around indefinitely. Never read. - _LEGACY_STAGING_MANIFEST_TABLES: tuple[str, ...] = ("benchbox_staging_manifest",) + _LEGACY_STAGING_MANIFEST_TABLES: tuple[str, ...] = ( + "benchbox_staging_manifest_v2", + "benchbox_staging_manifest", + ) def _quote_identifier(self, identifier: str) -> str: """Quote a SQL identifier. Subclasses override for dialect-specific quoting.""" - return quote_identifier(identifier) + return quote_identifier_for_dialect(identifier, getattr(self, "_setup_dialect", "standard")) + + def _table_exists(self, connection: DatabaseConnection, table_name: str) -> bool: + """Check if a table exists in the database. + + Uses a platform-agnostic approach that attempts to query the table + with LIMIT 0, which should work across most SQL databases without + requiring INFORMATION_SCHEMA access. + + Args: + connection: Database connection + table_name: Name of table to check (will be quoted for safety) + + Returns: + True if table exists, False otherwise + + Note: + This method catches exceptions to distinguish between: + - Table doesn't exist (expected, returns False) + - Other errors (logged, returns False for safety) + + Security: + Table name is quoted using _quote_identifier() to prevent SQL injection. + """ + return table_exists(connection, table_name, self.log_verbose, getattr(self, "_setup_dialect", None)) def _drop_legacy_staging_manifests(self, connection: DatabaseConnection) -> None: """Remove superseded manifest generations during a rebuild. @@ -426,14 +511,28 @@ def _staging_source_digest(self, connection: DatabaseConnection, source_tables: for table in source_tables: try: quoted = self._quote_identifier(table) - result = connection.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone() - count = result[0] if result else 0 + res = connection.execute(f"SELECT COUNT(*) FROM {quoted}") + if failed_platform_error(res) is not None: + count = 0 + else: + result = res.fetchone() + count = result[0] if result else 0 except Exception: count = 0 parts.append(f"{table}:{count}") payload = "|".join(parts) return hashlib.sha256(payload.encode("utf-8")).hexdigest() + def _manifest_text_type(self) -> str: + """Return the unbounded text column type for the staging manifest DDL. + + Databricks SQL rejects ``VARCHAR`` without a length, and BigQuery has + no ``VARCHAR`` at all; both spell the type ``STRING``. Other engines + accept a bare ``VARCHAR``. + """ + dialect = (getattr(self, "_setup_dialect", None) or "standard").lower() + return "STRING" if dialect in ("databricks", "bigquery") else "VARCHAR" + def _write_staging_manifest(self, connection: DatabaseConnection, source_tables: list[str]) -> None: """Persist this setup()'s staging provenance so a later is_setup() can require an exact match. @@ -446,18 +545,42 @@ def _write_staging_manifest(self, connection: DatabaseConnection, source_tables: source_digest = self._staging_source_digest(connection, source_tables) quoted_table = self._quote_identifier(self._STAGING_MANIFEST_TABLE) - connection.execute( + text_type = self._manifest_text_type() + res1 = connection.execute( f"CREATE TABLE IF NOT EXISTS {quoted_table} (" - "benchmark VARCHAR, scale VARCHAR, spec_version VARCHAR, " - "source_digest VARCHAR, created_at VARCHAR)" + f"benchmark {text_type}, scale {text_type}, spec_version {text_type}, " + f"source_digest {text_type}, created_at {text_type})" ) - connection.execute(f"DELETE FROM {quoted_table} WHERE benchmark = '{_sql_escape(benchmark_id)}'") + if (err := failed_platform_error(res1)) is not None: + raise RuntimeError(f"Failed to create staging manifest table: {err}") + + res2 = connection.execute(f"DELETE FROM {quoted_table} WHERE benchmark = '{_sql_escape(benchmark_id)}'") + if (err := failed_platform_error(res2)) is not None: + raise RuntimeError(f"Failed to delete previous staging manifest entry: {err}") + created_at = datetime.now(timezone.utc).isoformat() - connection.execute( + res3 = connection.execute( f"INSERT INTO {quoted_table} (benchmark, scale, spec_version, source_digest, created_at) VALUES " f"('{_sql_escape(benchmark_id)}', '{_sql_escape(scale)}', '{_sql_escape(spec_version)}', " f"'{_sql_escape(source_digest)}', '{_sql_escape(created_at)}')" ) + if (err := failed_platform_error(res3)) is not None: + raise RuntimeError(f"Failed to insert staging manifest entry: {err}") + + def _invalidate_staging_manifest(self, connection: DatabaseConnection) -> None: + """Remove this benchmark's manifest row before rebuilding staging in place. + + A rebuild that replaces tables in place leaves earlier tables populated + if it fails part-way. Without this, the old manifest row would still + match and a later setup() would reuse the half-rebuilt staging set. + """ + benchmark_id, _scale, _spec = self._staging_provenance_key() + quoted_table = self._quote_identifier(self._STAGING_MANIFEST_TABLE) + try: + connection.execute(f"DELETE FROM {quoted_table} WHERE benchmark = '{_sql_escape(benchmark_id)}'") + except Exception as e: + # A missing manifest table already means "no reusable staging". + self.log_verbose(f"Staging manifest not cleared ({e})") def _staging_manifest_matches(self, connection: DatabaseConnection, source_tables: list[str]) -> bool: """Return True iff a manifest row exists whose benchmark/scale/spec/digest match this run. @@ -478,7 +601,10 @@ def _staging_manifest_matches(self, connection: DatabaseConnection, source_table f"spec_version = '{_sql_escape(spec_version)}' AND " f"source_digest = '{_sql_escape(source_digest)}'" ) - result = connection.execute(query).fetchone() + res = connection.execute(query) + if failed_platform_error(res) is not None: + return False + result = res.fetchone() return bool(result and result[0]) except Exception: return False diff --git a/benchbox/core/tsbs_devops/benchmark.py b/benchbox/core/tsbs_devops/benchmark.py index 74cfb6385c..3a8cc6b41b 100644 --- a/benchbox/core/tsbs_devops/benchmark.py +++ b/benchbox/core/tsbs_devops/benchmark.py @@ -19,12 +19,14 @@ from typing import TYPE_CHECKING, Any, Union from benchbox.base import BaseBenchmark, GeneratorOutputDirMixin +from benchbox.core.query_catalog_base import CLOUD_TRANSLATED_DIALECTS, TranslatableQueryMixin from benchbox.core.tsbs_devops.generator import TSBSDevOpsDataGenerator from benchbox.core.tsbs_devops.queries import TSBSDevOpsQueryManager from benchbox.core.tsbs_devops.schema import ( TSBS_DEVOPS_SCHEMA, get_create_tables_sql, ) +from benchbox.utils.cloud_storage import normalize_output_dir from benchbox.utils.compression_mixin import extract_compression_kwargs from benchbox.utils.path_utils import get_benchmark_runs_datagen_path @@ -32,7 +34,7 @@ from benchbox.core.connection import DatabaseConnection -class TSBSDevOpsBenchmark(GeneratorOutputDirMixin, BaseBenchmark): +class TSBSDevOpsBenchmark(GeneratorOutputDirMixin, TranslatableQueryMixin, BaseBenchmark): """TSBS DevOps benchmark implementation. Implements Time Series Benchmark Suite for DevOps monitoring workloads: @@ -73,6 +75,11 @@ class TSBSDevOpsBenchmark(GeneratorOutputDirMixin, BaseBenchmark): interval_seconds: Measurement interval """ + # Queries are written in DuckDB SQL; BigQuery rejects DATE_TRUNC('minute', ts) + # on timestamps, so translate for cloud engines. + _source_dialect = "duckdb" + _translated_dialects = CLOUD_TRANSLATED_DIALECTS + def __init__( self, scale_factor: float = 1.0, @@ -105,8 +112,13 @@ def __init__( # Resolve through the shared helper so BENCHBOX_OUTPUT_DIR is honored at # construction time (canonical tsbs_devops_sf datagen name); falls # back to Path.cwd()/benchmark_runs/datagen when no override is set. + # normalize_output_dir keeps a CloudStagingPath/DatabricksPath handler + # intact; Path(...) would stringify it to the local cache and drop the + # cloud upload target the orchestrator resolved at construction time. resolved_output_dir = ( - Path(output_dir) if output_dir else get_benchmark_runs_datagen_path("tsbs_devops", scale_factor) + normalize_output_dir(output_dir) + if output_dir + else get_benchmark_runs_datagen_path("tsbs_devops", scale_factor) ) super().__init__( scale_factor=scale_factor, @@ -175,16 +187,21 @@ def generate_data(self) -> list[Union[str, Path]]: return list(self.tables.values()) + def supported_dialects(self) -> list[str]: + """Return dialects whose query rendering get_queries() actually provides.""" + return ["duckdb", *self._translated_dialects] + def get_queries(self, dialect: str | None = None) -> dict[str, str]: """Get all benchmark queries. Args: - dialect: Target SQL dialect (not used - queries are standard SQL) + dialect: Target SQL dialect; cloud dialects are translated from the + DuckDB source Returns: Dictionary mapping query IDs to query strings """ - return self.query_manager.get_queries() + return {qid: self.translate_for_dialect(sql, dialect) for qid, sql in self.query_manager.get_queries().items()} def get_query( self, @@ -198,7 +215,7 @@ def get_query( Args: query_id: Query identifier (e.g., "single-host-12-hr", "cpu-max-all-1-hr") params: Optional parameter overrides - **kwargs: Additional arguments + **kwargs: Additional arguments; ``dialect`` selects the target SQL dialect Returns: Parameterized query string @@ -207,7 +224,7 @@ def get_query( ValueError: If query_id is unknown """ query_key = str(query_id) - return self.query_manager.get_query(query_key, params) + return self.translate_for_dialect(self.query_manager.get_query(query_key, params), kwargs.get("dialect")) def get_schema(self) -> dict[str, dict[str, Any]]: """Get the TSBS DevOps schema. diff --git a/benchbox/core/tsbs_devops/dataframe_queries/parameters.py b/benchbox/core/tsbs_devops/dataframe_queries/parameters.py index 15751a5cd0..a24da6b51c 100644 --- a/benchbox/core/tsbs_devops/dataframe_queries/parameters.py +++ b/benchbox/core/tsbs_devops/dataframe_queries/parameters.py @@ -55,4 +55,20 @@ def get(self, key: str, default: Any = None) -> Any: def get_parameters(query_id: str) -> TSBSDevOpsParameters: """Get parameters for a TSBS DevOps query.""" params = TSBS_DEVOPS_DEFAULT_PARAMS.get(query_id, {}).copy() + if _parameter_overrides is not None and query_id in _parameter_overrides: + params.update(_parameter_overrides[query_id]) return TSBSDevOpsParameters(query_id=query_id, params=params) + + +_parameter_overrides: dict[str, dict[str, Any]] | None = None + + +def set_parameter_overrides(overrides: dict[str, dict[str, Any]] | None) -> None: + """Inject seed-derived parameters for a gate run. + + Called by the cross-surface builder before query execution so the DataFrame + surface reads the same windows the SQL surface rendered. Pass None to clear + and revert to static defaults. + """ + global _parameter_overrides + _parameter_overrides = overrides diff --git a/benchbox/core/tsbs_devops/generator.py b/benchbox/core/tsbs_devops/generator.py index cb37ee712e..efe0a2ec8b 100644 --- a/benchbox/core/tsbs_devops/generator.py +++ b/benchbox/core/tsbs_devops/generator.py @@ -31,6 +31,7 @@ TABLE_ORDER, TSBS_DEVOPS_SCHEMA, ) +from benchbox.utils.cloud_storage import normalize_output_dir from benchbox.utils.compression_mixin import CompressionMixin from benchbox.utils.verbosity import VerbosityMixin, compute_verbosity @@ -95,7 +96,10 @@ def __init__( super().__init__(**kwargs) self.scale_factor = scale_factor - self.output_dir = Path(output_dir) if output_dir else Path.cwd() / "tsbs_devops_data" + # normalize_output_dir keeps a CloudStagingPath/DatabricksPath handler + # intact; the wrapper delegates mkdir/truediv to its local cache, so + # generation behavior is unchanged while the cloud target survives. + self.output_dir = normalize_output_dir(output_dir) or Path.cwd() / "tsbs_devops_data" # Calculate dimensions based on scale factor. # SF=1.0 uses 100 hosts over 2 days, which keeps the dataset close to diff --git a/benchbox/core/tuning/applied_ledger.py b/benchbox/core/tuning/applied_ledger.py index 1e897f1300..e97ed9401b 100644 --- a/benchbox/core/tuning/applied_ledger.py +++ b/benchbox/core/tuning/applied_ledger.py @@ -297,6 +297,30 @@ def overall_status(self, *, tuning_enabled: bool, has_config: bool) -> str: return FAILED return NOOP + def snapshot(self) -> tuple[int, int, int]: + return len(self.executed_statements), self._failed_count(), len(self.dropped) + + def _failed_count(self) -> int: + return sum(1 for s in self.statements if s.status == STATEMENT_FAILED) + + def describe_apply_step(self, since: tuple[int, int, int]) -> str: + executed_before, failed_before, dropped_before = since + executed, failed, dropped = self.snapshot() + statements = executed - executed_before + noun = "statement" if statements == 1 else "statements" + failures = failed - failed_before + failed_note = f", {failures} failed" if failures else "" + return ( + f"Tuning apply step complete ({statements} {noun}{failed_note}; " + f"{dropped - dropped_before} intents not rendered at apply time)" + ) + + def describe_outcome(self) -> str: + return ( + f"Tuning outcome after load: {len(self.executed_statements)} executed, " + f"{self._failed_count()} failed, {len(self.dropped)} dropped" + ) + def applied_ledger_hash(self) -> str | None: """SHA-256 over the ORDERED executed statements (physical identity). diff --git a/benchbox/core/tuning/interface.py b/benchbox/core/tuning/interface.py index cb60e80bc0..3dcd213b58 100644 --- a/benchbox/core/tuning/interface.py +++ b/benchbox/core/tuning/interface.py @@ -1259,40 +1259,241 @@ def disable_foreign_keys(self) -> None: """Disable foreign key constraints.""" self.foreign_keys.enabled = False - def enable_platform_optimization(self, optimization_type: TuningType, **kwargs) -> None: + # Table-layout tuning types recorded through table_tunings entries. + # The wizard confirms these choices per platform (Snowflake clustering, + # BigQuery partitioning/clustering, Redshift distribution/sort keys, + # DuckDB and ClickHouse partitioning/sorting) but historically routed them + # through this method, which only stored platform-optimization flags and + # silently dropped them. Callers may pass explicit `columns` (a list of + # TuningColumn, or of names that resolve to UNKNOWN-typed columns) to + # record a real table entry; without columns the choice is still + # recorded as an enabled type via a benchmark-aware default entry built + # from the packaged tuned templates, so a confirmed choice is never + # silently lost. + _TABLE_LAYOUT_TYPES = frozenset( + { + TuningType.PARTITIONING, + TuningType.CLUSTERING, + TuningType.DISTRIBUTION, + TuningType.SORTING, + } + ) + + def enable_platform_optimization( + self, + optimization_type: TuningType, + benchmark: str = "tpch", + columns: Optional[list[Any]] = None, + table_name: Optional[str] = None, + **kwargs, + ) -> None: """Enable a specific platform optimization. + Table-layout types (partitioning, clustering, distribution, sorting) + are recorded as entries in ``table_tunings`` so the choice persists + in ``to_dict()`` and ``get_enabled_tuning_types()``. When ``columns`` + are given they are used verbatim (TuningColumn objects) or resolved + from plain names with ``UNKNOWN`` type; otherwise a benchmark-aware + default entry is built from the packaged tuned template for + ``benchmark`` (falling back to the TPC-H layout). + Args: optimization_type: The type of optimization to enable + benchmark: Benchmark name used to resolve default table layouts + columns: Optional TuningColumn list (or plain column names) + table_name: Optional table to attach the layout to **kwargs: Additional configuration parameters """ + if optimization_type in self._TABLE_LAYOUT_TYPES: + self._enable_table_layout(optimization_type, benchmark=benchmark, columns=columns, table_name=table_name) + return + # `columns` is an explicit named parameter (used by the table-layout + # path above); platform optimizations historically read it from + # kwargs, so honor both spellings here. + column_values = columns if columns is not None else kwargs.get("columns") if optimization_type == TuningType.Z_ORDERING: self.platform_optimizations.z_ordering_enabled = True self.platform_optimizations.databricks_clustering_strategy = "z_order" - if "columns" in kwargs: - self.platform_optimizations.z_ordering_columns = kwargs["columns"] + if column_values is not None: + self.platform_optimizations.z_ordering_columns = column_values elif optimization_type == TuningType.LIQUID_CLUSTERING: self.platform_optimizations.liquid_clustering_enabled = True self.platform_optimizations.databricks_clustering_strategy = "liquid_clustering" - if "columns" in kwargs: - self.platform_optimizations.liquid_clustering_columns = kwargs["columns"] + if column_values is not None: + self.platform_optimizations.liquid_clustering_columns = column_values elif optimization_type == TuningType.AUTO_OPTIMIZE: self.platform_optimizations.auto_optimize_enabled = True elif optimization_type == TuningType.AUTO_COMPACT: self.platform_optimizations.auto_compact_enabled = True elif optimization_type == TuningType.BLOOM_FILTERS: self.platform_optimizations.bloom_filters_enabled = True - if "columns" in kwargs: - self.platform_optimizations.bloom_filter_columns = kwargs["columns"] + if column_values is not None: + self.platform_optimizations.bloom_filter_columns = column_values elif optimization_type == TuningType.MATERIALIZED_VIEWS: self.platform_optimizations.materialized_views_enabled = True + def _enable_table_layout( + self, + layout_type: TuningType, + benchmark: str = "tpch", + columns: Optional[list[Any]] = None, + table_name: Optional[str] = None, + ) -> None: + """Record a table-layout choice as a ``table_tunings`` entry. + + Explicit ``columns`` (TuningColumn objects, ``{"name": ...}`` dicts, + or plain names resolved with ``UNKNOWN`` type) attach to ``table_name`` + (or the benchmark default table). Without columns, the default layout + for ``benchmark`` is applied from the packaged tuned template, + falling back to the TPC-H layout. Existing entries merge: the new + layout type overwrites that slot on the target table while other + slots are preserved. + """ + slot = layout_type.value + resolved_columns = self._resolve_layout_columns(columns) + if resolved_columns: + target = self._explicit_layout_table(benchmark, table_name) + self._record_layout_slot(target, slot, resolved_columns) + return + targets = self._default_layout_targets(layout_type, benchmark, table_name) + if not targets: + # No template for this benchmark carries a usable layout (e.g. a + # benchmark with no packaged template at all): decline to invent a + # foreign table entry rather than persisting a choice the adapter + # cannot apply. + return + for target_table, entry_columns in targets.items(): + self._record_layout_slot(target_table, slot, list(entry_columns)) + + @staticmethod + def _resolve_layout_columns(columns: Optional[list[Any]]) -> list[TuningColumn]: + """Normalize caller-supplied layout columns to TuningColumn objects.""" + if not columns: + return [] + resolved: list[TuningColumn] = [] + for position, column in enumerate(columns, start=1): + if isinstance(column, TuningColumn): + resolved.append(column) + elif isinstance(column, dict): + payload = dict(column) + payload.setdefault("order", position) + payload.setdefault("type", "UNKNOWN") + resolved.append(TuningColumn.from_dict(payload)) + else: + resolved.append(TuningColumn(name=str(column), type="UNKNOWN", order=position)) + return resolved + + @staticmethod + def _read_template_table_tunings(benchmark: str) -> dict[str, Any]: + """Load the packaged duckdb tuned template's table tunings for a benchmark.""" + from benchbox.core.tuning.packaged_templates import packaged_template_path + + template = packaged_template_path("duckdb", benchmark.lower()) + if not template.exists(): + return {} + try: + import yaml + except ImportError: + return {} + try: + payload = yaml.safe_load(template.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return {} + table_tunings = payload.get("table_tunings") or {} + return dict(table_tunings) if isinstance(table_tunings, dict) else {} + + @staticmethod + def _first_template_column(entry: Any) -> list[TuningColumn]: + """First layout column of a template table entry, reordered to 1.""" + if isinstance(entry, dict): + for slot in ("partitioning", "clustering", "distribution", "sorting"): + raw = entry.get(slot) or [] + if isinstance(raw, list) and raw: + first = dict(raw[0]) + first["order"] = 1 + return [TuningColumn.from_dict(first)] + return [] + + def _record_layout_slot(self, target_table: str, slot: str, entry_columns: list[TuningColumn]) -> None: + """Write one layout slot onto one table, preserving other slots.""" + existing = self.table_tunings.get(target_table) + if existing is not None: + setattr(existing, slot, list(entry_columns)) + return + kwargs: dict[str, Any] = {"table_name": target_table, slot: list(entry_columns)} + self.table_tunings[target_table] = TableTuning(**kwargs) + + @staticmethod + def _explicit_layout_table(benchmark: str, table_name: Optional[str]) -> str: + """Resolve the target table for explicit caller-supplied columns.""" + table_tunings = UnifiedTuningConfiguration._read_template_table_tunings(benchmark) + ordered = sorted(table_tunings) if table_tunings else [] + if table_name is not None: + match = next((name for name in ordered if name.lower() == table_name.lower()), None) + return match if match is not None else table_name + if ordered: + return ordered[0] + return "LINEITEM" + + @staticmethod + def _default_layout_targets( + layout_type: TuningType, + benchmark: str, + table_name: Optional[str], + ) -> dict[str, list[TuningColumn]]: + """Resolve default tables and columns for a layout type. + + Applies the choice to every benchmark-template table carrying the + slot (not just the first), so a global TPC-H sorting choice reaches + all six tuned tables. Benchmarks with no packaged template resolve + to no targets, declining to substitute another benchmark's schema. + """ + table_tunings = UnifiedTuningConfiguration._read_template_table_tunings(benchmark.lower()) + if not table_tunings: + return {} + ordered_tables = sorted(table_tunings) + if table_name is not None: + match = next( + (name for name in ordered_tables if name.lower() == table_name.lower()), + None, + ) + target = match if match is not None else table_name + entry = table_tunings.get(target) + raw_columns: list[dict[str, Any]] = [] + if isinstance(entry, dict): + raw_entry = entry.get(layout_type.value) or [] + raw_columns = list(raw_entry) if isinstance(raw_entry, list) else [] + if raw_columns: + return {target: [TuningColumn.from_dict(col) for col in raw_columns]} + return {} + targets: dict[str, list[TuningColumn]] = {} + for name in ordered_tables: + entry = table_tunings.get(name) + raw_columns = [] + if isinstance(entry, dict): + raw_entry = entry.get(layout_type.value) or [] + raw_columns = list(raw_entry) if isinstance(raw_entry, list) else [] + if raw_columns: + targets[name] = [TuningColumn.from_dict(col) for col in raw_columns] + return targets + def disable_platform_optimization(self, optimization_type: TuningType) -> None: """Disable a specific platform optimization. + Table-layout types clear their ``table_tunings`` slot across all + tables, dropping tables left with no tuning at all. + Args: optimization_type: The type of optimization to disable """ + if optimization_type in self._TABLE_LAYOUT_TYPES: + slot = optimization_type.value + for table_name in list(self.table_tunings): + entry = self.table_tunings[table_name] + setattr(entry, slot, None) + if not entry.has_any_tuning(): + del self.table_tunings[table_name] + return if optimization_type == TuningType.Z_ORDERING: self.platform_optimizations.z_ordering_enabled = False self.platform_optimizations.z_ordering_columns = [] diff --git a/benchbox/core/tuning/metadata.py b/benchbox/core/tuning/metadata.py index 67fa349c20..ebe9094ce2 100644 --- a/benchbox/core/tuning/metadata.py +++ b/benchbox/core/tuning/metadata.py @@ -12,14 +12,19 @@ import hashlib import json import logging +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field from datetime import datetime -from typing import Any, Optional +from typing import Any, Iterator, Optional + +from benchbox.core.primitives_benchmark_utils import failed_platform_error from .interface import BenchmarkTunings, TableTuning, TuningColumn, TuningType, UnifiedTuningConfiguration logger = logging.getLogger(__name__) +NO_TUNING_METADATA_ERROR = "No tuning metadata found in database" + # The subset of TuningType values that _rebuild_tunings_from_records knows how # to place into a TableTuning (column-based, per-table tunings). Any other # tuning_type value found in the metadata table -- including the sentinel @@ -183,6 +188,7 @@ def __init__( platform_adapter, database_name: Optional[str] = None, connection_config: Optional[dict[str, Any]] = None, + connection: Any = None, ): """Initialize the metadata manager. @@ -190,16 +196,42 @@ def __init__( platform_adapter: Database platform adapter instance database_name: Optional database name for isolation connection_config: Exact connection settings used by validation + connection: Optional live connection to reuse. When supplied, every + metadata statement runs on it and the manager never opens (or + closes) its own connection -- this keeps mid-run metadata I/O + from re-entering database create/drop decisions. When omitted, + one guarded temporary connection is opened per operation. """ self.platform_adapter = platform_adapter self.database_name = database_name self.connection_config = dict(connection_config or {}) + self._shared_connection = connection self.logger = logging.getLogger(f"{self.__class__.__name__}") self._metadata_table_name = "benchbox_tuning_metadata" self._table_exists = None # Cache for table existence check self.last_load_error: str | None = None self.marker_save_failed = False + @contextmanager + def _managed_connection(self) -> Iterator[Any]: + """Yield the connection metadata statements must use. + + The caller-provided run connection wins; it is yielded as-is and never + closed here. Otherwise a single temporary connection is opened under + the adapter's non-destructive guard (when the adapter offers one) so + even the fallback path cannot re-enter create/drop handling mid-run. + """ + if self._shared_connection is not None: + yield self._shared_connection + return + guard = getattr(self.platform_adapter, "non_destructive_connection_context", None) + with ExitStack() as stack: + if guard is not None: + stack.enter_context(guard()) + temp_conn = self.platform_adapter.create_connection(**self._connection_kwargs()) + stack.callback(self.platform_adapter.close_connection, temp_conn) + yield temp_conn + def _connection_kwargs(self) -> dict[str, Any]: """Use the validated database name instead of silently falling back to another database.""" config = dict(self.platform_adapter.platform_config) @@ -226,6 +258,8 @@ def _is_missing_metadata_table_error(self, exc: Exception) -> bool: errno = getattr(current, "errno", None) or getattr(current, "code", None) if code == "42P01" or errno in {60, 1146}: return True + if "sqlstate: 42p01" in message: + return True if any( phrase in message for phrase in ( @@ -385,11 +419,8 @@ def _load_section_markers(self) -> dict[str, str]: FROM {self._metadata_table_name} WHERE table_name = '{self._SECTION_MARKER_TABLE}' """ - temp_conn = self.platform_adapter.create_connection(**self._connection_kwargs()) - try: - rows = self._fetch_all(temp_conn, query_sql) - finally: - self.platform_adapter.close_connection(temp_conn) + with self._managed_connection() as conn: + rows = self._fetch_all(conn, query_sql) return dict(rows) @@ -491,17 +522,15 @@ def create_metadata_table(self) -> bool: self.logger.info(f"Creating tuning metadata table: {self._metadata_table_name}") - # Create temporary connection for schema operations - temp_conn = self.platform_adapter.create_connection(**self._connection_kwargs()) - try: - self._execute_sql(temp_conn, create_sql) + # Run schema operations on the shared run connection when one was + # supplied; otherwise a single guarded temporary connection. + with self._managed_connection() as conn: + self._execute_sql(conn, create_sql) # Create index for performance index_sql = self._get_create_index_sql() if index_sql: - self._execute_sql(temp_conn, index_sql) - finally: - self.platform_adapter.close_connection(temp_conn) + self._execute_sql(conn, index_sql) self._table_exists = True return True @@ -709,6 +738,25 @@ def validate_unified_tunings(self, unified_config: UnifiedTuningConfiguration) - result.add_error(f"Validation failed with error: {e}") return result + def _format_literal(self, value: Any) -> str: + """Render a value as an inline SQL literal for job-style clients. + + Only the tuning-metadata INSERT path uses this: job-style clients + such as BigQuery accept no ``?`` placeholders, so values are inlined + into the statement the adapter qualifies. Strings are single-quoted + with embedded quotes doubled; datetimes use ISO format; booleans + render as TRUE/FALSE; None renders as NULL. + """ + if value is None: + return "NULL" + if isinstance(value, bool): + return "TRUE" if value else "FALSE" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, datetime): + return f"'{value.isoformat()}'" + return "'" + str(value).replace("'", "''") + "'" + def _batch_insert_records(self, records: list[TuningMetadata]) -> None: """Insert metadata records in batch.""" if not records: @@ -738,14 +786,29 @@ def _batch_insert_records(self, records: list[TuningMetadata]) -> None: ) # Execute batch insert - handle platforms that don't support batch operations - temp_conn = self.platform_adapter.create_connection(**self._connection_kwargs()) - try: - cursor = temp_conn.cursor() + with self._managed_connection() as conn: + if not hasattr(conn, "cursor"): + # Job-style clients (BigQuery) expose query() instead of a + # DBAPI cursor and accept no ? placeholders: run one + # qualified INSERT per record through the adapter so the + # table resolves and values are inlined safely. + for params in param_lists: + values = ", ".join(self._format_literal(value) for value in params) + insert_sql = f""" + INSERT INTO {self._metadata_table_name} + (table_name, tuning_type, column_name, column_order, + configuration_hash, created_at, platform) + VALUES ({values}) + """ + self._execute_sql(conn, insert_sql) + return + cursor = conn.cursor() for params in param_lists: - cursor.execute(insert_sql, params) - temp_conn.commit() - finally: - self.platform_adapter.close_connection(temp_conn) + res = cursor.execute(insert_sql, params) + target = res if res is not None else cursor + if (err := failed_platform_error(target)) is not None: + raise RuntimeError(f"Failed to insert tuning metadata: {err}") + conn.commit() def load_tunings(self, benchmark_name: Optional[str] = None) -> Optional[BenchmarkTunings]: """Load tuning configuration from metadata table. @@ -769,11 +832,8 @@ def load_tunings(self, benchmark_name: Optional[str] = None) -> Optional[Benchma ORDER BY table_name, tuning_type, column_order """ - temp_conn = self.platform_adapter.create_connection(**self._connection_kwargs()) - try: - results = self._fetch_all(temp_conn, query_sql) - finally: - self.platform_adapter.close_connection(temp_conn) + with self._managed_connection() as conn: + results = self._fetch_all(conn, query_sql) if not results: return None @@ -793,13 +853,10 @@ def _table_exists_check(self) -> bool: try: # Try to query the table query_sql = f"SELECT COUNT(*) FROM {self._metadata_table_name} LIMIT 1" - temp_conn = self.platform_adapter.create_connection(**self._connection_kwargs()) - try: - self._fetch_one(temp_conn, query_sql) + with self._managed_connection() as conn: + self._fetch_one(conn, query_sql) self._table_exists = True return True - finally: - self.platform_adapter.close_connection(temp_conn) except Exception as exc: if not self._is_missing_metadata_table_error(exc): self.last_load_error = str(exc) @@ -896,7 +953,7 @@ def validate_tunings(self, expected_tunings: BenchmarkTunings) -> MetadataValida if self.last_load_error: result.add_error(f"Failed to load tuning metadata: {self.last_load_error}") else: - result.add_error("No tuning metadata found in database") + result.add_error(NO_TUNING_METADATA_ERROR) return result # Compare configurations @@ -994,13 +1051,12 @@ def clear_tunings(self, benchmark_name: Optional[str] = None) -> bool: if not self._table_exists_check(): return True # Nothing to clear - # Delete all records (could be filtered by benchmark_name if we stored it) - delete_sql = f"DELETE FROM {self._metadata_table_name}" - temp_conn = self.platform_adapter.create_connection(**self._connection_kwargs()) - try: - self._execute_sql(temp_conn, delete_sql) - finally: - self.platform_adapter.close_connection(temp_conn) + # Delete all records (could be filtered by benchmark_name if we stored it). + # BigQuery rejects WHERE-less DELETE, so spell the full-table + # clear in a form every engine accepts. + delete_sql = f"DELETE FROM {self._metadata_table_name} WHERE TRUE" + with self._managed_connection() as conn: + self._execute_sql(conn, delete_sql) self.logger.info("Cleared tuning metadata") return True @@ -1035,11 +1091,8 @@ def get_metadata_summary(self) -> dict[str, Any]: WHERE table_name != '{self._SECTION_MARKER_TABLE}' """ - temp_conn = self.platform_adapter.create_connection(**self._connection_kwargs()) - try: - result = self._fetch_one(temp_conn, summary_sql) - finally: - self.platform_adapter.close_connection(temp_conn) + with self._managed_connection() as conn: + result = self._fetch_one(conn, summary_sql) if result: return { "table_exists": True, @@ -1069,7 +1122,14 @@ def _execute_sql(self, connection, sql: str, params: Optional[list] = None) -> A """ # Use platform adapter's query execution method if hasattr(self.platform_adapter, "execute_query"): + if not hasattr(connection, "cursor"): + # Job-style clients bypass the adapter's DDL qualification + # for reads; writes take the same path so INSERT and DELETE + # resolve the qualified table the creation path wrote. + sql = self._qualify_metadata_read_sql(sql) result = self.platform_adapter.execute_query(connection, sql, "metadata") + if (err := failed_platform_error(result)) is not None: + raise RuntimeError(f"Tuning metadata execution failed: {err}") return result.get("result") else: # Fall back to direct connection execution @@ -1079,11 +1139,30 @@ def _execute_sql(self, connection, sql: str, params: Optional[list] = None) -> A else: cursor.execute(sql) + if (err := failed_platform_error(cursor)) is not None: + raise RuntimeError(f"Tuning metadata execution failed: {err}") + try: return cursor.fetchall() except Exception: return None + def _qualify_metadata_read_sql(self, sql: str) -> str: + """Qualify a metadata SELECT for job-style connections. + + The query()-job branch bypasses the adapter's execute_query(), which + is the path that qualifies and uppercases identifiers (BigQuery + creation routes through _convert_to_bigquery_table -> + _qualify_table_target, and reads through _qualify_table_names; the + connection itself carries no default dataset). Run reads through the + same adapter qualification when it is reachable so SELECTs resolve + the qualified uppercased table that creation wrote. + """ + qualify = getattr(self.platform_adapter, "_qualify_table_names", None) + if callable(qualify): + return qualify(sql) + return sql + def _fetch_all(self, connection, sql: str) -> list[tuple]: """Fetch all results from a SELECT query. @@ -1098,10 +1177,26 @@ def _fetch_all(self, connection, sql: str) -> list[tuple]: # DBAPI cursor. Prefer their native execute() result before falling # back to the cursor contract used by DBAPI adapters. if not hasattr(connection, "cursor") and hasattr(connection, "execute"): - return list(connection.execute(sql)) + res = connection.execute(sql) + if (err := failed_platform_error(res)) is not None: + raise RuntimeError(f"Tuning metadata query failed: {err}") + return list(res) + + # Job-style clients such as BigQuery expose neither cursor() nor + # execute(): statements run as jobs via query(). Consume the job + # result the same way so tuning metadata reads work there too. + # Qualify through the adapter first: the job connection carries no + # default dataset, and creation qualified/uppercased the table via + # execute_query(), so raw unqualified lowercase reads would miss it. + if not hasattr(connection, "cursor"): + query_fn = getattr(connection, "query", None) + if callable(query_fn): + return list(query_fn(self._qualify_metadata_read_sql(sql)).result()) cursor = connection.cursor() cursor.execute(sql) + if (err := failed_platform_error(cursor)) is not None: + raise RuntimeError(f"Tuning metadata query failed: {err}") return cursor.fetchall() def _fetch_one(self, connection, sql: str) -> Optional[tuple]: @@ -1115,9 +1210,22 @@ def _fetch_one(self, connection, sql: str) -> Optional[tuple]: Single result tuple or None """ if not hasattr(connection, "cursor") and hasattr(connection, "execute"): - rows = list(connection.execute(sql)) + res = connection.execute(sql) + if (err := failed_platform_error(res)) is not None: + raise RuntimeError(f"Tuning metadata query failed: {err}") + rows = list(res) return rows[0] if rows else None + # Job-style clients such as BigQuery expose neither cursor() nor + # execute(): statements run as jobs via query() (see _fetch_all). + if not hasattr(connection, "cursor"): + query_fn = getattr(connection, "query", None) + if callable(query_fn): + rows = list(query_fn(self._qualify_metadata_read_sql(sql)).result()) + return rows[0] if rows else None + cursor = connection.cursor() cursor.execute(sql) + if (err := failed_platform_error(cursor)) is not None: + raise RuntimeError(f"Tuning metadata query failed: {err}") return cursor.fetchone() diff --git a/benchbox/core/tuning/platform_capabilities.py b/benchbox/core/tuning/platform_capabilities.py index c326cb2399..9f37bc0076 100644 --- a/benchbox/core/tuning/platform_capabilities.py +++ b/benchbox/core/tuning/platform_capabilities.py @@ -52,6 +52,7 @@ class PlatformTuningMapping: decision: str = MAPPED reason: str = "" max_columns: int | None = None + capped_tuning_types: tuple[str, ...] | None = None physical_rendering_id: str | None = None @property @@ -220,6 +221,7 @@ def _map_redshift(roles: set[str]) -> PlatformTuningMapping: "SORTKEY decisions; DISTKEY remains single-key limited." ), max_columns=1, + capped_tuning_types=(DISTRIBUTION,), ) return PlatformTuningMapping( platform="redshift", @@ -227,6 +229,7 @@ def _map_redshift(roles: set[str]) -> PlatformTuningMapping: physical_mechanisms=(DISTRIBUTION,), reason="Distribution candidates map to Redshift DISTKEY decisions with single-key limitations.", max_columns=1, + capped_tuning_types=(DISTRIBUTION,), ) if roles & ({TEMPORAL_PARTITION} | LOCALITY_ROLES): return PlatformTuningMapping( @@ -247,7 +250,9 @@ def _map_snowflake(roles: set[str]) -> PlatformTuningMapping: platform="snowflake", tuning_types=(CLUSTERING,), physical_mechanisms=(CLUSTERING,), - reason=reason, + reason=reason + + " Tables carry at most four clustering columns so the adapter resumes automatic reclustering.", + max_columns=4, ) if DISTRIBUTION_CANDIDATE in roles: return PlatformTuningMapping( diff --git a/benchbox/core/tuning/profile_validation.py b/benchbox/core/tuning/profile_validation.py index d3b2341fc5..6f850d5075 100644 --- a/benchbox/core/tuning/profile_validation.py +++ b/benchbox/core/tuning/profile_validation.py @@ -9,6 +9,8 @@ from typing import Any from benchbox.core.results.builder import normalize_benchmark_id +from benchbox.core.tuning.capability_registry import get_capability +from benchbox.core.tuning.interface import TuningType from benchbox.core.tuning.platform_capabilities import ( CLUSTERING, DATABRICKS_LIQUID_AUTO_RENDERING, @@ -63,6 +65,7 @@ class CandidateTemplateMapping: candidate: WorkloadTuningCandidate platform_mapping: PlatformTuningMapping mapped_tuning_types: tuple[str, ...] + capped_tuning_types: tuple[str, ...] = () @property def candidate_key(self) -> str: @@ -72,6 +75,12 @@ def candidate_key(self) -> str: def mapped(self) -> bool: return self.platform_mapping.decision == MAPPED and not self.missing_tuning_types + @property + def capped(self) -> bool: + """Whether a platform cap accounts for every missing mechanism.""" + missing = set(self.missing_tuning_types) + return bool(missing) and missing.issubset(self.capped_tuning_types) + @property def missing_tuning_types(self) -> tuple[str, ...]: mapped = set(self.mapped_tuning_types) @@ -84,6 +93,7 @@ def to_dict(self) -> dict[str, Any]: "logical_roles": list(self.candidate.roles), "decision": self.platform_mapping.decision, "mapped_tuning_types": list(self.mapped_tuning_types), + "capped_tuning_types": list(self.capped_tuning_types), "physical_mechanisms": list(self.platform_mapping.physical_mechanisms), "reason": self.platform_mapping.reason, } @@ -116,6 +126,11 @@ def required_count(self) -> int: def mapped_count(self) -> int: return sum(1 for mapping in self.mappings if mapping.mapped) + @property + def capped_count(self) -> int: + """Candidates excluded by a platform column cap rather than omission.""" + return sum(1 for mapping in self.mappings if mapping.capped) + @property def unsupported_count(self) -> int: return sum(1 for mapping in self.mappings if mapping.platform_mapping.decision == UNSUPPORTED) @@ -138,7 +153,7 @@ def physical_mechanisms(self) -> tuple[str, ...]: def unmapped_logical_candidates(self) -> tuple[dict[str, Any], ...]: unmapped: list[dict[str, Any]] = [] for mapping in self.mappings: - if mapping.mapped: + if mapping.mapped or mapping.capped: continue entry = { "candidate": mapping.candidate_key, @@ -146,7 +161,11 @@ def unmapped_logical_candidates(self) -> tuple[dict[str, Any], ...]: "reason": mapping.platform_mapping.reason or "required candidate was not mapped to the template", } if mapping.missing_tuning_types: - entry["missing_tuning_types"] = list(mapping.missing_tuning_types) + entry["missing_tuning_types"] = [ + tuning_type + for tuning_type in mapping.missing_tuning_types + if tuning_type not in mapping.capped_tuning_types + ] unmapped.append(entry) return tuple(unmapped) @@ -161,6 +180,7 @@ def to_metadata(self, *, max_unmapped: int = 20) -> dict[str, Any]: "accepted_count": self.accepted_count, "required_count": self.required_count, "mapped_count": self.mapped_count, + "capped_count": self.capped_count, "unsupported_count": self.unsupported_count, "waived_count": self.waived_count, "unmapped_count": len(self.unmapped_logical_candidates), @@ -183,6 +203,7 @@ def validate_tuning_template( platform_key = platform.lower().replace("_", "-") template_columns = extract_template_columns(tuning_config) physical_rendering_id = resolve_physical_rendering_id(platform_key, tuning_config) + rendering_verified_types = _rendering_verified_tuning_types(platform_key, tuning_config) candidates = profile.candidates(benchmark_key) accepted_count = sum(1 for candidate in candidates if candidate.status == ACCEPTED) dropped_candidates = tuple(candidate for candidate in candidates if candidate.status == DROPPED_LOW_EVIDENCE) @@ -191,6 +212,7 @@ def validate_tuning_template( issues.extend(_config_validation_issues(platform_key, tuning_config)) mappings: list[CandidateTemplateMapping] = [] + capped_slots: dict[tuple[str, str], int] = {} for candidate in candidates: if candidate.is_dropped: if _candidate_present(template_columns, candidate): @@ -218,16 +240,82 @@ def validate_tuning_template( for tuning_type in platform_mapping.tuning_types if candidate.column in template_columns.get(candidate.table, {}).get(tuning_type, set()) ) + # Platforms with a per-table column cap (BigQuery clustering <= 4, + # Redshift DISTKEY <= 1) cannot carry every mapped candidate. The + # template holds the first N profile-order candidates per table and + # tuning type; overflow candidates are reported as capped, not + # missing. A cap applies only to the tuning types named in the + # mapping's capped_tuning_types (Redshift's single-key limit governs + # DISTKEY alone; its compound SORTKEY list is unbounded), defaulting + # to every mapped tuning type when the mapping names none. + capped_types: list[str] = [] + if platform_mapping.max_columns is not None: + capped_scope = platform_mapping.capped_tuning_types + if capped_scope is None: + capped_scope = platform_mapping.tuning_types + for tuning_type in platform_mapping.tuning_types: + if tuning_type not in capped_scope: + if tuning_type in mapped_tuning_types: + slot = (candidate.table, tuning_type) + capped_slots[slot] = capped_slots.get(slot, 0) + 1 + continue + slot = (candidate.table, tuning_type) + used = capped_slots.get(slot, 0) + if used >= platform_mapping.max_columns and tuning_type not in mapped_tuning_types: + capped_types.append(tuning_type) + elif tuning_type in mapped_tuning_types: + capped_slots[slot] = used + 1 mappings.append( CandidateTemplateMapping( candidate=candidate, platform_mapping=platform_mapping, mapped_tuning_types=mapped_tuning_types, + capped_tuning_types=tuple(capped_types), ) ) - missing_tuning_types = mappings[-1].missing_tuning_types - if platform_mapping.decision == MAPPED and missing_tuning_types: + missing_tuning_types = tuple(t for t in mappings[-1].missing_tuning_types if t not in capped_types) + # Upper-bound enforcement honors the mapping's cap scope: Redshift's + # single-key limit governs DISTKEY alone, so its unbounded compound + # SORTKEY lists must never trip this check. + cap_scope = platform_mapping.capped_tuning_types + if cap_scope is None: + cap_scope = platform_mapping.tuning_types + if platform_mapping.max_columns is not None: + for tuning_type in cap_scope: + configured = template_columns.get(candidate.table, {}).get(tuning_type, set()) + if len(configured) > platform_mapping.max_columns: + issues.append( + TuningProfileValidationIssue( + severity=ERROR, + candidate_key=candidate_key(candidate), + message=( + f"template configures {len(configured)} {tuning_type} columns on " + f"{candidate.table} but {platform_key} allows at most " + f"{platform_mapping.max_columns}" + ), + decision=platform_mapping.decision, + reason=platform_mapping.reason, + ) + ) + unrendered_tuning_types = tuple( + t for t in mappings[-1].mapped_tuning_types if t not in rendering_verified_types + ) + if unrendered_tuning_types: + unrendered = ", ".join(unrendered_tuning_types) + issues.append( + TuningProfileValidationIssue( + severity=ERROR, + candidate_key=candidate_key(candidate), + message=( + f"template maps {unrendered} with no executed rendering on {platform_key}; " + "certification requires the configured columns to reach the physical layout" + ), + decision=platform_mapping.decision, + reason=platform_mapping.reason, + ) + ) + elif platform_mapping.decision == MAPPED and missing_tuning_types: expected = ", ".join(missing_tuning_types) or "a mapped tuning type" issues.append( TuningProfileValidationIssue( @@ -341,6 +429,41 @@ def _normalize_profile_benchmark(benchmark: str) -> str: return normalize_benchmark_id(benchmark).replace("-", "_") +def _rendering_verified_tuning_types(platform: str, tuning_config: Any) -> frozenset[str]: + """Return the tuning types with an executed (non-preview) rendering path. + + Logical candidates are only certified when their mapped template columns + reach the physical layout at execution time. The capability registry + records which tuning types render for real and which exist only as + dry-run preview or inspect-and-log hooks. This gate applies to the + platforms whose tuned templates are generated from the logical profile + (BigQuery, Redshift, Snowflake); the longer-standing DuckDB and + Databricks templates keep their existing membership-based certification. + + - BigQuery partitioning/clustering are preview-only: the adapter's + post-load hook only inspects the table and logs a recreation hint. + - Redshift distribution is preview-only: the adapter only logs the + mismatch because changing keys requires table recreation. + - Redshift sorting renders only when sorted ingestion is enabled; the + generated cloud templates leave it off, so it is unverified here. + - Snowflake clustering renders post-load via ALTER TABLE ... CLUSTER BY, + and partitioning folds into that same statement when no clustering + columns are configured. + """ + if platform not in {"bigquery", "redshift", "snowflake"}: + return frozenset(TUNING_TYPES) + verified: set[str] = set() + for tuning_type in TUNING_TYPES: + capability = get_capability(platform, TuningType(tuning_type)) + if capability is None or capability.rendered_via == "none": + continue + if platform == "redshift" and tuning_type == SORTING: + if _get_value(_get_value(tuning_config, "platform_optimizations"), "sorted_ingestion_mode") != "force": + continue + verified.add(tuning_type) + return frozenset(verified) + + def _candidate_present(template_columns: dict[str, dict[str, set[str]]], candidate: WorkloadTuningCandidate) -> bool: return any(candidate.column in columns for columns in template_columns.get(candidate.table, {}).values()) diff --git a/benchbox/core/tuning/profiles/tpc.yaml b/benchbox/core/tuning/profiles/tpc.yaml index 2cc5ec6afe..53972ae968 100644 --- a/benchbox/core/tuning/profiles/tpc.yaml +++ b/benchbox/core/tuning/profiles/tpc.yaml @@ -1,7 +1,7 @@ profile: id: tpc-v1 - version: "2026-05-26" - description: "Platform-neutral logical tuning profile for TPC-H and TPC-DS checked-in tuned templates." + version: "2026-09-26" + description: "Platform-neutral logical tuning profile for TPC-H, TPC-DS, and SSB checked-in tuned templates." benchmarks: tpch: evidence_source: "benchbox/_binaries/tpc-h/templates/{queries,variants}/" @@ -409,3 +409,70 @@ profile: query_ids: ["10", "18", "35", "64", "69", "84", "91"] status: accepted rationale: "Customer demographic locality appears in seven TPC-DS templates." + ssb: + evidence_source: "benchbox/core/ssb/queries.py" + candidates: + - table: LINEORDER + column: LO_ORDERDATE + type: INTEGER + roles: [temporal_partition, join_locality] + query_count: 13 + query_ids: ["Q1.1", "Q1.2", "Q1.3", "Q2.1", "Q2.2", "Q2.3", "Q3.1", "Q3.2", "Q3.3", "Q3.4", "Q4.1", "Q4.2", "Q4.3"] + status: existing_baseline + rationale: "Fact date partition key; equi-join predicate lo_orderdate = d_datekey in all 13 queries." + - table: LINEORDER + column: LO_SUPPKEY + type: INTEGER + roles: [join_locality] + query_count: 10 + query_ids: ["Q2.1", "Q2.2", "Q2.3", "Q3.1", "Q3.2", "Q3.3", "Q3.4", "Q4.1", "Q4.2", "Q4.3"] + status: existing_baseline + rationale: "Fact-side supplier join key; highest-evidence LINEORDER join column." + - table: LINEORDER + column: LO_CUSTKEY + type: INTEGER + roles: [join_locality] + query_count: 7 + query_ids: ["Q3.1", "Q3.2", "Q3.3", "Q3.4", "Q4.1", "Q4.2", "Q4.3"] + status: existing_baseline + rationale: "Fact-side customer join key in seven queries." + - table: LINEORDER + column: LO_PARTKEY + type: INTEGER + roles: [join_locality] + query_count: 6 + query_ids: ["Q2.1", "Q2.2", "Q2.3", "Q4.1", "Q4.2", "Q4.3"] + status: existing_baseline + rationale: "Fact-side part join key in six queries." + - table: DATE + column: D_DATEKEY + type: INTEGER + roles: [join_locality] + query_count: 13 + query_ids: ["Q1.1", "Q1.2", "Q1.3", "Q2.1", "Q2.2", "Q2.3", "Q3.1", "Q3.2", "Q3.3", "Q3.4", "Q4.1", "Q4.2", "Q4.3"] + status: existing_baseline + rationale: "Static 2,556-row dimension key; structural join record, not a physical layout directive." + - table: CUSTOMER + column: C_CUSTKEY + type: INTEGER + roles: [join_locality] + query_count: 7 + query_ids: ["Q3.1", "Q3.2", "Q3.3", "Q3.4", "Q4.1", "Q4.2", "Q4.3"] + status: existing_baseline + rationale: "Existing customer dimension key in checked SSB tuned templates." + - table: SUPPLIER + column: S_SUPPKEY + type: INTEGER + roles: [join_locality] + query_count: 10 + query_ids: ["Q2.1", "Q2.2", "Q2.3", "Q3.1", "Q3.2", "Q3.3", "Q3.4", "Q4.1", "Q4.2", "Q4.3"] + status: existing_baseline + rationale: "Existing supplier dimension key in checked SSB tuned templates." + - table: PART + column: P_PARTKEY + type: INTEGER + roles: [join_locality] + query_count: 6 + query_ids: ["Q2.1", "Q2.2", "Q2.3", "Q4.1", "Q4.2", "Q4.3"] + status: existing_baseline + rationale: "Existing part dimension key in checked SSB tuned templates." diff --git a/benchbox/core/tuning/templates/README.md b/benchbox/core/tuning/templates/README.md index ab36dee9c1..bf485d4a26 100644 --- a/benchbox/core/tuning/templates/README.md +++ b/benchbox/core/tuning/templates/README.md @@ -22,7 +22,7 @@ cp examples/tunings//_tuned.yaml \ ``` Only the platforms/benchmarks with real auto-discovery naming -(`duckdb/`, `databricks/`) are packaged here. DataFrame tuning files +(`duckdb/`, `databricks/`, `snowflake/`) are packaged here. DataFrame tuning files (`examples/tunings/dataframe/`) are never auto-discovered (see `examples/tunings/README.md`) and are not packaged. diff --git a/benchbox/core/tuning/templates/databricks/ssb_tuned.yaml b/benchbox/core/tuning/templates/databricks/ssb_tuned.yaml index d84c651456..23a8992aa7 100644 --- a/benchbox/core/tuning/templates/databricks/ssb_tuned.yaml +++ b/benchbox/core/tuning/templates/databricks/ssb_tuned.yaml @@ -41,14 +41,20 @@ table_tunings: type: INTEGER order: 1 clustering: - - name: lo_orderkey + - name: lo_orderdate type: INTEGER order: 1 - - name: lo_custkey + - name: lo_suppkey type: INTEGER order: 2 + - name: lo_custkey + type: INTEGER + order: 3 + - name: lo_partkey + type: INTEGER + order: 4 distribution: - - name: lo_orderkey + - name: lo_orderdate type: INTEGER order: 1 diff --git a/benchbox/core/tuning/templates/duckdb/ssb_tuned.yaml b/benchbox/core/tuning/templates/duckdb/ssb_tuned.yaml index 850f9e691c..b566aa1d59 100644 --- a/benchbox/core/tuning/templates/duckdb/ssb_tuned.yaml +++ b/benchbox/core/tuning/templates/duckdb/ssb_tuned.yaml @@ -39,9 +39,18 @@ table_tunings: type: INTEGER order: 1 sorting: - - name: lo_orderkey + - name: lo_orderdate type: INTEGER order: 1 + - name: lo_suppkey + type: INTEGER + order: 2 + - name: lo_custkey + type: INTEGER + order: 3 + - name: lo_partkey + type: INTEGER + order: 4 date: table_name: date diff --git a/benchbox/core/tuning/templates/snowflake/tpcds_tuned.yaml b/benchbox/core/tuning/templates/snowflake/tpcds_tuned.yaml new file mode 100644 index 0000000000..578f4628c6 --- /dev/null +++ b/benchbox/core/tuning/templates/snowflake/tpcds_tuned.yaml @@ -0,0 +1,155 @@ +# Snowflake TPCDS Tuned Configuration +# Generated from the logical tuning profile (benchbox/core/tuning/profiles/tpc.yaml) +# by scripts/generate_cloud_tpc_templates.py. Do not hand-edit: re-run the +# generator after changing the profile. Provides platform-native tuning for +# TPCDS benchmark workloads. + +primary_keys: + enabled: true + enforce_uniqueness: true + nullable: false + +foreign_keys: + enabled: true + enforce_referential_integrity: true + on_delete_action: CASCADE + on_update_action: RESTRICT + +unique_constraints: + enabled: true + ignore_nulls: false + +check_constraints: + enabled: true + enforce_on_insert: true + enforce_on_update: true + +platform_optimizations: + z_ordering_enabled: false + z_ordering_columns: [] + auto_optimize_enabled: false + auto_compact_enabled: false + bloom_filters_enabled: false + bloom_filter_columns: [] + materialized_views_enabled: false + +table_tunings: + CATALOG_RETURNS: + table_name: CATALOG_RETURNS + clustering: + - name: CR_RETURNED_DATE_SK + type: INTEGER + order: 1 + - name: CR_ITEM_SK + type: INTEGER + order: 2 + + CATALOG_SALES: + table_name: CATALOG_SALES + clustering: + - name: CS_SOLD_DATE_SK + type: INTEGER + order: 1 + - name: CS_ITEM_SK + type: INTEGER + order: 2 + - name: CS_SHIP_MODE_SK + type: INTEGER + order: 3 + + CUSTOMER: + table_name: CUSTOMER + clustering: + - name: C_CUSTOMER_SK + type: INTEGER + order: 1 + - name: C_CURRENT_ADDR_SK + type: INTEGER + order: 2 + - name: C_CURRENT_CDEMO_SK + type: INTEGER + order: 3 + + DATE_DIM: + table_name: DATE_DIM + clustering: + - name: D_DATE_SK + type: INTEGER + order: 1 + - name: D_YEAR + type: INTEGER + order: 2 + - name: D_MOY + type: INTEGER + order: 3 + + ITEM: + table_name: ITEM + clustering: + - name: I_ITEM_SK + type: INTEGER + order: 1 + - name: I_CATEGORY + type: VARCHAR + order: 2 + - name: I_CLASS + type: VARCHAR + order: 3 + + STORE_RETURNS: + table_name: STORE_RETURNS + clustering: + - name: SR_RETURNED_DATE_SK + type: INTEGER + order: 1 + - name: SR_ITEM_SK + type: INTEGER + order: 2 + - name: SR_CUSTOMER_SK + type: INTEGER + order: 3 + - name: SR_STORE_SK + type: INTEGER + order: 4 + + STORE_SALES: + table_name: STORE_SALES + clustering: + - name: SS_SOLD_DATE_SK + type: INTEGER + order: 1 + - name: SS_ITEM_SK + type: INTEGER + order: 2 + - name: SS_CUSTOMER_SK + type: INTEGER + order: 3 + - name: SS_STORE_SK + type: INTEGER + order: 4 + + WEB_RETURNS: + table_name: WEB_RETURNS + clustering: + - name: WR_RETURNED_DATE_SK + type: INTEGER + order: 1 + - name: WR_ITEM_SK + type: INTEGER + order: 2 + + WEB_SALES: + table_name: WEB_SALES + clustering: + - name: WS_SOLD_DATE_SK + type: INTEGER + order: 1 + - name: WS_ITEM_SK + type: INTEGER + order: 2 + - name: WS_WEB_PAGE_SK + type: INTEGER + order: 3 + - name: WS_WEB_SITE_SK + type: INTEGER + order: 4 diff --git a/benchbox/core/tuning/templates/snowflake/tpch_tuned.yaml b/benchbox/core/tuning/templates/snowflake/tpch_tuned.yaml new file mode 100644 index 0000000000..d37531961c --- /dev/null +++ b/benchbox/core/tuning/templates/snowflake/tpch_tuned.yaml @@ -0,0 +1,107 @@ +# Snowflake TPCH Tuned Configuration +# Generated from the logical tuning profile (benchbox/core/tuning/profiles/tpc.yaml) +# by scripts/generate_cloud_tpc_templates.py. Do not hand-edit: re-run the +# generator after changing the profile. Provides platform-native tuning for +# TPCH benchmark workloads. + +primary_keys: + enabled: true + enforce_uniqueness: true + nullable: false + +foreign_keys: + enabled: true + enforce_referential_integrity: true + on_delete_action: CASCADE + on_update_action: RESTRICT + +unique_constraints: + enabled: true + ignore_nulls: false + +check_constraints: + enabled: true + enforce_on_insert: true + enforce_on_update: true + +platform_optimizations: + z_ordering_enabled: false + z_ordering_columns: [] + auto_optimize_enabled: false + auto_compact_enabled: false + bloom_filters_enabled: false + bloom_filter_columns: [] + materialized_views_enabled: false + +table_tunings: + CUSTOMER: + table_name: CUSTOMER + clustering: + - name: C_CUSTKEY + type: INTEGER + order: 1 + - name: C_NATIONKEY + type: INTEGER + order: 2 + + LINEITEM: + table_name: LINEITEM + clustering: + - name: L_SHIPDATE + type: DATE + order: 1 + - name: L_ORDERKEY + type: INTEGER + order: 2 + - name: L_PARTKEY + type: INTEGER + order: 3 + - name: L_SUPPKEY + type: INTEGER + order: 4 + + ORDERS: + table_name: ORDERS + clustering: + - name: O_ORDERDATE + type: DATE + order: 1 + - name: O_ORDERKEY + type: INTEGER + order: 2 + - name: O_CUSTKEY + type: INTEGER + order: 3 + + PART: + table_name: PART + clustering: + - name: P_PARTKEY + type: INTEGER + order: 1 + - name: P_TYPE + type: VARCHAR + order: 2 + - name: P_SIZE + type: INTEGER + order: 3 + + PARTSUPP: + table_name: PARTSUPP + clustering: + - name: PS_PARTKEY + type: INTEGER + order: 1 + - name: PS_SUPPKEY + type: INTEGER + order: 2 + + SUPPLIER: + table_name: SUPPLIER + clustering: + - name: S_SUPPKEY + type: INTEGER + order: 1 + - name: S_NATIONKEY + type: INTEGER + order: 2 diff --git a/benchbox/core/validation/engines.py b/benchbox/core/validation/engines.py index 0994dc7628..f88712be25 100644 --- a/benchbox/core/validation/engines.py +++ b/benchbox/core/validation/engines.py @@ -257,6 +257,12 @@ def validate_generated_data(self, manifest_path: Path) -> ValidationResult: warnings=warnings, ) + # A manifest that lists no tables, or only tables without files, is + # invalid for every benchmark, with or without table-count expectations: + # nothing can load from it. + if not any((manifest.get("tables") or {}).values()): + errors.append("Manifest lists no tables; data generation produced no usable output") + # Get benchmark expectations benchmark_type = manifest.get("benchmark", "").lower() expectations = self.BENCHMARK_EXPECTATIONS.get(benchmark_type) diff --git a/benchbox/core/validation/query_validation.py b/benchbox/core/validation/query_validation.py index 4506aa32e8..7bce7fded4 100644 --- a/benchbox/core/validation/query_validation.py +++ b/benchbox/core/validation/query_validation.py @@ -67,31 +67,6 @@ def _set_thread_context(state: threading.local, attribute: str, value: object) - def set_reference_seed_context(is_reference_seed: bool | None) -> None: - """Record, for the CURRENT THREAD, whether the query about to run through - QueryValidator is using the pinned TPC reference seed's substitution - parameters. - - This module never derives or compares seeds itself -- callers (the TPC-H - power/throughput drivers) compute the comparison against - benchbox.core.tpch.benchmark.get_reference_seed() and pass the result in. - - Values: - True: this query's actual seed matches the reference seed for its - scale factor (or the run used qgen defaults, which the TPC-H - drivers treat as reference-equivalent) -- exact validation applies - normally, including for the parameter-sensitive queries. - False: this query is running under different substitution parameters - than the reference answer set -- get_parameter_sensitive_query_ids - entries are relaxed from their canonical EXACT mode to their RANGE - bounds (or LOOSE tolerance) rather than exact-compared, so - spec-sanctioned parameter variance is accepted while a 0-row or - out-of-range result still fails. - None (the default/unset value): unknown -- preserves the pre-existing - behavior of always attempting EXACT validation. Benchmarks that - never call this function (TPC-DS, DataFrame validation, any other - QueryValidator caller) are therefore completely unaffected by the - relaxation below. - """ _set_thread_context(_reference_seed_state, "is_reference_seed", is_reference_seed) diff --git a/benchbox/core/write_primitives/benchmark.py b/benchbox/core/write_primitives/benchmark.py index 3e53dd7d57..d1e63a6610 100644 --- a/benchbox/core/write_primitives/benchmark.py +++ b/benchbox/core/write_primitives/benchmark.py @@ -24,7 +24,11 @@ from benchbox.core.connection import DatabaseConnection from benchbox.core.primitives_benchmark_utils import ( build_tpch_staging_tables_sql, - quote_identifier, + failed_platform_error, + fetch_count_probe, + quote_identifier_for_dialect, + replace_table_sql, + replaces_tables_in_place, summarize_validation_failures, table_exists, ) @@ -45,6 +49,7 @@ POSTGRES_WRITE_PRIMITIVES_OPERATION_SKIPS, ) from benchbox.utils.clock import elapsed_seconds, mono_time +from benchbox.utils.cloud_storage import normalize_output_dir from benchbox.utils.path_utils import get_benchmark_runs_datagen_path _POSTGRES_OPERATION_SKIP_DIALECTS = frozenset({"postgres", "postgresql"}) @@ -157,7 +162,14 @@ def _check_validation_query(val_query: Any, actual_rows: int, val_result: list | if not val_result: return False for row in val_result: - if not row: + if not row or row[0] is None: + # A None first column means the row is a placeholder the + # adapter synthesized for a FAILED payload or an + # unmaterialized result (PlatformAdapterCursor emits (None,) + # past index 0 without real rows): it certifies nothing, so + # the value check fails. Count modes are mutually exclusive + # with value modes at load time, so no count fallback exists + # here. return False try: scalar = float(row[0]) @@ -194,6 +206,10 @@ def _resolve_validation_sql( return override, None +#: Setup dialects whose connection runs one SQL statement per ``execute`` call. +_SINGLE_STATEMENT_SETUP_DIALECTS = frozenset({"sqlite", "databricks"}) + + class WritePrimitivesBenchmark(TransactionalBenchmarkBase["OperationResult"]): """Write Primitives benchmark implementation. @@ -233,15 +249,17 @@ def __init__( super().__init__(scale_factor, quiet=quiet, **config) self._name = "Write Primitives Benchmark" - self._version = "1.0" + self._version = "2.0" self._description = "Write Primitives benchmark - Testing fundamental write operations using TPC-H schema" - # Setup directories + # Setup directories. normalize_output_dir keeps an orchestrator-resolved + # CloudStagingPath/DatabricksPath handler intact instead of leaving a + # raw cloud URI string for downstream Path(...) calls to stringify. if output_dir is None: # Reuse the canonical TPC-H datagen directory output_dir = get_benchmark_runs_datagen_path("tpch", scale_factor) - self.output_dir = output_dir + self.output_dir = normalize_output_dir(output_dir) # Initialize components self.operations_manager = WriteOperationsManager() @@ -280,13 +298,16 @@ def _acquire_setup_lock( # Create lock table if it doesn't exist (atomic operation) try: - connection.execute(""" + lock_res = connection.execute(""" CREATE TABLE IF NOT EXISTS write_primitives_setup_lock ( lock_name VARCHAR(255) PRIMARY KEY, holder_info VARCHAR(1000), acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + if (err := failed_platform_error(lock_res)) is not None: + self.log_verbose(f"Warning: Could not create lock table: {err}") + return False except Exception as e: self.log_verbose(f"Warning: Could not create lock table: {e}") return False @@ -306,10 +327,19 @@ def _acquire_setup_lock( escaped_lock_name = lock_name.replace("'", "''") escaped_holder_info = holder_info.replace("'", "''") - connection.execute( + ins_res = connection.execute( f"INSERT INTO write_primitives_setup_lock (lock_name, holder_info) " f"VALUES ('{escaped_lock_name}', '{escaped_holder_info}')" ) + if (err := failed_platform_error(ins_res)) is not None: + error_msg = err.lower() + if "unique" in error_msg or "duplicate" in error_msg or "constraint" in error_msg: + # Lock held by another process - wait and retry + time.sleep(0.5) + continue + else: + self.log_verbose(f"Unexpected error acquiring lock: {err}") + return False self.log_verbose(f"Acquired setup lock (waited {elapsed_seconds(start_time):.1f}s)") return True except Exception as e: @@ -359,7 +389,11 @@ def _quote_identifier(self, identifier: str) -> str: """Quote SQL identifier to prevent SQL injection. Uses double quotes (SQL standard) for most dialects. Uses backticks for - StarRocks, which runs in MySQL mode where double-quotes are string literals. + StarRocks, which runs in MySQL mode where double-quotes are string literals, + and for BigQuery, where double quotes also denote string literals. + Uppercases for dialects whose catalogs are uppercase (Snowflake folds + unquoted names to upper; BigQuery is case-sensitive with uppercase + tables), so setup probes resolve the adapter-created tables. Args: identifier: Table, column, or schema name @@ -373,7 +407,7 @@ def _quote_identifier(self, identifier: str) -> str: if self._setup_dialect == "starrocks": escaped = identifier.replace("`", "``") return f"`{escaped}`" - return quote_identifier(identifier) + return quote_identifier_for_dialect(identifier, self._setup_dialect) def _get_effective_write_sql( self, @@ -506,8 +540,7 @@ def _check_staging_table_population(self, write_sql: str, connection: DatabaseCo continue try: quoted = self._quote_identifier(table_name) - result = connection.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone() - row_count = result[0] if result else 0 + row_count = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted}") except Exception: # Table missing entirely (never created) is the same "unavailable" case. row_count = 0 @@ -552,31 +585,10 @@ def _check_bulk_load_file_dependencies(self, operation: Any) -> str | None: return ", ".join(missing) def _table_exists(self, connection: DatabaseConnection, table_name: str) -> bool: - """Check if a table exists in the database. - - Uses a platform-agnostic approach that attempts to query the table - with LIMIT 0, which should work across most SQL databases without - requiring INFORMATION_SCHEMA access. - - Args: - connection: Database connection - table_name: Name of table to check (will be quoted for safety) - - Returns: - True if table exists, False otherwise - - Note: - This method catches exceptions to distinguish between: - - Table doesn't exist (expected, returns False) - - Other errors (logged, returns False for safety) - - Security: - Table name is quoted using _quote_identifier() to prevent SQL injection. - """ - return table_exists(connection, table_name, self.log_verbose) + """Check if a table exists in the database.""" + return table_exists(connection, table_name, self.log_verbose, getattr(self, "_setup_dialect", None)) - @staticmethod - def _scd2_row_hash_expr(acctbal_expr: str) -> str: + def _scd2_row_hash_expr(self, acctbal_expr: str) -> str: """Build the portable SCD2 change-detection fingerprint expression. Concatenates the tracked dimension attributes into a single string so @@ -594,6 +606,11 @@ def _scd2_row_hash_expr(acctbal_expr: str) -> str: Returns: A portable SQL string expression yielding the row fingerprint. + The numeric-to-text CAST spells the target type per setup dialect + (BigQuery has no VARCHAR type); like _date_literal, this spelling + must be applied here because staging population runs directly on + the connection, before the normal operation SQL translation path. + Safety assumptions (verified for TPC-H; revisit if reused elsewhere): - Same-engine comparison only. The dimension and staging ``row_hash`` are both computed by this expression during setup on the SAME @@ -608,7 +625,10 @@ def _scd2_row_hash_expr(acctbal_expr: str) -> str: changed row look unchanged (or vice versa); use a collision- resistant separator or length-prefixed encoding in that case. """ - return f"c_name || '|' || c_address || '|' || CAST({acctbal_expr} AS VARCHAR) || '|' || c_mktsegment" + # BigQuery has no VARCHAR, and Databricks rejects VARCHAR without a length. + dialect = getattr(self, "_setup_dialect", "standard").lower() + text_type = "STRING" if dialect in {"bigquery", "databricks"} else "VARCHAR" + return f"c_name || '|' || c_address || '|' || CAST({acctbal_expr} AS {text_type}) || '|' || c_mktsegment" def _date_literal(self, value: str) -> str: """Return a date literal accepted by the active setup dialect. @@ -716,17 +736,23 @@ def _get_population_sql(self, table_name: str, source_table: str) -> str: def _execute_population_sql(self, connection: DatabaseConnection, sql: str) -> None: """Execute staging population SQL using the active dialect's statement contract. - SQLite's DB-API ``execute`` accepts only one statement, while the SCD2 - stage population is intentionally a three-statement batch. The other - adapters accept the batch as-is, so split only for SQLite and keep the - existing execution path unchanged elsewhere. + SQLite's DB-API ``execute`` and the Databricks SQL connector accept + only one statement per call (Databricks answers a batch with + ``PARSE_SYNTAX_ERROR``), while the SCD2 stage population is + intentionally a three-statement batch. The other adapters accept the + batch as-is, so split only for those dialects and keep the existing + execution path unchanged elsewhere. """ - if self._setup_dialect.lower() == "sqlite": + if self._setup_dialect.lower() in _SINGLE_STATEMENT_SETUP_DIALECTS: for statement in sql.split(";"): if statement.strip(): - connection.execute(statement) + stmt_res = connection.execute(statement) + if (err := failed_platform_error(stmt_res)) is not None: + raise RuntimeError(f"Population SQL statement failed: {err}") return - connection.execute(sql) + res = connection.execute(sql) + if (err := failed_platform_error(res)) is not None: + raise RuntimeError(f"Population SQL failed: {err}") def _populate_staging_tables(self, connection: DatabaseConnection, tables: dict[str, str]) -> dict[str, int]: """Populate staging tables from source tables. @@ -745,8 +771,7 @@ def _populate_staging_tables(self, connection: DatabaseConnection, tables: dict[ # Check if table needs population try: - result = connection.execute(f"SELECT COUNT(*) FROM {quoted_table}").fetchone() - current_count = result[0] if result else 0 + current_count = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted_table}") except Exception: current_count = 0 @@ -754,8 +779,7 @@ def _populate_staging_tables(self, connection: DatabaseConnection, tables: dict[ # Validate source table exists and has data before copying try: quoted_source = self._quote_identifier(source_table) - source_result = connection.execute(f"SELECT COUNT(*) FROM {quoted_source}").fetchone() - source_count = source_result[0] if source_result else 0 + source_count = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted_source}") except Exception as e: # Source table doesn't exist - skip population for optional tables # (see _OPTIONAL_WHEN_SOURCE_MISSING). Minimal test fixtures load @@ -791,8 +815,7 @@ def _populate_staging_tables(self, connection: DatabaseConnection, tables: dict[ populate_sql = self._get_population_sql(table_name, source_table) self._execute_population_sql(connection, populate_sql) - result = connection.execute(f"SELECT COUNT(*) FROM {quoted_table}").fetchone() - status[table_name] = result[0] if result else 0 + status[table_name] = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted_table}") self.log_verbose(f"Populated {table_name} with {status[table_name]} rows") else: # Table already has data @@ -829,7 +852,9 @@ def setup(self, connection: DatabaseConnection, force: bool = False, dialect: st required_tables = ["orders", "lineitem"] for table in required_tables: try: - connection.execute(f"SELECT 1 FROM {table} LIMIT 1") + probe_res = connection.execute(f"SELECT 1 FROM {table} LIMIT 1") + if (err := failed_platform_error(probe_res)) is not None: + raise RuntimeError(f"Source table check failed: {err}") except Exception as e: raise RuntimeError( f"Required TPC-H table '{table}' not found. " @@ -858,10 +883,13 @@ def setup(self, connection: DatabaseConnection, force: bool = False, dialect: st rebuild = force or not self._staging_manifest_matches(connection, required_tables) # Drop existing staging tables when rebuilding (done once before loop) + replace_in_place = rebuild and replaces_tables_in_place(dialect) if rebuild: reason = "force mode" if force else "stale/absent staging manifest" self._drop_legacy_staging_manifests(connection) - for table_name in STAGING_TABLES: + if replace_in_place: + self._invalidate_staging_manifest(connection) + for table_name in [] if replace_in_place else STAGING_TABLES: try: quoted = self._quote_identifier(table_name) connection.execute(f"DROP TABLE IF EXISTS {quoted}") @@ -873,9 +901,15 @@ def setup(self, connection: DatabaseConnection, force: bool = False, dialect: st created_tables = [] for table_name in STAGING_TABLES: table_existed = self._table_exists(connection, table_name) - create_sql = get_create_table_sql(table_name, dialect=dialect, if_not_exists=True) + create_sql = ( + replace_table_sql(get_create_table_sql(table_name, dialect=dialect)) + if replace_in_place + else get_create_table_sql(table_name, dialect=dialect, if_not_exists=True) + ) try: - connection.execute(create_sql) + create_res = connection.execute(create_sql) + if (err := failed_platform_error(create_res)) is not None: + raise RuntimeError(f"Failed to create {table_name}: {err}") if not table_existed: created_tables.append(table_name) self.log_verbose(f"Created {table_name}") @@ -906,8 +940,7 @@ def setup(self, connection: DatabaseConnection, force: bool = False, dialect: st if table_name not in status: try: quoted = self._quote_identifier(table_name) - result = connection.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone() - status[table_name] = result[0] if result else 0 + status[table_name] = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted}") except Exception: status[table_name] = 0 @@ -1052,8 +1085,8 @@ def is_setup(self, connection: DatabaseConnection) -> bool: for table_name in required_tables: quoted = self._quote_identifier(table_name) - result = connection.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone() - if not result or result[0] == 0: + count = fetch_count_probe(connection, f"SELECT COUNT(*) FROM {quoted}") + if count <= 0: return False return self._staging_manifest_matches(connection, ["orders", "lineitem"]) except Exception: @@ -1299,6 +1332,13 @@ def execute_operation( write_start = time.perf_counter() write_result = connection.execute(write_sql) write_duration_ms = (time.perf_counter() - write_start) * 1000 + # Adapters that report failures as a FAILED result payload do not + # raise here: without this check a failed write reads as executed + # and only content-checking validations can catch it (verified + # live on Snowflake, where multi-statement writes no-op'd while + # COUNT(*) validations kept passing). + if (write_error := failed_platform_error(write_result)) is not None: + raise RuntimeError(f"Write SQL failed on platform: {write_error}") rows_affected = self._extract_rows_affected(write_result, operation_id) @@ -1403,7 +1443,26 @@ def _run_operation_validation( continue val_sql = self._replace_placeholders(effective_sql) - val_result = connection.execute(val_sql).fetchall() + val_cursor = connection.execute(val_sql) + # A failed validation SELECT surfaces here as an empty row set + # (adapters materialize placeholder rows for FAILED payloads), + # which vacuous COUNT(*) checks would accept. Fail the validation + # with the adapter-reported error instead. + if (val_error := failed_platform_error(val_cursor)) is not None: + validation_passed = False + validation_results.append( + { + "query_id": val_query.id, + "sql": effective_sql, + "expected_rows": val_query.expected_rows, + "actual_rows": 0, + "passed": False, + "error": val_error, + "sample": [], + } + ) + continue + val_result = val_cursor.fetchall() actual_rows = len(val_result) passed = _check_validation_query(val_query, actual_rows, val_result) validation_passed = validation_passed and passed @@ -1433,7 +1492,9 @@ def _run_operation_cleanup( if operation.cleanup_sql: try: - connection.execute(operation.cleanup_sql) + cleanup_res = connection.execute(operation.cleanup_sql) + if (cleanup_err := failed_platform_error(cleanup_res)) is not None: + raise RuntimeError(cleanup_err) self.log_verbose(f"Executed cleanup SQL for {operation_id}") except Exception as e: cleanup_error = str(e) diff --git a/benchbox/core/write_primitives/catalog/operations.yaml b/benchbox/core/write_primitives/catalog/operations.yaml index 756acde034..3337294bd1 100644 --- a/benchbox/core/write_primitives/catalog/operations.yaml +++ b/benchbox/core/write_primitives/catalog/operations.yaml @@ -3,35 +3,35 @@ # Licensed under the MIT License # NOTE: Compact flow-style YAML; parsed catalog data must remain identical. -version: 1 +version: 2 operations: - {"id": "insert_single_row", "description": "Tests single row INSERT performance with all columns specified", "write_sql": "INSERT INTO insert_ops_lineitem\nVALUES (\n 9000001, 1, 1, 1,\n 10.0, 1000.0, 0.05, 0.02,\n 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'test insert'\n)\n", "validation_queries": [{"id": "verify_values", "sql": "SELECT l_quantity, l_extendedprice, l_discount\nFROM insert_ops_lineitem\nWHERE l_orderkey = 9000001\n", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey = 9000001\n", "expected_rows_affected": 1} - {"id": "insert_batch_values_10", "description": "Tests small batch INSERT using VALUES clause with 10 rows", "write_sql": "INSERT INTO insert_ops_lineitem VALUES\n (9000010, 1, 1, 1, 10.0, 1000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 1'),\n (9000011, 2, 2, 1, 20.0, 2000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 2'),\n (9000012, 3, 3, 1, 30.0, 3000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 3'),\n (9000013, 4, 4, 1, 40.0, 4000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 4'),\n (9000014, 5, 5, 1, 50.0, 5000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 5'),\n (9000015, 6, 6, 1, 60.0, 6000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 6'),\n (9000016, 7, 7, 1, 70.0, 7000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 7'),\n (9000017, 8, 8, 1, 80.0, 8000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 8'),\n (9000018, 9, 9, 1, 90.0, 9000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 9'),\n (9000019, 10, 10, 1, 100.0, 10000.0, 0.05, 0.02, 'N', 'O', DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20', 'DELIVER IN PERSON', 'TRUCK', 'batch 10')\n", "validation_queries": [{"id": "verify_batch", "sql": "SELECT l_orderkey, l_quantity FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9000010 AND 9000019 ORDER BY l_orderkey", "expected_rows": 10}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9000010 AND 9000019\n", "expected_rows_affected": 10} - {"id": "insert_select_simple", "description": "Tests INSERT INTO...SELECT with simple WHERE filter", "write_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 1 AND 10;\nINSERT INTO insert_ops_lineitem\nSELECT * FROM lineitem\nWHERE l_orderkey BETWEEN 1 AND 10\n", "validation_queries": [{"id": "verify_insert_select", "sql": "SELECT l_orderkey, l_linenumber\nFROM insert_ops_lineitem\nWHERE l_orderkey BETWEEN 1 AND 10\nORDER BY l_orderkey, l_linenumber\n", "expected_rows": null, "expected_rows_min": 1, "expected_rows_max": 200}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"starrocks": null}} -- {"id": "insert_batch_values_100", "description": "Tests medium batch INSERT using VALUES clause with 100 rows to measure batch overhead", "write_sql": "INSERT INTO insert_ops_lineitem\nSELECT 9000100 + n, 1, 1, 1, 10.0 * n, 1000.0 * n, 0.05, 0.02, 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'batch_' || CAST(n AS VARCHAR)\nFROM (SELECT unnest(generate_series(0, 99)) AS n) t\n", "validation_queries": [{"id": "verify_batch_100", "sql": "SELECT COUNT(*) as cnt FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9000100 AND 9000199", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9000100 AND 9000199\n", "expected_rows_affected": 100, "platform_overrides": {"starrocks": null, "datafusion": "INSERT INTO insert_ops_lineitem\nSELECT 9000100 + n AS l_orderkey, 1 AS l_partkey, 1 AS l_suppkey, 1 AS l_linenumber, 10.0 * n AS l_quantity, 1000.0 * n AS l_extendedprice, 0.05 AS l_discount, 0.02 AS l_tax, 'N' AS l_returnflag, 'O' AS l_linestatus,\n DATE '1998-01-01' AS l_shipdate, DATE '1998-01-15' AS l_commitdate, DATE '1998-01-20' AS l_receiptdate,\n 'DELIVER IN PERSON' AS l_shipinstruct, 'TRUCK' AS l_shipmode, 'batch_' || CAST(n AS VARCHAR) AS l_comment\nFROM (SELECT unnest(generate_series(0, 99)) AS n) t\n"}} -- {"id": "insert_batch_values_1000", "description": "Tests large batch INSERT with 1000 rows to measure large batch processing and memory overhead", "write_sql": "INSERT INTO insert_ops_lineitem\nSELECT 9001000 + n, 1, 1, 1, 10.0 * n, 1000.0 * n, 0.05, 0.02, 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'large_batch_' || CAST(n AS VARCHAR)\nFROM (SELECT unnest(generate_series(0, 999)) AS n) t\n", "validation_queries": [{"id": "verify_batch_1000", "sql": "SELECT COUNT(*) as cnt FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9001000 AND 9001999", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9001000 AND 9001999\n", "expected_rows_affected": 1000, "platform_overrides": {"starrocks": null, "datafusion": "INSERT INTO insert_ops_lineitem\nSELECT 9001000 + n AS l_orderkey, 1 AS l_partkey, 1 AS l_suppkey, 1 AS l_linenumber, 10.0 * n AS l_quantity, 1000.0 * n AS l_extendedprice, 0.05 AS l_discount, 0.02 AS l_tax, 'N' AS l_returnflag, 'O' AS l_linestatus,\n DATE '1998-01-01' AS l_shipdate, DATE '1998-01-15' AS l_commitdate, DATE '1998-01-20' AS l_receiptdate,\n 'DELIVER IN PERSON' AS l_shipinstruct, 'TRUCK' AS l_shipmode, 'large_batch_' || CAST(n AS VARCHAR) AS l_comment\nFROM (SELECT unnest(generate_series(0, 999)) AS n) t\n"}} +- {"id": "insert_batch_values_100", "description": "Tests medium batch INSERT using VALUES clause with 100 rows to measure batch overhead", "write_sql": "INSERT INTO insert_ops_lineitem\nSELECT 9000100 + n, 1, 1, 1, CAST(10.0 * n AS DECIMAL(15,2)), CAST(1000.0 * n AS DECIMAL(15,2)), CAST(0.05 AS DECIMAL(15,2)), CAST(0.02 AS DECIMAL(15,2)), 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'batch_' || CAST(n AS VARCHAR)\nFROM (SELECT unnest(generate_series(0, 99)) AS n) t\n", "validation_queries": [{"id": "verify_batch_100", "sql": "SELECT COUNT(*) as cnt FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9000100 AND 9000199", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9000100 AND 9000199\n", "expected_rows_affected": 100, "platform_overrides": {"databricks": "INSERT INTO insert_ops_lineitem\nSELECT 9000100 + n, 1, 1, 1, CAST(10.0 * n AS DECIMAL(15,2)), CAST(1000.0 * n AS DECIMAL(15,2)), CAST(0.05 AS DECIMAL(15,2)), CAST(0.02 AS DECIMAL(15,2)), 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'batch_' || CAST(n AS VARCHAR)\nFROM (SELECT EXPLODE(SEQUENCE(0, 99)) AS n) t\n", "bigquery": "INSERT INTO insert_ops_lineitem\nSELECT 9000100 + n, 1, 1, 1, CAST(10.0 * n AS NUMERIC), CAST(1000.0 * n AS NUMERIC), CAST(0.05 AS NUMERIC), CAST(0.02 AS NUMERIC), 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'batch_' || CAST(n AS STRING)\nFROM UNNEST(GENERATE_ARRAY(0, 99)) AS n\n", "starrocks": null, "datafusion": "INSERT INTO insert_ops_lineitem\nSELECT 9000100 + n AS l_orderkey, 1 AS l_partkey, 1 AS l_suppkey, 1 AS l_linenumber, 10.0 * n AS l_quantity, 1000.0 * n AS l_extendedprice, 0.05 AS l_discount, 0.02 AS l_tax, 'N' AS l_returnflag, 'O' AS l_linestatus,\n DATE '1998-01-01' AS l_shipdate, DATE '1998-01-15' AS l_commitdate, DATE '1998-01-20' AS l_receiptdate,\n 'DELIVER IN PERSON' AS l_shipinstruct, 'TRUCK' AS l_shipmode, 'batch_' || CAST(n AS VARCHAR) AS l_comment\nFROM (SELECT unnest(generate_series(0, 99)) AS n) t\n", "snowflake": "INSERT INTO insert_ops_lineitem\nSELECT 9000100 + n, 1, 1, 1, CAST(10.0 * n AS DECIMAL(15,2)), CAST(1000.0 * n AS DECIMAL(15,2)), CAST(0.05 AS DECIMAL(15,2)), CAST(0.02 AS DECIMAL(15,2)), 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'batch_' || CAST(n AS VARCHAR)\nFROM (SELECT ROW_NUMBER() OVER (ORDER BY SEQ4()) - 1 AS n FROM TABLE(GENERATOR(ROWCOUNT => 100))) t\n"}} +- {"id": "insert_batch_values_1000", "description": "Tests large batch INSERT with 1000 rows to measure large batch processing and memory overhead", "write_sql": "INSERT INTO insert_ops_lineitem\nSELECT 9001000 + n, 1, 1, 1, CAST(10.0 * n AS DECIMAL(15,2)), CAST(1000.0 * n AS DECIMAL(15,2)), CAST(0.05 AS DECIMAL(15,2)), CAST(0.02 AS DECIMAL(15,2)), 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'large_batch_' || CAST(n AS VARCHAR)\nFROM (SELECT unnest(generate_series(0, 999)) AS n) t\n", "validation_queries": [{"id": "verify_batch_1000", "sql": "SELECT COUNT(*) as cnt FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9001000 AND 9001999", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey BETWEEN 9001000 AND 9001999\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": "INSERT INTO insert_ops_lineitem\nSELECT 9001000 + n, 1, 1, 1, CAST(10.0 * n AS DECIMAL(15,2)), CAST(1000.0 * n AS DECIMAL(15,2)), CAST(0.05 AS DECIMAL(15,2)), CAST(0.02 AS DECIMAL(15,2)), 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'large_batch_' || CAST(n AS VARCHAR)\nFROM (SELECT EXPLODE(SEQUENCE(0, 999)) AS n) t\n", "bigquery": "INSERT INTO insert_ops_lineitem\nSELECT 9001000 + n, 1, 1, 1, CAST(10.0 * n AS NUMERIC), CAST(1000.0 * n AS NUMERIC), CAST(0.05 AS NUMERIC), CAST(0.02 AS NUMERIC), 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'large_batch_' || CAST(n AS STRING)\nFROM UNNEST(GENERATE_ARRAY(0, 999)) AS n\n", "starrocks": null, "datafusion": "INSERT INTO insert_ops_lineitem\nSELECT 9001000 + n AS l_orderkey, 1 AS l_partkey, 1 AS l_suppkey, 1 AS l_linenumber, 10.0 * n AS l_quantity, 1000.0 * n AS l_extendedprice, 0.05 AS l_discount, 0.02 AS l_tax, 'N' AS l_returnflag, 'O' AS l_linestatus,\n DATE '1998-01-01' AS l_shipdate, DATE '1998-01-15' AS l_commitdate, DATE '1998-01-20' AS l_receiptdate,\n 'DELIVER IN PERSON' AS l_shipinstruct, 'TRUCK' AS l_shipmode, 'large_batch_' || CAST(n AS VARCHAR) AS l_comment\nFROM (SELECT unnest(generate_series(0, 999)) AS n) t\n", "snowflake": "INSERT INTO insert_ops_lineitem\nSELECT 9001000 + n, 1, 1, 1, CAST(10.0 * n AS DECIMAL(15,2)), CAST(1000.0 * n AS DECIMAL(15,2)), CAST(0.05 AS DECIMAL(15,2)), CAST(0.02 AS DECIMAL(15,2)), 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'large_batch_' || CAST(n AS VARCHAR)\nFROM (SELECT ROW_NUMBER() OVER (ORDER BY SEQ4()) - 1 AS n FROM TABLE(GENERATOR(ROWCOUNT => 1000))) t\n"}} - {"id": "insert_select_with_join", "description": "Tests INSERT INTO...SELECT with JOIN to measure join overhead during data insertion", "write_sql": "INSERT INTO insert_ops_orders_summary (o_orderkey, o_custkey, o_orderdate, o_totalprice, customer_name)\nSELECT o.o_orderkey, o.o_custkey, o.o_orderdate, o.o_totalprice, c.c_name\nFROM orders o\nJOIN customer c ON o.o_custkey = c.c_custkey\nWHERE o.o_orderkey BETWEEN 1 AND 100\n", "validation_queries": [{"id": "verify_joined_insert", "sql": "SELECT COUNT(*) as cnt\nFROM insert_ops_orders_summary\nWHERE o_orderkey BETWEEN 1 AND 100\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM insert_ops_orders_summary WHERE o_orderkey BETWEEN 1 AND 100\n"} - {"id": "insert_select_aggregated", "description": "Tests INSERT INTO...SELECT with GROUP BY aggregation to measure aggregation overhead", "write_sql": "INSERT INTO insert_ops_orders_summary (o_custkey, o_totalprice, order_count)\nSELECT o_custkey, SUM(o_totalprice), COUNT(*)\nFROM orders\nWHERE o_orderkey <= 1000\nGROUP BY o_custkey\n", "validation_queries": [{"id": "verify_aggregated_insert", "sql": "SELECT COUNT(*) as customer_count, SUM(order_count) as total_orders\nFROM insert_ops_orders_summary\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM insert_ops_orders_summary\n"} - {"id": "insert_select_from_multiple", "description": "Tests INSERT INTO...SELECT from 3 plus tables with multiple JOINs to measure complex query overhead", "write_sql": "INSERT INTO insert_ops_lineitem_enriched\n (l_orderkey, l_partkey, l_suppkey, l_quantity, l_extendedprice,\n o_orderdate, c_name, s_name, p_name)\nSELECT l.l_orderkey, l.l_partkey, l.l_suppkey, l.l_quantity, l.l_extendedprice,\n o.o_orderdate, c.c_name, s.s_name, p.p_name\nFROM lineitem l\nJOIN orders o ON l.l_orderkey = o.o_orderkey\nJOIN customer c ON o.o_custkey = c.c_custkey\nJOIN supplier s ON l.l_suppkey = s.s_suppkey\nJOIN part p ON l.l_partkey = p.p_partkey\nWHERE l.l_orderkey BETWEEN 1 AND 50\n", "validation_queries": [{"id": "verify_multi_join_insert", "sql": "SELECT COUNT(*) as cnt\nFROM insert_ops_lineitem_enriched\nWHERE l_orderkey BETWEEN 1 AND 50\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM insert_ops_lineitem_enriched WHERE l_orderkey BETWEEN 1 AND 50\n"} - {"id": "insert_with_default_values", "description": "Tests INSERT with DEFAULT keyword to measure default value processing overhead", "write_sql": "INSERT INTO write_ops_log (log_id, operation_id, operation_category, timestamp, rows_affected, duration_ms, success)\nVALUES (1, 'test_insert', 'insert', CURRENT_TIMESTAMP, 0, 0.0, true)\n", "validation_queries": [{"id": "verify_default_insert", "sql": "SELECT COUNT(*) as cnt\nFROM write_ops_log\nWHERE operation_id = 'test_insert' AND operation_category = 'insert'\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM write_ops_log WHERE operation_id = 'test_insert' AND operation_category = 'insert'\n"} - {"id": "insert_select_union", "description": "Tests INSERT INTO...SELECT with UNION to measure set operation overhead during insertion", "write_sql": "DELETE FROM insert_ops_orders WHERE o_orderkey BETWEEN 1 AND 50;\nINSERT INTO insert_ops_orders\nSELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 25\nUNION ALL\nSELECT * FROM orders WHERE o_orderkey BETWEEN 26 AND 50\n", "validation_queries": [{"id": "verify_union_insert", "sql": "SELECT COUNT(*) as cnt\nFROM insert_ops_orders\nWHERE o_orderkey BETWEEN 1 AND 50\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM insert_ops_orders WHERE o_orderkey BETWEEN 1 AND 50\n"} -- {"id": "insert_on_conflict_ignore", "description": "Tests INSERT with conflict handling using ON CONFLICT DO NOTHING to measure constraint checking overhead", "write_sql": "INSERT INTO insert_ops_orders\nSELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\nON CONFLICT (o_orderkey) DO NOTHING\n", "validation_queries": [{"id": "verify_conflict_handling", "sql": "SELECT COUNT(*) as cnt\nFROM insert_ops_orders\nWHERE o_orderkey BETWEEN 1 AND 10\n", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_orders WHERE o_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"duckdb": "INSERT OR IGNORE INTO insert_ops_orders\nSELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n", "sqlite": "INSERT OR IGNORE INTO insert_ops_orders\nSELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n", "datafusion": null, "starrocks": null, "ducklake": null}} -- {"id": "insert_returning_clause", "description": "Tests INSERT...RETURNING to measure overhead of returning inserted row data", "write_sql": "INSERT INTO insert_ops_lineitem\nVALUES (9000500, 1, 1, 1, 10.0, 1000.0, 0.05, 0.02, 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'returning_test')\nRETURNING l_orderkey, l_partkey, l_quantity\n", "validation_queries": [{"id": "verify_returning_insert", "sql": "SELECT l_orderkey, l_quantity\nFROM insert_ops_lineitem\nWHERE l_orderkey = 9000500\n", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey = 9000500\n", "expected_rows_affected": 1, "platform_overrides": {"mysql": null, "bigquery": null, "datafusion": null, "starrocks": null, "ducklake": null}} +- {"id": "insert_on_conflict_ignore", "description": "Tests INSERT with conflict handling using ON CONFLICT DO NOTHING to measure constraint checking overhead", "write_sql": "INSERT INTO insert_ops_orders\nSELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\nON CONFLICT (o_orderkey) DO NOTHING\n", "validation_queries": [{"id": "verify_conflict_handling", "sql": "SELECT COUNT(*) as cnt\nFROM insert_ops_orders\nWHERE o_orderkey BETWEEN 1 AND 10\n", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_orders WHERE o_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"databricks": "MERGE INTO insert_ops_orders AS t\nUSING (SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10) AS s\nON t.o_orderkey = s.o_orderkey\nWHEN NOT MATCHED THEN INSERT *\n", "bigquery": "MERGE INTO insert_ops_orders AS t\nUSING (SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10) AS s\nON t.o_orderkey = s.o_orderkey\nWHEN NOT MATCHED THEN INSERT ROW\n", "duckdb": "INSERT OR IGNORE INTO insert_ops_orders\nSELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n", "sqlite": "INSERT OR IGNORE INTO insert_ops_orders\nSELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n", "datafusion": null, "starrocks": null, "ducklake": null, "snowflake": "MERGE INTO insert_ops_orders AS t\nUSING (SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10) AS s\nON t.o_orderkey = s.o_orderkey\nWHEN NOT MATCHED THEN INSERT VALUES (s.o_orderkey, s.o_custkey, s.o_orderstatus, s.o_totalprice, s.o_orderdate, s.o_orderpriority, s.o_clerk, s.o_shippriority, s.o_comment)\n"}} +- {"id": "insert_returning_clause", "description": "Tests INSERT...RETURNING to measure overhead of returning inserted row data", "write_sql": "INSERT INTO insert_ops_lineitem\nVALUES (9000500, 1, 1, 1, 10.0, 1000.0, 0.05, 0.02, 'N', 'O',\n DATE '1998-01-01', DATE '1998-01-15', DATE '1998-01-20',\n 'DELIVER IN PERSON', 'TRUCK', 'returning_test')\nRETURNING l_orderkey, l_partkey, l_quantity\n", "validation_queries": [{"id": "verify_returning_insert", "sql": "SELECT l_orderkey, l_quantity\nFROM insert_ops_lineitem\nWHERE l_orderkey = 9000500\n", "expected_rows": 1}], "cleanup_sql": "DELETE FROM insert_ops_lineitem WHERE l_orderkey = 9000500\n", "expected_rows_affected": 1, "platform_overrides": {"databricks": null, "mysql": null, "bigquery": null, "datafusion": null, "starrocks": null, "ducklake": null, "snowflake": null}} - {"id": "update_single_row_pk", "description": "Tests single row UPDATE by primary key to measure index lookup plus update cost", "write_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice * 1.1,\n o_comment = 'updated_single'\nWHERE o_orderkey = (SELECT MIN(o_orderkey) FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_update", "sql": "SELECT COUNT(*) as cnt\nFROM update_ops_orders\nWHERE o_comment = 'updated_single'\n", "expected_rows": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice / 1.1,\n o_comment = ''\nWHERE o_comment = 'updated_single'\n", "expected_rows_affected": 1, "platform_overrides": {"datafusion": null}} - {"id": "update_selective_10pct", "description": "Tests UPDATE affecting approximately 10 percent of table rows to measure selective update performance", "write_sql": "UPDATE update_ops_orders\nSET o_comment = 'selective_10pct'\nWHERE o_orderkey <= (\n SELECT CAST(MAX(o_orderkey) * 0.1 AS INTEGER)\n FROM update_ops_orders\n)\n", "validation_queries": [{"id": "count_updated", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'selective_10pct'"}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_comment = ''\nWHERE o_comment = 'selective_10pct'\n", "platform_overrides": {"datafusion": null}} - {"id": "update_with_subquery", "description": "Tests UPDATE with correlated subquery to measure subquery execution cost per row", "write_sql": "UPDATE update_ops_orders\nSET o_totalprice = (\n SELECT SUM(l_extendedprice * (1 - l_discount))\n FROM lineitem\n WHERE l_orderkey = update_ops_orders.o_orderkey\n)\nWHERE EXISTS (\n SELECT 1 FROM lineitem WHERE l_orderkey = update_ops_orders.o_orderkey\n)\nAND o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_calculation", "sql": "SELECT COUNT(*) as cnt\nFROM update_ops_orders o\nWHERE o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n"}], "cleanup_sql": "-- Recalculate to restore\nUPDATE update_ops_orders\nSET o_totalprice = (\n SELECT SUM(l_extendedprice * (1 - l_discount))\n FROM lineitem\n WHERE l_orderkey = update_ops_orders.o_orderkey\n)\nWHERE o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "platform_overrides": {"datafusion": null}} - {"id": "update_bulk_50pct", "description": "Tests UPDATE affecting approximately 50 percent of table rows to measure large-scale update performance", "write_sql": "UPDATE update_ops_orders\nSET o_comment = 'bulk_50pct_update'\nWHERE o_orderkey <= (\n SELECT CAST(MAX(o_orderkey) * 0.5 AS INTEGER)\n FROM update_ops_orders\n)\n", "validation_queries": [{"id": "count_updated_50pct", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'bulk_50pct_update'"}], "cleanup_sql": "UPDATE update_ops_orders SET o_comment = '' WHERE o_comment = 'bulk_50pct_update'\n", "platform_overrides": {"datafusion": null}} - {"id": "update_bulk_all", "description": "Tests UPDATE of entire table to measure full table scan and update overhead", "write_sql": "UPDATE update_ops_orders\nSET o_comment = 'bulk_all_update'\n", "validation_queries": [{"id": "verify_all_updated", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'bulk_all_update'"}], "cleanup_sql": "UPDATE update_ops_orders SET o_comment = ''\n", "platform_overrides": {"datafusion": null}} -- {"id": "update_with_join", "description": "Tests UPDATE with JOIN syntax to measure join overhead during updates", "write_sql": "UPDATE update_ops_orders\nSET o_comment = 'joined_update'\nFROM customer\nWHERE update_ops_orders.o_custkey = customer.c_custkey\n AND customer.c_mktsegment = 'BUILDING'\n AND update_ops_orders.o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_joined_update", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'joined_update'", "expected_rows_min": 0}], "cleanup_sql": "UPDATE update_ops_orders SET o_comment = '' WHERE o_comment = 'joined_update'\n", "platform_overrides": {"mysql": "UPDATE update_ops_orders\nJOIN customer ON update_ops_orders.o_custkey = customer.c_custkey\nSET update_ops_orders.o_comment = 'joined_update'\nWHERE customer.c_mktsegment = 'BUILDING'\n AND update_ops_orders.o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "datafusion": null}} +- {"id": "update_with_join", "description": "Tests UPDATE with JOIN syntax to measure join overhead during updates", "write_sql": "UPDATE update_ops_orders\nSET o_comment = 'joined_update'\nFROM customer\nWHERE update_ops_orders.o_custkey = customer.c_custkey\n AND customer.c_mktsegment = 'BUILDING'\n AND update_ops_orders.o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_joined_update", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'joined_update'", "expected_rows_min": 0}], "cleanup_sql": "UPDATE update_ops_orders SET o_comment = '' WHERE o_comment = 'joined_update'\n", "platform_overrides": {"databricks": "UPDATE update_ops_orders\nSET o_comment = 'joined_update'\nWHERE EXISTS (\n SELECT 1 FROM customer\n WHERE customer.c_custkey = update_ops_orders.o_custkey\n AND customer.c_mktsegment = 'BUILDING'\n)\nAND o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "mysql": "UPDATE update_ops_orders\nJOIN customer ON update_ops_orders.o_custkey = customer.c_custkey\nSET update_ops_orders.o_comment = 'joined_update'\nWHERE customer.c_mktsegment = 'BUILDING'\n AND update_ops_orders.o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "datafusion": null}} - {"id": "update_from_select", "description": "Tests UPDATE...FROM...WHERE EXISTS to measure correlated EXISTS overhead", "write_sql": "UPDATE update_ops_orders\nSET o_comment = 'exists_update'\nWHERE EXISTS (\n SELECT 1 FROM lineitem\n WHERE lineitem.l_orderkey = update_ops_orders.o_orderkey\n AND lineitem.l_quantity > 40\n)\nAND o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_exists_update", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'exists_update'", "expected_rows_min": 0}], "cleanup_sql": "UPDATE update_ops_orders SET o_comment = '' WHERE o_comment = 'exists_update'\n", "platform_overrides": {"datafusion": null}} - {"id": "update_set_multiple_columns", "description": "Tests UPDATE of 5 plus columns to measure multi-column update overhead", "write_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice * 1.05,\n o_orderpriority = '1-URGENT',\n o_comment = 'multi_col_update',\n o_shippriority = 1,\n o_clerk = 'Clerk#000000001'\nWHERE o_orderkey = (SELECT MIN(o_orderkey) FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_multi_column", "sql": "SELECT COUNT(*) as cnt\nFROM update_ops_orders\nWHERE o_comment = 'multi_col_update'\n", "expected_rows": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice / 1.05,\n o_orderpriority = '5-LOW',\n o_comment = '',\n o_shippriority = 0,\n o_clerk = 'Clerk#000000000'\nWHERE o_comment = 'multi_col_update'\n", "expected_rows_affected": 1, "platform_overrides": {"datafusion": null}} - {"id": "update_with_case_expression", "description": "Tests UPDATE using CASE WHEN expression to measure conditional logic overhead", "write_sql": "UPDATE update_ops_orders\nSET o_orderpriority = CASE\n WHEN o_totalprice < 100000 THEN '5-LOW'\n WHEN o_totalprice < 200000 THEN '4-NOT SPECIFIED'\n WHEN o_totalprice < 300000 THEN '3-MEDIUM'\n WHEN o_totalprice < 400000 THEN '2-HIGH'\n ELSE '1-URGENT'\nEND,\no_comment = 'case_update'\nWHERE o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_case_update", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'case_update'", "expected_rows_min": 1}], "cleanup_sql": "UPDATE update_ops_orders SET o_orderpriority = '5-LOW', o_comment = '' WHERE o_comment = 'case_update'\n", "platform_overrides": {"datafusion": null}} - {"id": "update_computed_column", "description": "Tests UPDATE with mathematical expressions to measure computation overhead", "write_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice * 1.1 + 50.0 - (o_totalprice * 0.02),\n o_comment = 'computed_update'\nWHERE o_orderkey BETWEEN (SELECT MIN(o_orderkey) FROM update_ops_orders) AND (SELECT MIN(o_orderkey) + 50 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_computed", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'computed_update'", "expected_rows_min": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_totalprice = (o_totalprice + (o_totalprice * 0.02) - 50.0) / 1.1,\n o_comment = ''\nWHERE o_comment = 'computed_update'\n", "platform_overrides": {"datafusion": null}} - {"id": "update_string_manipulation", "description": "Tests UPDATE with string functions CONCAT SUBSTRING to measure string processing overhead", "write_sql": "UPDATE update_ops_orders\nSET o_comment = CONCAT('Updated:', SUBSTRING(o_orderpriority, 1, 5), '|', CAST(o_orderkey AS VARCHAR))\nWHERE o_orderkey BETWEEN (SELECT MIN(o_orderkey) FROM update_ops_orders) AND (SELECT MIN(o_orderkey) + 50 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_string_ops", "sql": "SELECT COUNT(*) as cnt\nFROM update_ops_orders\nWHERE o_comment LIKE 'Updated:%'\n", "expected_rows_min": 1}], "cleanup_sql": "UPDATE update_ops_orders SET o_comment = '' WHERE o_comment LIKE 'Updated:%'\n", "platform_overrides": {"datafusion": null}} -- {"id": "update_date_arithmetic", "description": "Tests UPDATE with date calculations to measure temporal operation overhead", "write_sql": "UPDATE update_ops_orders\nSET o_orderdate = o_orderdate + INTERVAL '7' DAY,\n o_comment = 'date_update'\nWHERE o_orderkey BETWEEN (SELECT MIN(o_orderkey) FROM update_ops_orders) AND (SELECT MIN(o_orderkey) + 50 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_date_ops", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'date_update'", "expected_rows_min": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_orderdate = o_orderdate - INTERVAL '7' DAY,\n o_comment = ''\nWHERE o_comment = 'date_update'\n", "platform_overrides": {"datafusion": null}} +- {"id": "update_date_arithmetic", "description": "Tests UPDATE with date calculations to measure temporal operation overhead", "write_sql": "UPDATE update_ops_orders\nSET o_orderdate = o_orderdate + INTERVAL '7' DAY,\n o_comment = 'date_update'\nWHERE o_orderkey BETWEEN (SELECT MIN(o_orderkey) FROM update_ops_orders) AND (SELECT MIN(o_orderkey) + 50 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_date_ops", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'date_update'", "expected_rows_min": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_orderdate = o_orderdate - INTERVAL '7' DAY,\n o_comment = ''\nWHERE o_comment = 'date_update'\n", "platform_overrides": {"bigquery": null, "datafusion": null}} - {"id": "update_conditional_multi_column", "description": "Tests complex conditional UPDATE with multiple column updates to measure combined overhead", "write_sql": "UPDATE update_ops_orders\nSET o_totalprice = CASE\n WHEN o_orderpriority = '1-URGENT' THEN o_totalprice * 1.2\n WHEN o_orderpriority = '2-HIGH' THEN o_totalprice * 1.1\n ELSE o_totalprice\n END,\n o_orderpriority = CASE\n WHEN o_totalprice > 250000 THEN '1-URGENT'\n ELSE o_orderpriority\n END,\n o_comment = 'complex_conditional'\nWHERE o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_complex_conditional", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'complex_conditional'", "expected_rows_min": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_totalprice = CASE\n WHEN o_orderpriority = '1-URGENT' THEN o_totalprice / 1.2\n WHEN o_orderpriority = '2-HIGH' THEN o_totalprice / 1.1\n ELSE o_totalprice\n END,\n o_orderpriority = '5-LOW',\n o_comment = ''\nWHERE o_comment = 'complex_conditional'\n", "platform_overrides": {"datafusion": null}} - {"id": "update_with_aggregate", "description": "Tests UPDATE using aggregated subquery to measure aggregation overhead per row", "write_sql": "UPDATE update_ops_orders\nSET o_totalprice = (\n SELECT AVG(l_extendedprice)\n FROM lineitem\n WHERE l_orderkey = update_ops_orders.o_orderkey\n),\no_comment = 'agg_update'\nWHERE EXISTS (SELECT 1 FROM lineitem WHERE l_orderkey = update_ops_orders.o_orderkey)\n AND o_orderkey <= (SELECT MIN(o_orderkey) + 50 FROM update_ops_orders)\n", "validation_queries": [{"id": "verify_agg_update", "sql": "SELECT COUNT(*) as cnt FROM update_ops_orders WHERE o_comment = 'agg_update'", "expected_rows_min": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_totalprice = (\n SELECT SUM(l_extendedprice * (1 - l_discount))\n FROM lineitem\n WHERE l_orderkey = update_ops_orders.o_orderkey\n),\no_comment = ''\nWHERE o_comment = 'agg_update'\n", "platform_overrides": {"datafusion": null}} -- {"id": "update_returning", "description": "Tests UPDATE...RETURNING to measure overhead of returning updated row data", "write_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice * 1.15,\n o_comment = 'returning_update'\nWHERE o_orderkey = (SELECT MIN(o_orderkey) FROM update_ops_orders)\nRETURNING o_orderkey, o_totalprice, o_comment\n", "validation_queries": [{"id": "verify_returning_update", "sql": "SELECT COUNT(*) as cnt\nFROM update_ops_orders\nWHERE o_comment = 'returning_update'\n", "expected_rows": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice / 1.15,\n o_comment = ''\nWHERE o_comment = 'returning_update'\n", "expected_rows_affected": 1, "platform_overrides": {"mysql": null, "bigquery": null, "datafusion": null, "starrocks": null, "ducklake": null}} +- {"id": "update_returning", "description": "Tests UPDATE...RETURNING to measure overhead of returning updated row data", "write_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice * 1.15,\n o_comment = 'returning_update'\nWHERE o_orderkey = (SELECT MIN(o_orderkey) FROM update_ops_orders)\nRETURNING o_orderkey, o_totalprice, o_comment\n", "validation_queries": [{"id": "verify_returning_update", "sql": "SELECT COUNT(*) as cnt\nFROM update_ops_orders\nWHERE o_comment = 'returning_update'\n", "expected_rows": 1}], "cleanup_sql": "UPDATE update_ops_orders\nSET o_totalprice = o_totalprice / 1.15,\n o_comment = ''\nWHERE o_comment = 'returning_update'\n", "expected_rows_affected": 1, "platform_overrides": {"databricks": null, "mysql": null, "bigquery": null, "datafusion": null, "starrocks": null, "ducklake": null, "snowflake": null}} - {"id": "delete_single_row_pk", "description": "Tests single row DELETE by primary key to measure index-based deletion", "write_sql": "DELETE FROM delete_ops_orders\nWHERE o_orderkey = (SELECT MAX(o_orderkey) FROM delete_ops_orders)\n", "validation_queries": [{"id": "verify_table_integrity", "sql": "-- Validate table still accessible and structure is intact after DELETE\n-- Note: Full validation of DELETE correctness requires pre-write state - future enhancement\nSELECT o_orderkey, o_custkey\nFROM delete_ops_orders\nLIMIT 1\n", "expected_rows": null, "expected_rows_min": 0, "expected_rows_max": 1}], "expected_rows_affected": 1, "platform_overrides": {"datafusion": null}} - {"id": "delete_selective_10pct", "description": "Tests DELETE affecting approximately 10 percent of rows to measure selective deletion performance", "write_sql": "DELETE FROM delete_ops_orders\nWHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.9 AS INTEGER)\n FROM delete_ops_orders\n)\n", "validation_queries": [{"id": "verify_table_integrity", "sql": "-- Validate table still accessible after bulk DELETE\n-- Note: Full validation requires pre-write state - future enhancement\nSELECT COUNT(*) as remaining_count\nFROM delete_ops_orders\n", "expected_rows": null, "expected_rows_min": 0, "expected_rows_max": 1}], "platform_overrides": {"datafusion": null}} - {"id": "delete_with_subquery", "description": "Tests DELETE with WHERE...IN subquery to measure subquery evaluation overhead", "write_sql": "DELETE FROM delete_ops_orders\nWHERE o_orderkey IN (\n SELECT o_orderkey\n FROM delete_ops_orders\n WHERE o_orderpriority = '1-URGENT'\n LIMIT 10\n)\n", "validation_queries": [{"id": "verify_table_integrity", "sql": "SELECT COUNT(*) as cnt FROM delete_ops_orders", "expected_rows": 1, "expected_rows_min": 0}], "platform_overrides": {"datafusion": null}} @@ -41,103 +41,103 @@ operations: - {"id": "delete_bulk_75pct", "description": "Tests DELETE affecting approximately 75 percent of rows to measure very large bulk deletion", "write_sql": "DELETE FROM delete_ops_orders\nWHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.25 AS INTEGER)\n FROM delete_ops_orders\n)\n", "validation_queries": [{"id": "verify_75pct_delete", "sql": "SELECT COUNT(*) as cnt FROM delete_ops_orders", "expected_rows": 1, "expected_rows_min": 0}], "platform_overrides": {"datafusion": null}} - {"id": "delete_with_not_exists", "description": "Tests DELETE using NOT EXISTS to measure anti-join deletion performance", "write_sql": "DELETE FROM delete_ops_orders\nWHERE NOT EXISTS (\n SELECT 1 FROM lineitem\n WHERE lineitem.l_orderkey = delete_ops_orders.o_orderkey\n)\nAND delete_ops_orders.o_orderkey <= (SELECT MIN(o_orderkey) + 50 FROM delete_ops_orders)\n", "validation_queries": [{"id": "verify_not_exists_delete", "sql": "SELECT COUNT(*) as cnt FROM delete_ops_orders", "expected_rows": 1, "expected_rows_min": 0}], "platform_overrides": {"datafusion": null}} - {"id": "delete_with_aggregation", "description": "Tests DELETE using aggregate in WHERE clause to measure aggregation evaluation overhead", "write_sql": "DELETE FROM delete_ops_orders\nWHERE o_totalprice < (\n SELECT AVG(o_totalprice) FROM delete_ops_orders\n)\nAND delete_ops_orders.o_orderkey <= (SELECT MIN(o_orderkey) + 30 FROM delete_ops_orders)\n", "validation_queries": [{"id": "verify_agg_delete", "sql": "SELECT COUNT(*) as cnt FROM delete_ops_orders", "expected_rows": 1, "expected_rows_min": 0}], "platform_overrides": {"datafusion": null}} -- {"id": "delete_returning", "description": "Tests DELETE...RETURNING to measure overhead of returning deleted row data", "write_sql": "DELETE FROM delete_ops_orders\nWHERE o_orderkey = (SELECT MAX(o_orderkey) FROM delete_ops_orders)\nRETURNING o_orderkey, o_custkey, o_totalprice\n", "validation_queries": [{"id": "verify_delete_returning", "sql": "SELECT COUNT(*) as cnt FROM delete_ops_orders", "expected_rows": 1, "expected_rows_min": 0}], "expected_rows_affected": 1, "platform_overrides": {"mysql": null, "bigquery": null, "datafusion": null, "starrocks": null, "ducklake": null}} +- {"id": "delete_returning", "description": "Tests DELETE...RETURNING to measure overhead of returning deleted row data", "write_sql": "DELETE FROM delete_ops_orders\nWHERE o_orderkey = (SELECT MAX(o_orderkey) FROM delete_ops_orders)\nRETURNING o_orderkey, o_custkey, o_totalprice\n", "validation_queries": [{"id": "verify_delete_returning", "sql": "SELECT COUNT(*) as cnt FROM delete_ops_orders", "expected_rows": 1, "expected_rows_min": 0}], "expected_rows_affected": 1, "platform_overrides": {"databricks": null, "mysql": null, "bigquery": null, "datafusion": null, "starrocks": null, "ducklake": null, "snowflake": null}} - {"id": "delete_cascade_simulation", "description": "Tests multi-table DELETE simulation to measure cascading deletion overhead", "write_sql": "DELETE FROM delete_ops_lineitem\nWHERE l_orderkey IN (\n SELECT o_orderkey FROM delete_ops_orders\n WHERE o_orderpriority = '1-URGENT'\n LIMIT 5\n)\n", "validation_queries": [{"id": "verify_cascade_delete", "sql": "SELECT COUNT(*) as cnt\nFROM delete_ops_lineitem l\nWHERE l_orderkey IN (\n SELECT o_orderkey FROM delete_ops_orders\n WHERE o_orderpriority = '1-URGENT'\n LIMIT 5\n)\n", "expected_rows": 1, "expected_rows_min": 0}], "platform_overrides": {"datafusion": null, "starrocks": null}} - {"id": "delete_all_truncate_comparison", "description": "Tests DELETE star versus TRUNCATE to measure full table deletion methods", "write_sql": "DELETE FROM delete_ops_lineitem\n", "validation_queries": [{"id": "verify_all_deleted", "sql": "SELECT COUNT(*) as cnt FROM delete_ops_lineitem", "expected_rows": 1}], "platform_overrides": {"datafusion": null, "starrocks": null}} -- {"id": "bulk_load_csv_small_uncompressed", "category": "bulk_load", "description": "Tests bulk load from small CSV file 1K rows uncompressed to measure baseline CSV parsing overhead", "file_dependencies": ["csv_small_1k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv' (FORMAT CSV, HEADER true)\n", "mysql": "TRUNCATE TABLE bulk_load_ops_target;\nLOAD DATA LOCAL INFILE '{file_path}/csv_small_1k.csv'\nINTO TABLE bulk_load_ops_target\nFIELDS TERMINATED BY ','\nLINES TERMINATED BY '\\n'\nIGNORE 1 ROWS\n", "sqlite": "DELETE FROM bulk_load_ops_target;\n.mode csv\n.import {file_path}/csv_small_1k.csv bulk_load_ops_target\n", "starrocks": null}} -- {"id": "bulk_load_csv_small_gzip", "category": "bulk_load", "description": "Tests bulk load from small CSV file 1K rows gzip compressed to measure gzip decompression overhead", "file_dependencies": ["csv_small_1k.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.gz' WITH (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} -- {"id": "bulk_load_csv_small_zstd", "category": "bulk_load", "description": "Tests bulk load from small CSV file 1K rows zstd compressed to measure zstd decompression overhead", "file_dependencies": ["csv_small_1k.csv.zst"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.zst' WITH (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.zst' (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "starrocks": null}} -- {"id": "bulk_load_csv_small_bzip2", "category": "bulk_load", "description": "Tests bulk load from small CSV file 1K rows bzip2 compressed to measure bzip2 decompression overhead", "file_dependencies": ["csv_small_1k.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "-- DuckDB does not support bzip2 compression - use gzip instead\nTRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} -- {"id": "bulk_load_csv_medium_uncompressed", "category": "bulk_load", "description": "Tests bulk load from medium CSV file 100K rows uncompressed to measure medium dataset processing", "file_dependencies": ["csv_medium_100k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv' (FORMAT CSV, HEADER true)\n", "mysql": "TRUNCATE TABLE bulk_load_ops_target;\nLOAD DATA LOCAL INFILE '{file_path}/csv_medium_100k.csv'\nINTO TABLE bulk_load_ops_target\nFIELDS TERMINATED BY ','\nLINES TERMINATED BY '\\n'\nIGNORE 1 ROWS\n", "starrocks": null}} -- {"id": "bulk_load_csv_medium_gzip", "category": "bulk_load", "description": "Tests bulk load from medium CSV file 100K rows gzip compressed to measure compression benefit on medium data", "file_dependencies": ["csv_medium_100k.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.gz' WITH (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} -- {"id": "bulk_load_csv_medium_zstd", "category": "bulk_load", "description": "Tests bulk load from medium CSV file 100K rows zstd compressed to measure zstd efficiency on medium data", "file_dependencies": ["csv_medium_100k.csv.zst"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.zst' WITH (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.zst' (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "starrocks": null}} -- {"id": "bulk_load_csv_medium_bzip2", "category": "bulk_load", "description": "Tests bulk load from medium CSV file 100K rows bzip2 compressed to measure bzip2 efficiency on medium data", "file_dependencies": ["csv_medium_100k.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"duckdb": "-- DuckDB does not support bzip2 compression - use gzip instead\nTRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} -- {"id": "bulk_load_csv_large_uncompressed", "category": "bulk_load", "description": "Tests bulk load from large CSV file 1M rows uncompressed to measure large dataset processing limits", "file_dependencies": ["csv_large_1m.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv' (FORMAT CSV, HEADER true)\n", "mysql": "TRUNCATE TABLE bulk_load_ops_target;\nLOAD DATA LOCAL INFILE '{file_path}/csv_large_1m.csv'\nINTO TABLE bulk_load_ops_target\nFIELDS TERMINATED BY ','\nLINES TERMINATED BY '\\n'\nIGNORE 1 ROWS\n", "starrocks": null}} -- {"id": "bulk_load_csv_large_gzip", "category": "bulk_load", "description": "Tests bulk load from large CSV file 1M rows gzip compressed to measure compression benefit on large data", "file_dependencies": ["csv_large_1m.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.gz' WITH (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} -- {"id": "bulk_load_csv_large_zstd", "category": "bulk_load", "description": "Tests bulk load from large CSV file 1M rows zstd compressed to measure zstd efficiency on large data", "file_dependencies": ["csv_large_1m.csv.zst"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.zst' WITH (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.zst' (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "starrocks": null}} -- {"id": "bulk_load_csv_large_bzip2", "category": "bulk_load", "description": "Tests bulk load from large CSV file 1M rows bzip2 compressed to measure bzip2 efficiency on large data", "file_dependencies": ["csv_large_1m.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"duckdb": "-- DuckDB does not support bzip2 compression - use gzip instead\nTRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} -- {"id": "bulk_load_parquet_small_uncompressed", "category": "bulk_load", "description": "Tests bulk load from small Parquet file 1K rows uncompressed to measure Parquet columnar format overhead", "file_dependencies": ["parquet_small_1k.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_small_1k.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_small_snappy", "category": "bulk_load", "description": "Tests bulk load from small Parquet file 1K rows snappy compressed to measure Snappy decompression overhead", "file_dependencies": ["parquet_small_1k_snappy.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_small_1k_snappy.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_small_gzip", "category": "bulk_load", "description": "Tests bulk load from small Parquet file 1K rows gzip compressed to measure gzip in Parquet context", "file_dependencies": ["parquet_small_1k_gzip.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_small_1k_gzip.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_small_zstd", "category": "bulk_load", "description": "Tests bulk load from small Parquet file 1K rows zstd compressed to measure zstd in Parquet context", "file_dependencies": ["parquet_small_1k_zstd.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_small_1k_zstd.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_medium_uncompressed", "category": "bulk_load", "description": "Tests bulk load from medium Parquet file 100K rows uncompressed to measure columnar format efficiency", "file_dependencies": ["parquet_medium_100k.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_medium_100k.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_medium_snappy", "category": "bulk_load", "description": "Tests bulk load from medium Parquet file 100K rows snappy compressed to measure Snappy efficiency", "file_dependencies": ["parquet_medium_100k_snappy.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_medium_100k_snappy.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_medium_gzip", "category": "bulk_load", "description": "Tests bulk load from medium Parquet file 100K rows gzip compressed to measure compression trade-offs", "file_dependencies": ["parquet_medium_100k_gzip.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_medium_100k_gzip.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_medium_zstd", "category": "bulk_load", "description": "Tests bulk load from medium Parquet file 100K rows zstd compressed to measure zstd columnar efficiency", "file_dependencies": ["parquet_medium_100k_zstd.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_medium_100k_zstd.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_large_uncompressed", "category": "bulk_load", "description": "Tests bulk load from large Parquet file 1M rows uncompressed to measure large columnar dataset processing", "file_dependencies": ["parquet_large_1m.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_large_1m.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_large_snappy", "category": "bulk_load", "description": "Tests bulk load from large Parquet file 1M rows snappy compressed to measure Snappy on large data", "file_dependencies": ["parquet_large_1m_snappy.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_large_1m_snappy.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_large_gzip", "category": "bulk_load", "description": "Tests bulk load from large Parquet file 1M rows gzip compressed to measure gzip on large data", "file_dependencies": ["parquet_large_1m_gzip.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_large_1m_gzip.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_parquet_large_zstd", "category": "bulk_load", "description": "Tests bulk load from large Parquet file 1M rows zstd compressed to measure zstd on large data", "file_dependencies": ["parquet_large_1m_zstd.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_large_1m_zstd.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"starrocks": null}} -- {"id": "bulk_load_column_subset", "category": "bulk_load", "description": "Tests bulk load with column subset selection to measure partial schema load overhead", "file_dependencies": ["csv_medium_100k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nINSERT INTO bulk_load_ops_target (o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate)\nSELECT o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate\nFROM read_csv_auto('{file_path}/csv_medium_100k.csv')\n", "validation_queries": [{"id": "verify_partial_load", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target\nWHERE o_orderkey IS NOT NULL\n", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nINSERT INTO bulk_load_ops_target (o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate)\nSELECT o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate\nFROM read_csv_auto('{file_path}/csv_medium_100k.csv')\n", "starrocks": null}} -- {"id": "bulk_load_with_transformation", "category": "bulk_load", "description": "Tests bulk load with inline type conversion and transformation to measure ETL overhead", "file_dependencies": ["csv_small_1k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nINSERT INTO bulk_load_ops_target\nSELECT\n CAST(o_orderkey AS INTEGER),\n CAST(o_custkey AS INTEGER),\n UPPER(o_orderstatus),\n CAST(o_totalprice AS DECIMAL(15,2)),\n CAST(o_orderdate AS DATE),\n o_orderpriority,\n o_clerk,\n o_shippriority,\n CONCAT('TRANSFORMED: ', o_comment)\nFROM read_csv_auto('{file_path}/csv_small_1k.csv')\n", "validation_queries": [{"id": "verify_transformation", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target\nWHERE o_comment LIKE 'TRANSFORMED:%'\n", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nINSERT INTO bulk_load_ops_target\nSELECT\n CAST(o_orderkey AS INTEGER),\n CAST(o_custkey AS INTEGER),\n UPPER(o_orderstatus),\n CAST(o_totalprice AS DECIMAL(15,2)),\n CAST(o_orderdate AS DATE),\n o_orderpriority,\n o_clerk,\n o_shippriority,\n CONCAT('TRANSFORMED: ', o_comment)\nFROM read_csv_auto('{file_path}/csv_small_1k.csv')\n", "starrocks": null}} -- {"id": "bulk_load_error_handling_skip_bad_rows", "category": "bulk_load", "description": "Tests bulk load with error tolerance to skip malformed rows and measure error handling overhead", "file_dependencies": ["csv_with_errors.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_with_errors.csv'\n(FORMAT CSV, HEADER true, IGNORE_ERRORS true)\n", "validation_queries": [{"id": "verify_error_skip", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_with_errors.csv'\n(FORMAT CSV, HEADER true, IGNORE_ERRORS true)\n", "datafusion": null, "starrocks": null}} -- {"id": "bulk_load_parallel_multi_file", "category": "bulk_load", "description": "Tests bulk load from multiple files in parallel to measure parallel ingestion capability", "file_dependencies": ["csv_parallel_part1.csv", "csv_parallel_part2.csv", "csv_parallel_part3.csv", "csv_parallel_part4.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_parallel_part*.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_multi_file", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 4000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_parallel_part*.csv' (FORMAT CSV, HEADER true)\n", "starrocks": null}} -- {"id": "bulk_load_upsert_mode", "category": "bulk_load", "description": "Tests bulk load with UPSERT semantics using MERGE to measure conflict resolution overhead", "file_dependencies": ["csv_upsert_data.csv"], "write_sql": "MERGE INTO bulk_load_ops_target AS target\nUSING (\n SELECT * FROM read_csv_auto('{file_path}/csv_upsert_data.csv')\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET\n o_custkey = source.o_custkey,\n o_orderstatus = source.o_orderstatus,\n o_totalprice = source.o_totalprice,\n o_orderdate = source.o_orderdate,\n o_orderpriority = source.o_orderpriority,\n o_clerk = source.o_clerk,\n o_shippriority = source.o_shippriority,\n o_comment = source.o_comment\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_upsert", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "platform_overrides": {"datafusion": null, "starrocks": null}} -- {"id": "bulk_load_append_mode", "category": "bulk_load", "description": "Tests bulk load in append mode without TRUNCATE to measure incremental load overhead", "file_dependencies": ["csv_small_1k.csv"], "write_sql": "COPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_append", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1, "expected_rows_min": 1000}], "cleanup_sql": "DELETE FROM bulk_load_ops_target WHERE o_orderkey IN (\n SELECT o_orderkey FROM (\n SELECT o_orderkey FROM bulk_load_ops_target ORDER BY o_orderkey DESC LIMIT 1000\n ) t\n)\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "COPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv' (FORMAT CSV, HEADER true)\n", "starrocks": null}} -- {"id": "bulk_load_replace_mode", "category": "bulk_load", "description": "Tests bulk load with TRUNCATE before load to measure replace semantics overhead", "file_dependencies": ["csv_medium_100k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_replace", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv' (FORMAT CSV, HEADER true)\n", "starrocks": null}} -- {"id": "bulk_load_delimited_custom", "category": "bulk_load", "description": "Tests bulk load with custom delimiter pipe character to measure delimiter parsing flexibility", "file_dependencies": ["csv_custom_delim.psv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_custom_delim.psv'\nWITH (FORMAT CSV, DELIMITER '|', HEADER true)\n", "validation_queries": [{"id": "verify_custom_delim", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_custom_delim.psv'\n(FORMAT CSV, DELIMITER '|', HEADER true)\n", "mysql": "TRUNCATE TABLE bulk_load_ops_target;\nLOAD DATA LOCAL INFILE '{file_path}/csv_custom_delim.psv'\nINTO TABLE bulk_load_ops_target\nFIELDS TERMINATED BY '|'\nLINES TERMINATED BY '\\n'\nIGNORE 1 ROWS\n", "starrocks": null}} -- {"id": "bulk_load_quoted_fields", "category": "bulk_load", "description": "Tests bulk load with quoted string fields to measure quote escaping overhead", "file_dependencies": ["csv_quoted_fields.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_quoted_fields.csv'\nWITH (FORMAT CSV, HEADER true, QUOTE '\"', ESCAPE '\"')\n", "validation_queries": [{"id": "verify_quoted", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_quoted_fields.csv'\n(FORMAT CSV, HEADER true, QUOTE '\"', ESCAPE '\"')\n", "starrocks": null}} -- {"id": "bulk_load_null_handling", "category": "bulk_load", "description": "Tests bulk load with NULL value representation to measure NULL parsing overhead", "file_dependencies": ["csv_with_nulls.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_with_nulls.csv'\nWITH (FORMAT CSV, HEADER true, NULL 'NULL')\n", "validation_queries": [{"id": "verify_nulls", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target\nWHERE o_comment IS NULL OR o_clerk IS NULL\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_with_nulls.csv'\n(FORMAT CSV, HEADER true, NULLSTR 'NULL')\n", "starrocks": null}} -- {"id": "bulk_load_encoding_utf8", "category": "bulk_load", "description": "Tests bulk load with explicit UTF-8 encoding to measure character encoding overhead", "file_dependencies": ["csv_utf8_encoded.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_utf8_encoded.csv'\nWITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_encoding", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_utf8_encoded.csv'\n(FORMAT CSV, HEADER true)\n", "starrocks": null}} -- {"id": "bulk_load_date_format_custom", "category": "bulk_load", "description": "Tests bulk load with custom date format specification to measure date parsing flexibility", "file_dependencies": ["csv_custom_dates.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_custom_dates.csv'\nWITH (FORMAT CSV, HEADER true, DATEFORMAT '%Y/%m/%d')\n", "validation_queries": [{"id": "verify_date_format", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target\nWHERE o_orderdate IS NOT NULL\n", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_custom_dates.csv'\n(FORMAT CSV, HEADER true, DATEFORMAT '%Y/%m/%d')\n", "starrocks": null, "datafusion": null}} -- {"id": "merge_simple_upsert_small", "description": "Tests simple MERGE upsert with 10 rows to measure baseline MERGE overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n o_comment = 'merged_update'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_orderkey BETWEEN 1 AND 10\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target WHERE o_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_upsert_with_delete", "description": "Tests MERGE with DELETE clause for unmatched target rows to measure tri-directional merge overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 20\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED BY TARGET THEN INSERT\nWHEN NOT MATCHED BY SOURCE AND target.o_orderkey <= 25 THEN\n DELETE\n", "validation_queries": [{"id": "verify_merge_delete", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_orderkey BETWEEN 1 AND 25\n", "expected_rows": 1, "expected_rows_min": 1}], "platform_overrides": {"datafusion": null, "ducklake": null}} -- {"id": "merge_overlap_10pct", "description": "Tests MERGE with approximately 10 percent row overlap to measure low collision overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.9 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_10pct'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_merge_overlap", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_10pct'", "expected_rows_min": 0}], "platform_overrides": {"datafusion": null}} -- {"id": "merge_overlap_50pct", "description": "Tests MERGE with approximately 50 percent row overlap to measure medium collision overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.5 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_50pct'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_merge_50pct", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_50pct'", "expected_rows_min": 0}], "platform_overrides": {"datafusion": null}} -- {"id": "merge_overlap_90pct", "description": "Tests MERGE with approximately 90 percent row overlap to measure high collision overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey <= (\n SELECT CAST(MAX(o_orderkey) * 0.95 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_90pct'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_merge_90pct", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_90pct'", "expected_rows_min": 1}], "platform_overrides": {"datafusion": null}} -- {"id": "merge_no_overlap_all_insert", "description": "Tests MERGE with zero overlap all inserts to measure pure insert path performance", "write_sql": "MERGE INTO merge_ops_source AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 100\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_all_insert", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_source\nWHERE o_orderkey BETWEEN 1 AND 100\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_source WHERE o_orderkey BETWEEN 1 AND 100\n", "platform_overrides": {"datafusion": null}} +- {"id": "bulk_load_csv_small_uncompressed", "category": "bulk_load", "description": "Tests bulk load from small CSV file 1K rows uncompressed to measure baseline CSV parsing overhead", "file_dependencies": ["csv_small_1k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv' (FORMAT CSV, HEADER true)\n", "mysql": "TRUNCATE TABLE bulk_load_ops_target;\nLOAD DATA LOCAL INFILE '{file_path}/csv_small_1k.csv'\nINTO TABLE bulk_load_ops_target\nFIELDS TERMINATED BY ','\nLINES TERMINATED BY '\\n'\nIGNORE 1 ROWS\n", "sqlite": "DELETE FROM bulk_load_ops_target;\n.mode csv\n.import {file_path}/csv_small_1k.csv bulk_load_ops_target\n", "starrocks": null}} +- {"id": "bulk_load_csv_small_gzip", "category": "bulk_load", "description": "Tests bulk load from small CSV file 1K rows gzip compressed to measure gzip decompression overhead", "file_dependencies": ["csv_small_1k.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.gz' WITH (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} +- {"id": "bulk_load_csv_small_zstd", "category": "bulk_load", "description": "Tests bulk load from small CSV file 1K rows zstd compressed to measure zstd decompression overhead", "file_dependencies": ["csv_small_1k.csv.zst"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.zst' WITH (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.zst' (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "starrocks": null}} +- {"id": "bulk_load_csv_small_bzip2", "category": "bulk_load", "description": "Tests bulk load from small CSV file 1K rows bzip2 compressed to measure bzip2 decompression overhead", "file_dependencies": ["csv_small_1k.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "-- DuckDB does not support bzip2 compression - use gzip instead\nTRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} +- {"id": "bulk_load_csv_medium_uncompressed", "category": "bulk_load", "description": "Tests bulk load from medium CSV file 100K rows uncompressed to measure medium dataset processing", "file_dependencies": ["csv_medium_100k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv' (FORMAT CSV, HEADER true)\n", "mysql": "TRUNCATE TABLE bulk_load_ops_target;\nLOAD DATA LOCAL INFILE '{file_path}/csv_medium_100k.csv'\nINTO TABLE bulk_load_ops_target\nFIELDS TERMINATED BY ','\nLINES TERMINATED BY '\\n'\nIGNORE 1 ROWS\n", "starrocks": null}} +- {"id": "bulk_load_csv_medium_gzip", "category": "bulk_load", "description": "Tests bulk load from medium CSV file 100K rows gzip compressed to measure compression benefit on medium data", "file_dependencies": ["csv_medium_100k.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.gz' WITH (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} +- {"id": "bulk_load_csv_medium_zstd", "category": "bulk_load", "description": "Tests bulk load from medium CSV file 100K rows zstd compressed to measure zstd efficiency on medium data", "file_dependencies": ["csv_medium_100k.csv.zst"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.zst' WITH (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.zst' (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "starrocks": null}} +- {"id": "bulk_load_csv_medium_bzip2", "category": "bulk_load", "description": "Tests bulk load from medium CSV file 100K rows bzip2 compressed to measure bzip2 efficiency on medium data", "file_dependencies": ["csv_medium_100k.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "-- DuckDB does not support bzip2 compression - use gzip instead\nTRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} +- {"id": "bulk_load_csv_large_uncompressed", "category": "bulk_load", "description": "Tests bulk load from large CSV file 1M rows uncompressed to measure large dataset processing limits", "file_dependencies": ["csv_large_1m.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv' (FORMAT CSV, HEADER true)\n", "mysql": "TRUNCATE TABLE bulk_load_ops_target;\nLOAD DATA LOCAL INFILE '{file_path}/csv_large_1m.csv'\nINTO TABLE bulk_load_ops_target\nFIELDS TERMINATED BY ','\nLINES TERMINATED BY '\\n'\nIGNORE 1 ROWS\n", "starrocks": null}} +- {"id": "bulk_load_csv_large_gzip", "category": "bulk_load", "description": "Tests bulk load from large CSV file 1M rows gzip compressed to measure compression benefit on large data", "file_dependencies": ["csv_large_1m.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.gz' WITH (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} +- {"id": "bulk_load_csv_large_zstd", "category": "bulk_load", "description": "Tests bulk load from large CSV file 1M rows zstd compressed to measure zstd efficiency on large data", "file_dependencies": ["csv_large_1m.csv.zst"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.zst' WITH (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.zst' (FORMAT CSV, HEADER true, COMPRESSION 'zstd')\n", "starrocks": null}} +- {"id": "bulk_load_csv_large_bzip2", "category": "bulk_load", "description": "Tests bulk load from large CSV file 1M rows bzip2 compressed to measure bzip2 efficiency on large data", "file_dependencies": ["csv_large_1m.csv.gz"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "-- DuckDB does not support bzip2 compression - use gzip instead\nTRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_large_1m.csv.gz' (FORMAT CSV, HEADER true, COMPRESSION 'gzip')\n", "starrocks": null}} +- {"id": "bulk_load_parquet_small_uncompressed", "category": "bulk_load", "description": "Tests bulk load from small Parquet file 1K rows uncompressed to measure Parquet columnar format overhead", "file_dependencies": ["parquet_small_1k.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_small_1k.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_small_snappy", "category": "bulk_load", "description": "Tests bulk load from small Parquet file 1K rows snappy compressed to measure Snappy decompression overhead", "file_dependencies": ["parquet_small_1k_snappy.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_small_1k_snappy.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_small_gzip", "category": "bulk_load", "description": "Tests bulk load from small Parquet file 1K rows gzip compressed to measure gzip in Parquet context", "file_dependencies": ["parquet_small_1k_gzip.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_small_1k_gzip.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_small_zstd", "category": "bulk_load", "description": "Tests bulk load from small Parquet file 1K rows zstd compressed to measure zstd in Parquet context", "file_dependencies": ["parquet_small_1k_zstd.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_small_1k_zstd.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_medium_uncompressed", "category": "bulk_load", "description": "Tests bulk load from medium Parquet file 100K rows uncompressed to measure columnar format efficiency", "file_dependencies": ["parquet_medium_100k.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_medium_100k.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_medium_snappy", "category": "bulk_load", "description": "Tests bulk load from medium Parquet file 100K rows snappy compressed to measure Snappy efficiency", "file_dependencies": ["parquet_medium_100k_snappy.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_medium_100k_snappy.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_medium_gzip", "category": "bulk_load", "description": "Tests bulk load from medium Parquet file 100K rows gzip compressed to measure compression trade-offs", "file_dependencies": ["parquet_medium_100k_gzip.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_medium_100k_gzip.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_medium_zstd", "category": "bulk_load", "description": "Tests bulk load from medium Parquet file 100K rows zstd compressed to measure zstd columnar efficiency", "file_dependencies": ["parquet_medium_100k_zstd.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_medium_100k_zstd.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_large_uncompressed", "category": "bulk_load", "description": "Tests bulk load from large Parquet file 1M rows uncompressed to measure large columnar dataset processing", "file_dependencies": ["parquet_large_1m.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_large_1m.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_large_snappy", "category": "bulk_load", "description": "Tests bulk load from large Parquet file 1M rows snappy compressed to measure Snappy on large data", "file_dependencies": ["parquet_large_1m_snappy.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_large_1m_snappy.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_large_gzip", "category": "bulk_load", "description": "Tests bulk load from large Parquet file 1M rows gzip compressed to measure gzip on large data", "file_dependencies": ["parquet_large_1m_gzip.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_large_1m_gzip.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_parquet_large_zstd", "category": "bulk_load", "description": "Tests bulk load from large Parquet file 1M rows zstd compressed to measure zstd on large data", "file_dependencies": ["parquet_large_1m_zstd.parquet"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/parquet_large_1m_zstd.parquet' (FORMAT PARQUET)\n", "validation_queries": [{"id": "verify_row_count", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "starrocks": null}} +- {"id": "bulk_load_column_subset", "category": "bulk_load", "description": "Tests bulk load with column subset selection to measure partial schema load overhead", "file_dependencies": ["csv_medium_100k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nINSERT INTO bulk_load_ops_target (o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate)\nSELECT o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate\nFROM read_csv_auto('{file_path}/csv_medium_100k.csv')\n", "validation_queries": [{"id": "verify_partial_load", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target\nWHERE o_orderkey IS NOT NULL\n", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nINSERT INTO bulk_load_ops_target (o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate)\nSELECT o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate\nFROM read_csv_auto('{file_path}/csv_medium_100k.csv')\n", "starrocks": null}} +- {"id": "bulk_load_with_transformation", "category": "bulk_load", "description": "Tests bulk load with inline type conversion and transformation to measure ETL overhead", "file_dependencies": ["csv_small_1k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nINSERT INTO bulk_load_ops_target\nSELECT\n CAST(o_orderkey AS INTEGER),\n CAST(o_custkey AS INTEGER),\n UPPER(o_orderstatus),\n CAST(o_totalprice AS DECIMAL(15,2)),\n CAST(o_orderdate AS DATE),\n o_orderpriority,\n o_clerk,\n o_shippriority,\n CONCAT('TRANSFORMED: ', o_comment)\nFROM read_csv_auto('{file_path}/csv_small_1k.csv')\n", "validation_queries": [{"id": "verify_transformation", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target\nWHERE o_comment LIKE 'TRANSFORMED:%'\n", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nINSERT INTO bulk_load_ops_target\nSELECT\n CAST(o_orderkey AS INTEGER),\n CAST(o_custkey AS INTEGER),\n UPPER(o_orderstatus),\n CAST(o_totalprice AS DECIMAL(15,2)),\n CAST(o_orderdate AS DATE),\n o_orderpriority,\n o_clerk,\n o_shippriority,\n CONCAT('TRANSFORMED: ', o_comment)\nFROM read_csv_auto('{file_path}/csv_small_1k.csv')\n", "starrocks": null}} +- {"id": "bulk_load_error_handling_skip_bad_rows", "category": "bulk_load", "description": "Tests bulk load with error tolerance to skip malformed rows and measure error handling overhead", "file_dependencies": ["csv_with_errors.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_with_errors.csv'\n(FORMAT CSV, HEADER true, IGNORE_ERRORS true)\n", "validation_queries": [{"id": "verify_error_skip", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_with_errors.csv'\n(FORMAT CSV, HEADER true, IGNORE_ERRORS true)\n", "datafusion": null, "starrocks": null}} +- {"id": "bulk_load_parallel_multi_file", "category": "bulk_load", "description": "Tests bulk load from multiple files in parallel to measure parallel ingestion capability", "file_dependencies": ["csv_parallel_part1.csv", "csv_parallel_part2.csv", "csv_parallel_part3.csv", "csv_parallel_part4.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_parallel_part*.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_multi_file", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 4000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_parallel_part*.csv' (FORMAT CSV, HEADER true)\n", "starrocks": null}} +- {"id": "bulk_load_upsert_mode", "category": "bulk_load", "description": "Tests bulk load with UPSERT semantics using MERGE to measure conflict resolution overhead", "file_dependencies": ["csv_upsert_data.csv"], "write_sql": "MERGE INTO bulk_load_ops_target AS target\nUSING (\n SELECT * FROM read_csv_auto('{file_path}/csv_upsert_data.csv')\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET\n o_custkey = source.o_custkey,\n o_orderstatus = source.o_orderstatus,\n o_totalprice = source.o_totalprice,\n o_orderdate = source.o_orderdate,\n o_orderpriority = source.o_orderpriority,\n o_clerk = source.o_clerk,\n o_shippriority = source.o_shippriority,\n o_comment = source.o_comment\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_upsert", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "datafusion": null, "starrocks": null}} +- {"id": "bulk_load_append_mode", "category": "bulk_load", "description": "Tests bulk load in append mode without TRUNCATE to measure incremental load overhead", "file_dependencies": ["csv_small_1k.csv"], "write_sql": "COPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_append", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1, "expected_rows_min": 1000}], "cleanup_sql": "DELETE FROM bulk_load_ops_target WHERE o_orderkey IN (\n SELECT o_orderkey FROM (\n SELECT o_orderkey FROM bulk_load_ops_target ORDER BY o_orderkey DESC LIMIT 1000\n ) t\n)\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "COPY bulk_load_ops_target FROM '{file_path}/csv_small_1k.csv' (FORMAT CSV, HEADER true)\n", "starrocks": null}} +- {"id": "bulk_load_replace_mode", "category": "bulk_load", "description": "Tests bulk load with TRUNCATE before load to measure replace semantics overhead", "file_dependencies": ["csv_medium_100k.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv' WITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_replace", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 100000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_medium_100k.csv' (FORMAT CSV, HEADER true)\n", "starrocks": null}} +- {"id": "bulk_load_delimited_custom", "category": "bulk_load", "description": "Tests bulk load with custom delimiter pipe character to measure delimiter parsing flexibility", "file_dependencies": ["csv_custom_delim.psv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_custom_delim.psv'\nWITH (FORMAT CSV, DELIMITER '|', HEADER true)\n", "validation_queries": [{"id": "verify_custom_delim", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_custom_delim.psv'\n(FORMAT CSV, DELIMITER '|', HEADER true)\n", "mysql": "TRUNCATE TABLE bulk_load_ops_target;\nLOAD DATA LOCAL INFILE '{file_path}/csv_custom_delim.psv'\nINTO TABLE bulk_load_ops_target\nFIELDS TERMINATED BY '|'\nLINES TERMINATED BY '\\n'\nIGNORE 1 ROWS\n", "starrocks": null}} +- {"id": "bulk_load_quoted_fields", "category": "bulk_load", "description": "Tests bulk load with quoted string fields to measure quote escaping overhead", "file_dependencies": ["csv_quoted_fields.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_quoted_fields.csv'\nWITH (FORMAT CSV, HEADER true, QUOTE '\"', ESCAPE '\"')\n", "validation_queries": [{"id": "verify_quoted", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_quoted_fields.csv'\n(FORMAT CSV, HEADER true, QUOTE '\"', ESCAPE '\"')\n", "starrocks": null}} +- {"id": "bulk_load_null_handling", "category": "bulk_load", "description": "Tests bulk load with NULL value representation to measure NULL parsing overhead", "file_dependencies": ["csv_with_nulls.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_with_nulls.csv'\nWITH (FORMAT CSV, HEADER true, NULL 'NULL')\n", "validation_queries": [{"id": "verify_nulls", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target\nWHERE o_comment IS NULL OR o_clerk IS NULL\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_with_nulls.csv'\n(FORMAT CSV, HEADER true, NULLSTR 'NULL')\n", "starrocks": null}} +- {"id": "bulk_load_encoding_utf8", "category": "bulk_load", "description": "Tests bulk load with explicit UTF-8 encoding to measure character encoding overhead", "file_dependencies": ["csv_utf8_encoded.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_utf8_encoded.csv'\nWITH (FORMAT CSV, HEADER true)\n", "validation_queries": [{"id": "verify_encoding", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_utf8_encoded.csv'\n(FORMAT CSV, HEADER true)\n", "starrocks": null}} +- {"id": "bulk_load_date_format_custom", "category": "bulk_load", "description": "Tests bulk load with custom date format specification to measure date parsing flexibility", "file_dependencies": ["csv_custom_dates.csv"], "write_sql": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_custom_dates.csv'\nWITH (FORMAT CSV, HEADER true, DATEFORMAT '%Y/%m/%d')\n", "validation_queries": [{"id": "verify_date_format", "sql": "SELECT COUNT(*) as cnt FROM bulk_load_ops_target\nWHERE o_orderdate IS NOT NULL\n", "expected_rows": 1}], "cleanup_sql": "TRUNCATE TABLE bulk_load_ops_target\n", "expected_rows_affected": 1000, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "duckdb": "TRUNCATE TABLE bulk_load_ops_target;\nCOPY bulk_load_ops_target FROM '{file_path}/csv_custom_dates.csv'\n(FORMAT CSV, HEADER true, DATEFORMAT '%Y/%m/%d')\n", "starrocks": null, "datafusion": null}} +- {"id": "merge_simple_upsert_small", "description": "Tests simple MERGE upsert with 10 rows to measure baseline MERGE overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n o_comment = 'merged_update'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_orderkey BETWEEN 1 AND 10\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target WHERE o_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n o_comment = 'merged_update'\nWHEN NOT MATCHED THEN INSERT *\n", "bigquery": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n o_comment = 'merged_update'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "snowflake": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n o_comment = 'merged_update'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "datafusion": null}} +- {"id": "merge_upsert_with_delete", "description": "Tests MERGE with DELETE clause for unmatched target rows to measure tri-directional merge overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 20\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED BY TARGET THEN INSERT\nWHEN NOT MATCHED BY SOURCE AND target.o_orderkey <= 25 THEN\n DELETE\n", "validation_queries": [{"id": "verify_merge_delete", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_orderkey BETWEEN 1 AND 25\n", "expected_rows": 1, "expected_rows_min": 1}], "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "datafusion": null, "ducklake": null}} +- {"id": "merge_overlap_10pct", "description": "Tests MERGE with approximately 10 percent row overlap to measure low collision overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.9 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_10pct'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_merge_overlap", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_10pct'", "expected_rows_min": 0}], "platform_overrides": {"databricks": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.9 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_10pct'\nWHEN NOT MATCHED THEN INSERT *\n", "bigquery": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.9 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_10pct'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "snowflake": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.9 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_10pct'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "datafusion": null}} +- {"id": "merge_overlap_50pct", "description": "Tests MERGE with approximately 50 percent row overlap to measure medium collision overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.5 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_50pct'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_merge_50pct", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_50pct'", "expected_rows_min": 0}], "platform_overrides": {"databricks": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.5 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_50pct'\nWHEN NOT MATCHED THEN INSERT *\n", "bigquery": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.5 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_50pct'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "snowflake": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey > (\n SELECT CAST(MAX(o_orderkey) * 0.5 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_50pct'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "datafusion": null}} +- {"id": "merge_overlap_90pct", "description": "Tests MERGE with approximately 90 percent row overlap to measure high collision overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey <= (\n SELECT CAST(MAX(o_orderkey) * 0.95 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_90pct'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_merge_90pct", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_90pct'", "expected_rows_min": 1}], "platform_overrides": {"databricks": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey <= (\n SELECT CAST(MAX(o_orderkey) * 0.95 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_90pct'\nWHEN NOT MATCHED THEN INSERT *\n", "bigquery": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey <= (\n SELECT CAST(MAX(o_orderkey) * 0.95 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_90pct'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "snowflake": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders\n WHERE o_orderkey <= (\n SELECT CAST(MAX(o_orderkey) * 0.95 AS INTEGER) FROM merge_ops_target\n )\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_90pct'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "datafusion": null}} +- {"id": "merge_no_overlap_all_insert", "description": "Tests MERGE with zero overlap all inserts to measure pure insert path performance", "write_sql": "MERGE INTO merge_ops_source AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 100\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_all_insert", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_source\nWHERE o_orderkey BETWEEN 1 AND 100\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_source WHERE o_orderkey BETWEEN 1 AND 100\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_source AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 100\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED THEN INSERT *\n", "bigquery": "MERGE INTO merge_ops_source AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 100\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "snowflake": "MERGE INTO merge_ops_source AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 100\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "datafusion": null}} - {"id": "merge_full_overlap_all_update", "description": "Tests MERGE with full overlap all updates to measure pure update path performance", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT o_orderkey, o_totalprice * 1.1 AS o_totalprice, 'merge_all_update' AS o_comment\n FROM merge_ops_target\n WHERE o_orderkey <= (SELECT MIN(o_orderkey) + 100 FROM merge_ops_target)\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n o_comment = source.o_comment\n", "validation_queries": [{"id": "verify_all_update", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_all_update'", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target\nSET o_totalprice = o_totalprice / 1.1,\n o_comment = ''\nWHERE o_comment = 'merge_all_update'\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_with_join_condition", "description": "Tests MERGE with complex join condition involving multiple columns to measure matching overhead", "write_sql": "MERGE INTO merge_ops_lineitem_target AS target\nUSING (\n SELECT l.*, o.o_custkey\n FROM lineitem l\n JOIN orders o ON l.l_orderkey = o.o_orderkey\n WHERE l.l_orderkey BETWEEN 1 AND 10\n) AS source\nON target.l_orderkey = source.l_orderkey\n AND target.l_linenumber = source.l_linenumber\nWHEN MATCHED THEN\n UPDATE SET l_comment = 'merge_multi_key'\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.l_orderkey, source.l_partkey, source.l_suppkey, source.l_linenumber,\n source.l_quantity, source.l_extendedprice, source.l_discount, source.l_tax,\n source.l_returnflag, source.l_linestatus, source.l_shipdate, source.l_commitdate,\n source.l_receiptdate, source.l_shipinstruct, source.l_shipmode, source.l_comment)\n", "validation_queries": [{"id": "verify_join_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_lineitem_target\nWHERE l_orderkey BETWEEN 1 AND 10\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_lineitem_target WHERE l_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_from_subquery_aggregated", "description": "Tests MERGE with aggregated subquery source to measure aggregation plus merge overhead", "write_sql": "MERGE INTO merge_ops_summary_target AS target\nUSING (\n SELECT o_custkey, SUM(o_totalprice) AS total_price, COUNT(*) AS order_count\n FROM orders\n WHERE o_orderkey <= 500\n GROUP BY o_custkey\n) AS source\nON target.o_custkey = source.o_custkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.total_price,\n order_count = source.order_count\nWHEN NOT MATCHED THEN\n INSERT VALUES (NULL, source.o_custkey, NULL, source.total_price, NULL, source.order_count)\n", "validation_queries": [{"id": "verify_agg_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_summary_target WHERE order_count > 0", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_summary_target\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_conditional_update", "description": "Tests MERGE with conditional WHEN MATCHED clause to measure predicate evaluation overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED AND source.o_totalprice > 100000 THEN\n UPDATE SET o_comment = 'merge_high_value',\n o_orderpriority = '1-URGENT'\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_low_value'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_conditional", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_comment LIKE 'merge_%'\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target SET o_comment = '', o_orderpriority = '5-LOW'\nWHERE o_comment LIKE 'merge_%'\n", "platform_overrides": {"datafusion": null, "ducklake": null}} -- {"id": "merge_conditional_insert", "description": "Tests MERGE with conditional WHEN NOT MATCHED clause to measure insert filtering overhead", "write_sql": "MERGE INTO merge_ops_source AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 100\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED AND source.o_totalprice > 50000 THEN\n INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus,\n source.o_totalprice, source.o_orderdate, source.o_orderpriority,\n source.o_clerk, source.o_shippriority, 'high_value_insert')\n", "validation_queries": [{"id": "verify_filtered_insert", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_source\nWHERE o_comment = 'high_value_insert'\n", "expected_rows": 1, "expected_rows_min": 0}], "cleanup_sql": "DELETE FROM merge_ops_source WHERE o_orderkey BETWEEN 1 AND 100\n", "platform_overrides": {"datafusion": null}} +- {"id": "merge_with_join_condition", "description": "Tests MERGE with complex join condition involving multiple columns to measure matching overhead", "write_sql": "MERGE INTO merge_ops_lineitem_target AS target\nUSING (\n SELECT l.*, o.o_custkey\n FROM lineitem l\n JOIN orders o ON l.l_orderkey = o.o_orderkey\n WHERE l.l_orderkey BETWEEN 1 AND 10\n) AS source\nON target.l_orderkey = source.l_orderkey\n AND target.l_linenumber = source.l_linenumber\nWHEN MATCHED THEN\n UPDATE SET l_comment = 'merge_multi_key'\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.l_orderkey, source.l_partkey, source.l_suppkey, source.l_linenumber,\n source.l_quantity, source.l_extendedprice, source.l_discount, source.l_tax,\n source.l_returnflag, source.l_linestatus, source.l_shipdate, source.l_commitdate,\n source.l_receiptdate, source.l_shipinstruct, source.l_shipmode, source.l_comment)\n", "validation_queries": [{"id": "verify_join_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_lineitem_target\nWHERE l_orderkey BETWEEN 1 AND 10\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_lineitem_target WHERE l_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_lineitem_target AS target\nUSING (\n SELECT l.*, o.o_custkey\n FROM lineitem l\n JOIN orders o ON l.l_orderkey = o.o_orderkey\n WHERE l.l_orderkey BETWEEN 1 AND 10\n) AS source\nON target.l_orderkey = source.l_orderkey\n AND target.l_linenumber = source.l_linenumber\nWHEN MATCHED THEN\n UPDATE SET l_comment = 'merge_multi_key'\nWHEN NOT MATCHED THEN\n INSERT (l_orderkey, l_partkey, l_suppkey, l_linenumber, l_quantity, l_extendedprice, l_discount, l_tax, l_returnflag, l_linestatus, l_shipdate, l_commitdate, l_receiptdate, l_shipinstruct, l_shipmode, l_comment) VALUES (source.l_orderkey, source.l_partkey, source.l_suppkey, source.l_linenumber,\n source.l_quantity, source.l_extendedprice, source.l_discount, source.l_tax,\n source.l_returnflag, source.l_linestatus, source.l_shipdate, source.l_commitdate,\n source.l_receiptdate, source.l_shipinstruct, source.l_shipmode, source.l_comment)\n", "datafusion": null}} +- {"id": "merge_from_subquery_aggregated", "description": "Tests MERGE with aggregated subquery source to measure aggregation plus merge overhead", "write_sql": "MERGE INTO merge_ops_summary_target AS target\nUSING (\n SELECT o_custkey, SUM(o_totalprice) AS total_price, COUNT(*) AS order_count\n FROM orders\n WHERE o_orderkey <= 500\n GROUP BY o_custkey\n) AS source\nON target.o_custkey = source.o_custkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.total_price,\n order_count = source.order_count\nWHEN NOT MATCHED THEN\n INSERT VALUES (NULL, source.o_custkey, NULL, source.total_price, NULL, source.order_count)\n", "validation_queries": [{"id": "verify_agg_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_summary_target WHERE order_count > 0", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_summary_target\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_summary_target AS target\nUSING (\n SELECT o_custkey, SUM(o_totalprice) AS total_price, COUNT(*) AS order_count\n FROM orders\n WHERE o_orderkey <= 500\n GROUP BY o_custkey\n) AS source\nON target.o_custkey = source.o_custkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.total_price,\n order_count = source.order_count\nWHEN NOT MATCHED THEN\n INSERT (o_orderkey, o_custkey, o_orderdate, o_totalprice, customer_name, order_count) VALUES (NULL, source.o_custkey, NULL, source.total_price, NULL, source.order_count)\n", "datafusion": null}} +- {"id": "merge_conditional_update", "description": "Tests MERGE with conditional WHEN MATCHED clause to measure predicate evaluation overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED AND source.o_totalprice > 100000 THEN\n UPDATE SET o_comment = 'merge_high_value',\n o_orderpriority = '1-URGENT'\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_low_value'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_conditional", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_comment LIKE 'merge_%'\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target SET o_comment = '', o_orderpriority = '5-LOW'\nWHERE o_comment LIKE 'merge_%'\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED AND source.o_totalprice > 100000 THEN\n UPDATE SET o_comment = 'merge_high_value',\n o_orderpriority = '1-URGENT'\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_low_value'\nWHEN NOT MATCHED THEN INSERT *\n", "bigquery": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED AND source.o_totalprice > 100000 THEN\n UPDATE SET o_comment = 'merge_high_value',\n o_orderpriority = '1-URGENT'\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_low_value'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "snowflake": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED AND source.o_totalprice > 100000 THEN\n UPDATE SET o_comment = 'merge_high_value',\n o_orderpriority = '1-URGENT'\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_low_value'\nWHEN NOT MATCHED THEN INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus, source.o_totalprice, source.o_orderdate, source.o_orderpriority, source.o_clerk, source.o_shippriority, source.o_comment)\n", "datafusion": null, "ducklake": null}} +- {"id": "merge_conditional_insert", "description": "Tests MERGE with conditional WHEN NOT MATCHED clause to measure insert filtering overhead", "write_sql": "MERGE INTO merge_ops_source AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 100\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED AND source.o_totalprice > 50000 THEN\n INSERT VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus,\n source.o_totalprice, source.o_orderdate, source.o_orderpriority,\n source.o_clerk, source.o_shippriority, 'high_value_insert')\n", "validation_queries": [{"id": "verify_filtered_insert", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_source\nWHERE o_comment = 'high_value_insert'\n", "expected_rows": 1, "expected_rows_min": 0}], "cleanup_sql": "DELETE FROM merge_ops_source WHERE o_orderkey BETWEEN 1 AND 100\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_source AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 100\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_matched'\nWHEN NOT MATCHED AND source.o_totalprice > 50000 THEN\n INSERT (o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate, o_orderpriority, o_clerk, o_shippriority, o_comment) VALUES (source.o_orderkey, source.o_custkey, source.o_orderstatus,\n source.o_totalprice, source.o_orderdate, source.o_orderpriority,\n source.o_clerk, source.o_shippriority, 'high_value_insert')\n", "datafusion": null}} - {"id": "merge_multi_column_update", "description": "Tests MERGE updating 5 plus columns to measure multi-column update overhead in MERGE", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_totalprice * 1.05 AS o_totalprice,\n '1-URGENT' AS o_orderpriority,\n 'Clerk#000000999' AS o_clerk,\n o_shippriority + 1 AS o_shippriority,\n 'merge_multi_col' AS o_comment\n FROM merge_ops_target\n WHERE o_orderkey <= (SELECT MIN(o_orderkey) + 50 FROM merge_ops_target)\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET\n o_totalprice = source.o_totalprice,\n o_orderpriority = source.o_orderpriority,\n o_clerk = source.o_clerk,\n o_shippriority = source.o_shippriority,\n o_comment = source.o_comment\n", "validation_queries": [{"id": "verify_multi_col_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_multi_col'", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target\nSET o_totalprice = o_totalprice / 1.05,\n o_orderpriority = '5-LOW',\n o_clerk = 'Clerk#000000001',\n o_shippriority = 0,\n o_comment = ''\nWHERE o_comment = 'merge_multi_col'\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_computed_values", "description": "Tests MERGE with computed values using expressions to measure computation overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_totalprice * 1.15 + 100.0 AS computed_price,\n CASE\n WHEN o_totalprice < 100000 THEN '5-LOW'\n WHEN o_totalprice < 250000 THEN '3-MEDIUM'\n ELSE '1-URGENT'\n END AS computed_priority\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.computed_price,\n o_orderpriority = source.computed_priority,\n o_comment = 'merge_computed'\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.o_orderkey, 1, 'O', source.computed_price, CURRENT_DATE,\n source.computed_priority, 'Clerk#000000001', 0, 'merge_computed')\n", "validation_queries": [{"id": "verify_computed_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_computed'", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target\nSET o_totalprice = (o_totalprice - 100.0) / 1.15,\n o_orderpriority = '5-LOW',\n o_comment = ''\nWHERE o_comment = 'merge_computed'\n", "platform_overrides": {"datafusion": null}} +- {"id": "merge_computed_values", "description": "Tests MERGE with computed values using expressions to measure computation overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_totalprice * 1.15 + 100.0 AS computed_price,\n CASE\n WHEN o_totalprice < 100000 THEN '5-LOW'\n WHEN o_totalprice < 250000 THEN '3-MEDIUM'\n ELSE '1-URGENT'\n END AS computed_priority\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.computed_price,\n o_orderpriority = source.computed_priority,\n o_comment = 'merge_computed'\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.o_orderkey, 1, 'O', source.computed_price, CURRENT_DATE,\n source.computed_priority, 'Clerk#000000001', 0, 'merge_computed')\n", "validation_queries": [{"id": "verify_computed_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_computed'", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target\nSET o_totalprice = (o_totalprice - 100.0) / 1.15,\n o_orderpriority = '5-LOW',\n o_comment = ''\nWHERE o_comment = 'merge_computed'\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_totalprice * 1.15 + 100.0 AS computed_price,\n CASE\n WHEN o_totalprice < 100000 THEN '5-LOW'\n WHEN o_totalprice < 250000 THEN '3-MEDIUM'\n ELSE '1-URGENT'\n END AS computed_priority\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.computed_price,\n o_orderpriority = source.computed_priority,\n o_comment = 'merge_computed'\nWHEN NOT MATCHED THEN\n INSERT (o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate, o_orderpriority, o_clerk, o_shippriority, o_comment) VALUES (source.o_orderkey, 1, 'O', source.computed_price, CURRENT_DATE,\n source.computed_priority, 'Clerk#000000001', 0, 'merge_computed')\n", "datafusion": null}} - {"id": "merge_string_operations", "description": "Tests MERGE with string manipulation functions to measure string processing overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n CONCAT('ORDER-', CAST(o_orderkey AS VARCHAR), '-', o_orderpriority) AS computed_comment\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = source.computed_comment\n", "validation_queries": [{"id": "verify_string_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_comment LIKE 'ORDER-%'\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target SET o_comment = '' WHERE o_comment LIKE 'ORDER-%'\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_date_arithmetic", "description": "Tests MERGE with date calculations to measure temporal operation overhead in MERGE", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_orderdate + INTERVAL '30' DAY AS shifted_date,\n 'merge_date_shift' AS marker\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_orderdate = source.shifted_date,\n o_comment = source.marker\n", "validation_queries": [{"id": "verify_date_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_date_shift'", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target\nSET o_orderdate = o_orderdate - INTERVAL '30' DAY,\n o_comment = ''\nWHERE o_comment = 'merge_date_shift'\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_with_cte_source", "description": "Tests MERGE with CTE as source to measure CTE materialization overhead", "write_sql": "WITH enriched_orders AS (\n SELECT\n o.o_orderkey,\n o.o_totalprice,\n c.c_name,\n c.c_mktsegment\n FROM orders o\n JOIN customer c ON o.o_custkey = c.c_custkey\n WHERE o.o_orderkey BETWEEN 1 AND 50\n)\nMERGE INTO merge_ops_summary_target AS target\nUSING enriched_orders AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n customer_name = source.c_name\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.o_orderkey, NULL, NULL, source.o_totalprice, source.c_name, NULL)\n", "validation_queries": [{"id": "verify_cte_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_summary_target\nWHERE o_orderkey BETWEEN 1 AND 50\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_summary_target WHERE o_orderkey BETWEEN 1 AND 50\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_returning_clause", "description": "Tests MERGE...RETURNING to measure overhead of returning merged row data", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_returning'\nWHEN NOT MATCHED THEN INSERT\nRETURNING o_orderkey, o_totalprice, o_comment\n", "validation_queries": [{"id": "verify_returning_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_orderkey BETWEEN 1 AND 10\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target WHERE o_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"mysql": null, "bigquery": null, "datafusion": null, "starrocks": null, "ducklake": null}} -- {"id": "merge_error_handling", "description": "Tests MERGE with potential constraint violations to measure error handling overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 50\n UNION ALL\n SELECT * FROM orders WHERE o_orderkey BETWEEN 45 AND 55\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_with_dups'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_error_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_orderkey BETWEEN 1 AND 55\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target WHERE o_orderkey BETWEEN 1 AND 55\n", "platform_overrides": {"datafusion": null}} +- {"id": "merge_date_arithmetic", "description": "Tests MERGE with date calculations to measure temporal operation overhead in MERGE", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_orderdate + INTERVAL '30' DAY AS shifted_date,\n 'merge_date_shift' AS marker\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_orderdate = source.shifted_date,\n o_comment = source.marker\n", "validation_queries": [{"id": "verify_date_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target WHERE o_comment = 'merge_date_shift'", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "UPDATE merge_ops_target\nSET o_orderdate = o_orderdate - INTERVAL '30' DAY,\n o_comment = ''\nWHERE o_comment = 'merge_date_shift'\n", "platform_overrides": {"bigquery": null, "datafusion": null}} +- {"id": "merge_with_cte_source", "description": "Tests MERGE with CTE as source to measure CTE materialization overhead", "write_sql": "WITH enriched_orders AS (\n SELECT\n o.o_orderkey,\n o.o_totalprice,\n c.c_name,\n c.c_mktsegment\n FROM orders o\n JOIN customer c ON o.o_custkey = c.c_custkey\n WHERE o.o_orderkey BETWEEN 1 AND 50\n)\nMERGE INTO merge_ops_summary_target AS target\nUSING enriched_orders AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n customer_name = source.c_name\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.o_orderkey, NULL, NULL, source.o_totalprice, source.c_name, NULL)\n", "validation_queries": [{"id": "verify_cte_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_summary_target\nWHERE o_orderkey BETWEEN 1 AND 50\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_summary_target WHERE o_orderkey BETWEEN 1 AND 50\n", "platform_overrides": {"databricks": "WITH enriched_orders AS (\n SELECT\n o.o_orderkey,\n o.o_totalprice,\n c.c_name,\n c.c_mktsegment\n FROM orders o\n JOIN customer c ON o.o_custkey = c.c_custkey\n WHERE o.o_orderkey BETWEEN 1 AND 50\n)\nMERGE INTO merge_ops_summary_target AS target\nUSING enriched_orders AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n customer_name = source.c_name\nWHEN NOT MATCHED THEN\n INSERT (o_orderkey, o_custkey, o_orderdate, o_totalprice, customer_name, order_count) VALUES (source.o_orderkey, NULL, NULL, source.o_totalprice, source.c_name, NULL)\n", "bigquery": "MERGE INTO merge_ops_summary_target AS target\nUSING (\n SELECT o.o_orderkey, o.o_totalprice, c.c_name, c.c_mktsegment\n FROM orders o JOIN customer c ON o.o_custkey = c.c_custkey\n WHERE o.o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n customer_name = source.c_name\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.o_orderkey, NULL, NULL, source.o_totalprice, source.c_name, NULL)\n", "snowflake": "MERGE INTO merge_ops_summary_target AS target\nUSING (\n SELECT o.o_orderkey, o.o_totalprice, c.c_name, c.c_mktsegment\n FROM orders o JOIN customer c ON o.o_custkey = c.c_custkey\n WHERE o.o_orderkey BETWEEN 1 AND 50\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_totalprice = source.o_totalprice,\n customer_name = source.c_name\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.o_orderkey, NULL, NULL, source.o_totalprice, source.c_name, NULL)\n", "datafusion": null}} +- {"id": "merge_returning_clause", "description": "Tests MERGE...RETURNING to measure overhead of returning merged row data", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 10\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_returning'\nWHEN NOT MATCHED THEN INSERT\nRETURNING o_orderkey, o_totalprice, o_comment\n", "validation_queries": [{"id": "verify_returning_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_orderkey BETWEEN 1 AND 10\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target WHERE o_orderkey BETWEEN 1 AND 10\n", "platform_overrides": {"databricks": null, "mysql": null, "bigquery": null, "datafusion": null, "starrocks": null, "ducklake": null, "snowflake": null}} +- {"id": "merge_error_handling", "description": "Tests MERGE with potential constraint violations to measure error handling overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT * FROM orders WHERE o_orderkey BETWEEN 1 AND 50\n UNION ALL\n SELECT * FROM orders WHERE o_orderkey BETWEEN 45 AND 55\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED THEN\n UPDATE SET o_comment = 'merge_with_dups'\nWHEN NOT MATCHED THEN INSERT\n", "validation_queries": [{"id": "verify_error_merge", "sql": "SELECT COUNT(*) as cnt FROM merge_ops_target\nWHERE o_orderkey BETWEEN 1 AND 55\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target WHERE o_orderkey BETWEEN 1 AND 55\n", "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "datafusion": null}} - {"id": "delete_gdpr_suppliers_1pct", "description": "Tests GDPR-compliant deletion with IN subquery to measure deletion-by-list performance (1% of suppliers)", "write_sql": "DELETE FROM delete_ops_lineitem\nWHERE l_suppkey IN (\n SELECT DISTINCT l_suppkey\n FROM lineitem\n WHERE l_suppkey <= (SELECT MAX(l_suppkey) * 0.01 FROM lineitem)\n)\n", "validation_queries": [{"id": "verify_gdpr_deletion", "sql": "SELECT\n CASE\n WHEN COUNT(DISTINCT l_suppkey) = 0 THEN 1\n ELSE 0\n END as validation_passed\nFROM delete_ops_lineitem\nWHERE l_suppkey <= (SELECT MAX(l_suppkey) * 0.01 FROM lineitem)\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "INSERT INTO delete_ops_lineitem\nSELECT * FROM lineitem\nWHERE l_suppkey <= (SELECT MAX(l_suppkey) * 0.01 FROM lineitem)\n", "platform_overrides": {"datafusion": null, "starrocks": null}} - {"id": "delete_gdpr_suppliers_5pct", "description": "Tests GDPR-compliant deletion with IN subquery at medium scale to measure deletion-by-list performance (5% of suppliers)", "write_sql": "DELETE FROM delete_ops_lineitem\nWHERE l_suppkey IN (\n SELECT DISTINCT l_suppkey\n FROM lineitem\n WHERE l_suppkey <= (SELECT MAX(l_suppkey) * 0.05 FROM lineitem)\n)\n", "validation_queries": [{"id": "verify_gdpr_deletion_medium", "sql": "SELECT\n CASE\n WHEN COUNT(DISTINCT l_suppkey) = 0 THEN 1\n ELSE 0\n END as validation_passed\nFROM delete_ops_lineitem\nWHERE l_suppkey <= (SELECT MAX(l_suppkey) * 0.05 FROM lineitem)\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "INSERT INTO delete_ops_lineitem\nSELECT * FROM lineitem\nWHERE l_suppkey <= (SELECT MAX(l_suppkey) * 0.05 FROM lineitem)\n", "platform_overrides": {"datafusion": null, "starrocks": null}} -- {"id": "merge_etl_aggregation_pattern", "description": "Tests ETL aggregation pattern with running totals and aggregate computations to measure aggregation overhead in MERGE", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n l_orderkey,\n SUM(l_extendedprice * (1 - l_discount)) as computed_revenue,\n MAX(l_shipdate) as latest_ship_date,\n COUNT(*) as line_count\n FROM lineitem\n WHERE l_orderkey BETWEEN 1 AND 1000\n GROUP BY l_orderkey\n) AS source\nON target.o_orderkey = source.l_orderkey\nWHEN MATCHED THEN\n UPDATE SET\n o_totalprice = target.o_totalprice + source.computed_revenue,\n o_comment = 'etl_agg_lines_' || CAST(source.line_count AS VARCHAR)\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.l_orderkey, 1, 'O', source.computed_revenue, DATE '1998-01-01', '1-URGENT', 'Clerk#000000001', 0, 'etl_new')\n", "validation_queries": [{"id": "verify_etl_aggregation", "sql": "SELECT COUNT(*) as updated_count\nFROM merge_ops_target\nWHERE o_comment LIKE 'etl_agg%' OR o_comment = 'etl_new'\n", "expected_rows": 1, "expected_rows_min": 1}, {"id": "verify_etl_aggregation_values", "sql": "SELECT\n CASE\n WHEN COUNT(*) > 0 AND MIN(o_totalprice) IS NOT NULL THEN 1\n ELSE 0\n END as has_valid_aggregations\nFROM merge_ops_target\nWHERE o_comment LIKE 'etl_agg%'\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target\nWHERE o_comment LIKE 'etl_agg%' OR o_comment = 'etl_new'\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_deduplication_window_function", "description": "Tests MERGE with window function deduplication using ROW_NUMBER to measure duplicate elimination overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_custkey,\n o_orderstatus,\n o_totalprice,\n o_orderdate,\n o_orderpriority,\n o_clerk,\n o_shippriority,\n o_comment,\n ROW_NUMBER() OVER (\n PARTITION BY o_orderkey\n ORDER BY o_orderdate DESC, o_totalprice DESC\n ) as rn\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 500\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED AND source.rn = 1 THEN\n UPDATE SET\n o_totalprice = source.o_totalprice,\n o_comment = 'dedup_update'\nWHEN NOT MATCHED AND source.rn = 1 THEN\n INSERT VALUES (\n source.o_orderkey, source.o_custkey, source.o_orderstatus,\n source.o_totalprice, source.o_orderdate, source.o_orderpriority,\n source.o_clerk, source.o_shippriority, 'dedup_insert'\n )\n", "validation_queries": [{"id": "verify_deduplication", "sql": "SELECT COUNT(*) as dedup_count\nFROM merge_ops_target\nWHERE o_comment IN ('dedup_update', 'dedup_insert')\n", "expected_rows": 1, "expected_rows_min": 1}, {"id": "verify_no_duplicates", "sql": "SELECT\n CASE\n WHEN MAX(dup_count) = 1 THEN 1\n ELSE 0\n END as no_duplicates\nFROM (\n SELECT o_orderkey, COUNT(*) as dup_count\n FROM merge_ops_target\n WHERE o_comment IN ('dedup_update', 'dedup_insert')\n GROUP BY o_orderkey\n)\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target\nWHERE o_comment IN ('dedup_update', 'dedup_insert')\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_scd_type2_basic", "description": "Tests canonical SCD Type 2 dimension maintenance (close-old plus insert-new) via portable UPDATE then INSERT, keyed on a business key, to measure history-retaining merge overhead", "write_sql": "UPDATE scd2_ops_dim_customer\nSET is_current = false,\n valid_to = (SELECT MIN(s.effective_ts) FROM scd2_ops_stage_customer s\n WHERE s.c_custkey = scd2_ops_dim_customer.c_custkey AND s.change_type = 'changed')\nWHERE scd2_ops_dim_customer.is_current = true\n AND EXISTS (SELECT 1 FROM scd2_ops_stage_customer s\n WHERE s.c_custkey = scd2_ops_dim_customer.c_custkey\n AND s.change_type = 'changed'\n AND s.row_hash <> scd2_ops_dim_customer.row_hash);\nINSERT INTO scd2_ops_dim_customer\nSELECT (SELECT MAX(sk) FROM scd2_ops_dim_customer) + ROW_NUMBER() OVER (ORDER BY s.c_custkey),\n s.c_custkey, s.c_name, s.c_address, s.c_acctbal, s.c_mktsegment, s.row_hash,\n true, s.effective_ts, DATE '9999-12-31'\nFROM scd2_ops_stage_customer s\nWHERE (s.change_type = 'new'\n OR (s.change_type = 'changed'\n AND EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = false AND d.valid_to = s.effective_ts)))\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d2\n WHERE d2.c_custkey = s.c_custkey AND d2.is_current = true)\n", "validation_queries": [{"id": "at_most_one_current_per_business_key", "sql": "SELECT c_custkey\nFROM scd2_ops_dim_customer\nWHERE is_current = true\nGROUP BY c_custkey\nHAVING COUNT(*) <> 1\n", "expected_rows": 0}, {"id": "every_changed_key_closed_with_stamp", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'changed'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = false AND d.valid_to = s.effective_ts)\n", "expected_rows": 0}, {"id": "every_changed_key_has_current_staged_version", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'changed'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = true AND d.row_hash = s.row_hash)\n", "expected_rows": 0}, {"id": "every_new_key_inserted_as_current", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'new'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = true)\n", "expected_rows": 0}, {"id": "basic_inserts_expected_new_version_count", "sql": "SELECT 1 WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type IN ('changed', 'new'))) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type IN ('changed', 'new'))\n", "expected_rows": 0}, {"id": "basic_closes_expected_changed_count", "sql": "SELECT 1 WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE is_current = false\n AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer\n WHERE change_type = 'changed')) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n", "expected_rows": 0}], "cleanup_sql": "DELETE FROM scd2_ops_dim_customer\nWHERE (c_custkey IN (SELECT c_custkey FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n AND valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'changed'))\n OR (c_custkey IN (SELECT c_custkey FROM scd2_ops_stage_customer WHERE change_type = 'new')\n AND valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'new')\n AND sk > (SELECT MAX(c_custkey) FROM scd2_ops_stage_customer));\nUPDATE scd2_ops_dim_customer SET is_current = true, valid_to = DATE '9999-12-31'\nWHERE is_current = false AND c_custkey IN (SELECT c_custkey FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n", "platform_overrides": {"datafusion": null}} +- {"id": "merge_etl_aggregation_pattern", "description": "Tests ETL aggregation pattern with running totals and aggregate computations to measure aggregation overhead in MERGE", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n l_orderkey,\n SUM(l_extendedprice * (1 - l_discount)) as computed_revenue,\n MAX(l_shipdate) as latest_ship_date,\n COUNT(*) as line_count\n FROM lineitem\n WHERE l_orderkey BETWEEN 1 AND 1000\n GROUP BY l_orderkey\n) AS source\nON target.o_orderkey = source.l_orderkey\nWHEN MATCHED THEN\n UPDATE SET\n o_totalprice = target.o_totalprice + source.computed_revenue,\n o_comment = 'etl_agg_lines_' || CAST(source.line_count AS VARCHAR)\nWHEN NOT MATCHED THEN\n INSERT VALUES (source.l_orderkey, 1, 'O', source.computed_revenue, DATE '1998-01-01', '1-URGENT', 'Clerk#000000001', 0, 'etl_new')\n", "validation_queries": [{"id": "verify_etl_aggregation", "sql": "SELECT COUNT(*) as updated_count\nFROM merge_ops_target\nWHERE o_comment LIKE 'etl_agg%' OR o_comment = 'etl_new'\n", "expected_rows": 1, "expected_rows_min": 1}, {"id": "verify_etl_aggregation_values", "sql": "SELECT\n CASE\n WHEN COUNT(*) > 0 AND MIN(o_totalprice) IS NOT NULL THEN 1\n ELSE 0\n END as has_valid_aggregations\nFROM merge_ops_target\nWHERE o_comment LIKE 'etl_agg%'\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target\nWHERE o_comment LIKE 'etl_agg%' OR o_comment = 'etl_new'\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n l_orderkey,\n SUM(l_extendedprice * (1 - l_discount)) as computed_revenue,\n MAX(l_shipdate) as latest_ship_date,\n COUNT(*) as line_count\n FROM lineitem\n WHERE l_orderkey BETWEEN 1 AND 1000\n GROUP BY l_orderkey\n) AS source\nON target.o_orderkey = source.l_orderkey\nWHEN MATCHED THEN\n UPDATE SET\n o_totalprice = target.o_totalprice + source.computed_revenue,\n o_comment = 'etl_agg_lines_' || CAST(source.line_count AS VARCHAR)\nWHEN NOT MATCHED THEN\n INSERT (o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate, o_orderpriority, o_clerk, o_shippriority, o_comment) VALUES (source.l_orderkey, 1, 'O', source.computed_revenue, DATE '1998-01-01', '1-URGENT', 'Clerk#000000001', 0, 'etl_new')\n", "datafusion": null}} +- {"id": "merge_deduplication_window_function", "description": "Tests MERGE with window function deduplication using ROW_NUMBER to measure duplicate elimination overhead", "write_sql": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_custkey,\n o_orderstatus,\n o_totalprice,\n o_orderdate,\n o_orderpriority,\n o_clerk,\n o_shippriority,\n o_comment,\n ROW_NUMBER() OVER (\n PARTITION BY o_orderkey\n ORDER BY o_orderdate DESC, o_totalprice DESC\n ) as rn\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 500\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED AND source.rn = 1 THEN\n UPDATE SET\n o_totalprice = source.o_totalprice,\n o_comment = 'dedup_update'\nWHEN NOT MATCHED AND source.rn = 1 THEN\n INSERT VALUES (\n source.o_orderkey, source.o_custkey, source.o_orderstatus,\n source.o_totalprice, source.o_orderdate, source.o_orderpriority,\n source.o_clerk, source.o_shippriority, 'dedup_insert'\n )\n", "validation_queries": [{"id": "verify_deduplication", "sql": "SELECT COUNT(*) as dedup_count\nFROM merge_ops_target\nWHERE o_comment IN ('dedup_update', 'dedup_insert')\n", "expected_rows": 1, "expected_rows_min": 1}, {"id": "verify_no_duplicates", "sql": "SELECT\n CASE\n WHEN MAX(dup_count) = 1 THEN 1\n ELSE 0\n END as no_duplicates\nFROM (\n SELECT o_orderkey, COUNT(*) as dup_count\n FROM merge_ops_target\n WHERE o_comment IN ('dedup_update', 'dedup_insert')\n GROUP BY o_orderkey\n)\n", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DELETE FROM merge_ops_target\nWHERE o_comment IN ('dedup_update', 'dedup_insert')\n", "platform_overrides": {"databricks": "MERGE INTO merge_ops_target AS target\nUSING (\n SELECT\n o_orderkey,\n o_custkey,\n o_orderstatus,\n o_totalprice,\n o_orderdate,\n o_orderpriority,\n o_clerk,\n o_shippriority,\n o_comment,\n ROW_NUMBER() OVER (\n PARTITION BY o_orderkey\n ORDER BY o_orderdate DESC, o_totalprice DESC\n ) as rn\n FROM orders\n WHERE o_orderkey BETWEEN 1 AND 500\n) AS source\nON target.o_orderkey = source.o_orderkey\nWHEN MATCHED AND source.rn = 1 THEN\n UPDATE SET\n o_totalprice = source.o_totalprice,\n o_comment = 'dedup_update'\nWHEN NOT MATCHED AND source.rn = 1 THEN\n INSERT (o_orderkey, o_custkey, o_orderstatus, o_totalprice, o_orderdate, o_orderpriority, o_clerk, o_shippriority, o_comment) VALUES (\n source.o_orderkey, source.o_custkey, source.o_orderstatus,\n source.o_totalprice, source.o_orderdate, source.o_orderpriority,\n source.o_clerk, source.o_shippriority, 'dedup_insert'\n )\n", "datafusion": null}} +- {"id": "merge_scd_type2_basic", "description": "Tests canonical SCD Type 2 dimension maintenance (close-old plus insert-new) via portable UPDATE then INSERT, keyed on a business key, to measure history-retaining merge overhead", "write_sql": "UPDATE scd2_ops_dim_customer\nSET is_current = false,\n valid_to = (SELECT MIN(s.effective_ts) FROM scd2_ops_stage_customer s\n WHERE s.c_custkey = scd2_ops_dim_customer.c_custkey AND s.change_type = 'changed')\nWHERE scd2_ops_dim_customer.is_current = true\n AND EXISTS (SELECT 1 FROM scd2_ops_stage_customer s\n WHERE s.c_custkey = scd2_ops_dim_customer.c_custkey\n AND s.change_type = 'changed'\n AND s.row_hash <> scd2_ops_dim_customer.row_hash);\nINSERT INTO scd2_ops_dim_customer\nSELECT (SELECT MAX(sk) FROM scd2_ops_dim_customer) + ROW_NUMBER() OVER (ORDER BY s.c_custkey),\n s.c_custkey, s.c_name, s.c_address, s.c_acctbal, s.c_mktsegment, s.row_hash,\n true, s.effective_ts, DATE '9999-12-31'\nFROM scd2_ops_stage_customer s\nWHERE (s.change_type = 'new'\n OR (s.change_type = 'changed'\n AND EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = false AND d.valid_to = s.effective_ts)))\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d2\n WHERE d2.c_custkey = s.c_custkey AND d2.is_current = true)\n", "validation_queries": [{"id": "at_most_one_current_per_business_key", "sql": "SELECT c_custkey\nFROM scd2_ops_dim_customer\nWHERE is_current = true\nGROUP BY c_custkey\nHAVING COUNT(*) <> 1\n", "expected_rows": 0}, {"id": "every_changed_key_closed_with_stamp", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'changed'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = false AND d.valid_to = s.effective_ts)\n", "expected_rows": 0}, {"id": "every_changed_key_has_current_staged_version", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'changed'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = true AND d.row_hash = s.row_hash)\n", "expected_rows": 0}, {"id": "every_new_key_inserted_as_current", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'new'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = true)\n", "expected_rows": 0}, {"id": "basic_inserts_expected_new_version_count", "sql": "SELECT 1 WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type IN ('changed', 'new'))) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type IN ('changed', 'new'))\n", "expected_rows": 0, "platform_overrides": {"bigquery": "SELECT 1 FROM (SELECT 1 AS _one) AS _t WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type IN ('changed', 'new'))) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type IN ('changed', 'new'))\n"}}, {"id": "basic_closes_expected_changed_count", "sql": "SELECT 1 WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE is_current = false\n AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer\n WHERE change_type = 'changed')) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n", "expected_rows": 0, "platform_overrides": {"bigquery": "SELECT 1 FROM (SELECT 1 AS _one) AS _t WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE is_current = false\n AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer\n WHERE change_type = 'changed')) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n"}}], "cleanup_sql": "DELETE FROM scd2_ops_dim_customer\nWHERE (c_custkey IN (SELECT c_custkey FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n AND valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'changed'))\n OR (c_custkey IN (SELECT c_custkey FROM scd2_ops_stage_customer WHERE change_type = 'new')\n AND valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'new')\n AND sk > (SELECT MAX(c_custkey) FROM scd2_ops_stage_customer));\nUPDATE scd2_ops_dim_customer SET is_current = true, valid_to = DATE '9999-12-31'\nWHERE is_current = false AND c_custkey IN (SELECT c_custkey FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'changed')\n", "platform_overrides": {"datafusion": null}} - {"id": "merge_scd_type2_no_change", "description": "Tests SCD Type 2 idempotency by applying an unchanged batch whose row fingerprints match the dimension, so a correct implementation closes no rows and inserts zero new versions", "write_sql": "UPDATE scd2_ops_dim_customer\nSET is_current = false,\n valid_to = (SELECT MIN(s.effective_ts) FROM scd2_ops_stage_customer s\n WHERE s.c_custkey = scd2_ops_dim_customer.c_custkey AND s.change_type = 'unchanged')\nWHERE scd2_ops_dim_customer.is_current = true\n AND EXISTS (SELECT 1 FROM scd2_ops_stage_customer s\n WHERE s.c_custkey = scd2_ops_dim_customer.c_custkey\n AND s.change_type = 'unchanged'\n AND s.row_hash <> scd2_ops_dim_customer.row_hash);\nINSERT INTO scd2_ops_dim_customer\nSELECT (SELECT MAX(sk) FROM scd2_ops_dim_customer) + ROW_NUMBER() OVER (ORDER BY s.c_custkey),\n s.c_custkey, s.c_name, s.c_address, s.c_acctbal, s.c_mktsegment, s.row_hash,\n true, s.effective_ts, DATE '9999-12-31'\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'unchanged'\n AND EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = false AND d.valid_to = s.effective_ts)\n", "validation_queries": [{"id": "at_most_one_current_per_business_key", "sql": "SELECT c_custkey\nFROM scd2_ops_dim_customer\nWHERE is_current = true\nGROUP BY c_custkey\nHAVING COUNT(*) <> 1\n", "expected_rows": 0}, {"id": "no_rows_closed_by_batch", "sql": "SELECT c_custkey FROM scd2_ops_dim_customer\nWHERE is_current = false\n AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'unchanged')\n", "expected_rows": 0}, {"id": "no_new_versions_inserted", "sql": "SELECT c_custkey FROM scd2_ops_dim_customer\nWHERE valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'unchanged')\n", "expected_rows": 0}, {"id": "every_unchanged_key_has_current_version_matching_hash", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'unchanged'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = true AND d.row_hash = s.row_hash)\n", "expected_rows": 0}], "cleanup_sql": "DELETE FROM scd2_ops_dim_customer WHERE valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'unchanged');\nUPDATE scd2_ops_dim_customer SET is_current = true, valid_to = DATE '9999-12-31'\nWHERE is_current = false AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'unchanged')\n", "platform_overrides": {"datafusion": null}} -- {"id": "merge_scd_type2_new_keys_only", "description": "Tests the SCD Type 2 insert-only path where every staged business key is brand-new, so the operation appends current versions and closes no existing rows", "write_sql": "INSERT INTO scd2_ops_dim_customer\nSELECT (SELECT MAX(sk) FROM scd2_ops_dim_customer) + ROW_NUMBER() OVER (ORDER BY s.c_custkey),\n s.c_custkey, s.c_name, s.c_address, s.c_acctbal, s.c_mktsegment, s.row_hash,\n true, s.effective_ts, DATE '9999-12-31'\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'new'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d WHERE d.c_custkey = s.c_custkey)\n", "validation_queries": [{"id": "at_most_one_current_per_business_key", "sql": "SELECT c_custkey\nFROM scd2_ops_dim_customer\nWHERE is_current = true\nGROUP BY c_custkey\nHAVING COUNT(*) <> 1\n", "expected_rows": 0}, {"id": "every_new_key_present_as_current", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'new'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = true)\n", "expected_rows": 0}, {"id": "no_rows_closed", "sql": "SELECT c_custkey FROM scd2_ops_dim_customer\nWHERE is_current = false\n AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'new')\n", "expected_rows": 0}, {"id": "new_keys_only_inserts_expected_count", "sql": "SELECT 1 WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer\n WHERE change_type = 'new')) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type = 'new')\n", "expected_rows": 0}], "cleanup_sql": "DELETE FROM scd2_ops_dim_customer\nWHERE c_custkey IN (SELECT c_custkey FROM scd2_ops_stage_customer WHERE change_type = 'new')\n AND valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'new')\n AND sk <= (SELECT MAX(c_custkey) FROM scd2_ops_stage_customer)\n", "platform_overrides": {"datafusion": null}} -- {"id": "ddl_create_table_simple", "description": "Tests CREATE TABLE with simple schema with 5 columns and no constraints", "requires_setup": false, "write_sql": "CREATE TABLE test_simple (\n id INTEGER,\n name VARCHAR(100),\n value DECIMAL(15,2),\n created DATE,\n status VARCHAR(10)\n)\n", "validation_queries": [{"id": "table_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE table_name = 'test_simple'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_simple\n", "expected_rows_affected": 0} +- {"id": "merge_scd_type2_new_keys_only", "description": "Tests the SCD Type 2 insert-only path where every staged business key is brand-new, so the operation appends current versions and closes no existing rows", "write_sql": "INSERT INTO scd2_ops_dim_customer\nSELECT (SELECT MAX(sk) FROM scd2_ops_dim_customer) + ROW_NUMBER() OVER (ORDER BY s.c_custkey),\n s.c_custkey, s.c_name, s.c_address, s.c_acctbal, s.c_mktsegment, s.row_hash,\n true, s.effective_ts, DATE '9999-12-31'\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'new'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d WHERE d.c_custkey = s.c_custkey)\n", "validation_queries": [{"id": "at_most_one_current_per_business_key", "sql": "SELECT c_custkey\nFROM scd2_ops_dim_customer\nWHERE is_current = true\nGROUP BY c_custkey\nHAVING COUNT(*) <> 1\n", "expected_rows": 0}, {"id": "every_new_key_present_as_current", "sql": "SELECT s.c_custkey\nFROM scd2_ops_stage_customer s\nWHERE s.change_type = 'new'\n AND NOT EXISTS (SELECT 1 FROM scd2_ops_dim_customer d\n WHERE d.c_custkey = s.c_custkey AND d.is_current = true)\n", "expected_rows": 0}, {"id": "no_rows_closed", "sql": "SELECT c_custkey FROM scd2_ops_dim_customer\nWHERE is_current = false\n AND valid_to IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'new')\n", "expected_rows": 0}, {"id": "new_keys_only_inserts_expected_count", "sql": "SELECT 1 WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer\n WHERE change_type = 'new')) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type = 'new')\n", "expected_rows": 0, "platform_overrides": {"bigquery": "SELECT 1 FROM (SELECT 1 AS _one) AS _t WHERE (SELECT COUNT(*) FROM scd2_ops_dim_customer\n WHERE valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer\n WHERE change_type = 'new')) <> (SELECT COUNT(*) FROM scd2_ops_stage_customer WHERE change_type = 'new')\n"}}], "cleanup_sql": "DELETE FROM scd2_ops_dim_customer\nWHERE c_custkey IN (SELECT c_custkey FROM scd2_ops_stage_customer WHERE change_type = 'new')\n AND valid_from IN (SELECT effective_ts FROM scd2_ops_stage_customer WHERE change_type = 'new')\n AND sk <= (SELECT MAX(c_custkey) FROM scd2_ops_stage_customer)\n", "platform_overrides": {"datafusion": null}} +- {"id": "ddl_create_table_simple", "description": "Tests CREATE TABLE with simple schema with 5 columns and no constraints", "requires_setup": false, "write_sql": "CREATE TABLE test_simple (\n id INTEGER,\n name VARCHAR(100),\n value DECIMAL(15,2),\n created DATE,\n status VARCHAR(10)\n)\n", "validation_queries": [{"id": "table_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('test_simple')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_simple\n", "expected_rows_affected": 0} - {"id": "ddl_truncate_table_small", "requires_setup": false, "description": "Tests TRUNCATE TABLE on small staging table to measure fast delete baseline", "write_sql": "TRUNCATE TABLE ddl_truncate_target\n", "validation_queries": [{"id": "verify_empty", "sql": "SELECT * FROM ddl_truncate_target", "expected_rows": 0}], "platform_overrides": {"datafusion": null}} - {"id": "ddl_create_table_as_select_simple", "description": "Tests CREATE TABLE AS SELECT with simple SELECT to measure table creation from query results", "requires_setup": false, "write_sql": "CREATE TABLE orders_1995 AS\nSELECT *\nFROM orders\nWHERE o_orderdate >= DATE '1995-01-01'\n AND o_orderdate < DATE '1996-01-01'\n", "validation_queries": [{"id": "verify_ctas", "sql": "SELECT o_orderkey FROM orders_1995 LIMIT 10", "expected_rows": null, "expected_rows_min": 0, "expected_rows_max": 10}], "cleanup_sql": "DROP TABLE IF EXISTS orders_1995\n"} -- {"id": "ddl_create_table_with_constraints", "description": "Tests CREATE TABLE with PRIMARY KEY and NOT NULL constraints to measure constraint overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_constrained (\n id INTEGER PRIMARY KEY,\n name VARCHAR(100) NOT NULL,\n value DECIMAL(15,2) NOT NULL,\n created DATE NOT NULL,\n status VARCHAR(10)\n)\n", "validation_queries": [{"id": "table_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE table_name = 'test_constrained'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_constrained\n", "expected_rows_affected": 0, "platform_overrides": {"datafusion": null, "ducklake": null}} -- {"id": "ddl_create_table_with_index", "description": "Tests CREATE TABLE followed by CREATE INDEX to measure index creation overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_indexed (\n id INTEGER,\n name VARCHAR(100),\n value DECIMAL(15,2)\n);\nCREATE INDEX idx_test_value ON test_indexed(value)\n", "validation_queries": [{"id": "index_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE table_name = 'test_indexed'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_indexed\n", "expected_rows_affected": 0, "platform_overrides": {"datafusion": null, "starrocks": null, "ducklake": null}} -- {"id": "ddl_alter_table_add_column", "description": "Tests ALTER TABLE ADD COLUMN to measure schema modification overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_alter (\n id INTEGER,\n name VARCHAR(100)\n);\nALTER TABLE test_alter ADD COLUMN created DATE\n", "validation_queries": [{"id": "column_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE table_name = 'test_alter'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_alter\n", "expected_rows_affected": 0, "platform_overrides": {"datafusion": null}} -- {"id": "ddl_alter_table_drop_column", "description": "Tests ALTER TABLE DROP COLUMN to measure column removal overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_drop_col (\n id INTEGER,\n name VARCHAR(100),\n temp_col VARCHAR(50)\n);\nALTER TABLE test_drop_col DROP COLUMN temp_col\n", "validation_queries": [{"id": "table_modified", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE table_name = 'test_drop_col'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_drop_col\n", "expected_rows_affected": 0, "platform_overrides": {"datafusion": null}} -- {"id": "ddl_alter_table_rename_column", "description": "Tests ALTER TABLE RENAME COLUMN to measure column renaming overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_rename_col (\n id INTEGER,\n old_name VARCHAR(100)\n);\nALTER TABLE test_rename_col RENAME COLUMN old_name TO new_name\n", "validation_queries": [{"id": "column_renamed", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE table_name = 'test_rename_col'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_rename_col\n", "expected_rows_affected": 0, "platform_overrides": {"mysql": "CREATE TABLE test_rename_col (\n id INTEGER,\n old_name VARCHAR(100)\n);\nALTER TABLE test_rename_col CHANGE old_name new_name VARCHAR(100)\n", "starrocks": null, "datafusion": null}} -- {"id": "ddl_create_index_on_existing", "description": "Tests CREATE INDEX on populated table to measure index building on existing data", "requires_setup": false, "write_sql": "CREATE TABLE test_idx_existing AS SELECT * FROM orders WHERE o_orderkey <= 100;\nCREATE INDEX idx_existing_price ON test_idx_existing(o_totalprice)\n", "validation_queries": [{"id": "index_created", "sql": "SELECT COUNT(*) as cnt FROM test_idx_existing", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_idx_existing\n", "platform_overrides": {"datafusion": null, "starrocks": null, "ducklake": null}} -- {"id": "ddl_drop_index", "description": "Tests DROP INDEX to measure index removal overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_drop_idx (id INTEGER, value DECIMAL(15,2));\nCREATE INDEX idx_test_drop ON test_drop_idx(value);\nDROP INDEX idx_test_drop\n", "validation_queries": [{"id": "table_still_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE table_name = 'test_drop_idx'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_drop_idx\n", "expected_rows_affected": 0, "platform_overrides": {"mysql": "CREATE TABLE test_drop_idx (id INTEGER, value DECIMAL(15,2));\nCREATE INDEX idx_test_drop ON test_drop_idx(value);\nDROP INDEX idx_test_drop ON test_drop_idx\n", "datafusion": null, "starrocks": null, "ducklake": null}} -- {"id": "ddl_create_view_simple", "description": "Tests CREATE VIEW with simple SELECT to measure view creation overhead", "requires_setup": false, "write_sql": "CREATE VIEW orders_view AS\nSELECT o_orderkey, o_custkey, o_totalprice, o_orderdate\nFROM orders\nWHERE o_orderkey <= 1000\n", "validation_queries": [{"id": "view_exists", "sql": "SELECT table_name\nFROM information_schema.views\nWHERE table_name = 'orders_view'\n", "expected_rows": 1}], "cleanup_sql": "DROP VIEW IF EXISTS orders_view\n", "expected_rows_affected": 0} -- {"id": "ddl_drop_table", "description": "Tests DROP TABLE to measure table removal overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_drop AS SELECT * FROM orders WHERE o_orderkey <= 50;\nDROP TABLE test_drop\n", "validation_queries": [{"id": "table_dropped", "sql": "SELECT COUNT(*) as cnt\nFROM information_schema.tables\nWHERE table_name = 'test_drop'\n", "expected_rows": 1}]} -- {"id": "sketch_ddl_create_persistent_table", "description": "Tests CREATE/DROP overhead for a sketch persistence table with BINARY columns", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BLOB,\n rev_sketch BLOB\n);\n", "validation_queries": [{"id": "table_exists", "sql": "SELECT COUNT(*) as cnt\nFROM information_schema.tables\nWHERE table_name = 'sketch_ops_daily_users'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region STRING,\n user_sketch BINARY,\n rev_sketch BINARY\n) USING DELTA;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BINARY,\n rev_sketch BINARY\n);\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region STRING,\n user_sketch BYTES,\n rev_sketch BYTES\n);\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date Date,\n region String,\n user_sketch AggregateFunction(uniq, UInt64),\n rev_sketch AggregateFunction(uniq, UInt64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\n", "datafusion": null, "redshift": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch HLLSKETCH,\n rev_sketch HLLSKETCH\n)\nDISTSTYLE EVEN;\n", "sqlite": null, "starrocks": null}} -- {"id": "sketch_insert_theta_per_partition", "description": "Builds per-partition Theta sketches over l_orderkey + l_extendedprice and persists them", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BLOB,\n rev_sketch BLOB\n);\nINSERT INTO sketch_ops_daily_users\nSELECT\n l_shipdate AS activity_date,\n l_returnflag AS region,\n datasketch_theta(l_orderkey) AS user_sketch,\n datasketch_theta(CAST(l_extendedprice AS INTEGER)) AS rev_sketch\nFROM lineitem\nGROUP BY l_shipdate, l_returnflag;\n", "validation_queries": [{"id": "rows_persisted", "sql": "SELECT COUNT(*) as partitions FROM sketch_ops_daily_users\n", "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BINARY, rev_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n theta_sketch_agg(l_orderkey),\n theta_sketch_agg(CAST(l_extendedprice AS INT))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BINARY, rev_sketch BINARY\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n DATASKETCHES_THETA_ACCUMULATE(l_orderkey),\n DATASKETCHES_THETA_ACCUMULATE(CAST(l_extendedprice AS INTEGER))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BYTES, rev_sketch BYTES\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n HLL_COUNT.INIT(l_orderkey),\n HLL_COUNT.INIT(CAST(l_extendedprice AS INT64))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date Date, region String,\n user_sketch AggregateFunction(uniq, UInt64),\n rev_sketch AggregateFunction(uniq, UInt64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n uniqState(toUInt64(l_orderkey)),\n uniqState(toUInt64(l_extendedprice))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "datafusion": null, "redshift": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25),\n user_sketch HLLSKETCH, rev_sketch HLLSKETCH\n) DISTSTYLE EVEN;\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n HLL_CREATE_SKETCH(l_orderkey),\n HLL_CREATE_SKETCH(CAST(l_extendedprice AS BIGINT))\nFROM lineitem\nGROUP BY l_shipdate, l_returnflag;\n", "sqlite": null, "starrocks": null}} -- {"id": "sketch_insert_kll_per_partition", "description": "Builds per-partition KLL quantile sketches over l_extendedprice", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE,\n region VARCHAR(25),\n price_sketch BLOB\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT\n l_shipdate,\n l_returnflag,\n datasketch_kll(200, l_extendedprice)\nFROM lineitem\nGROUP BY l_shipdate, l_returnflag;\n", "validation_queries": [{"id": "kll_partitions_persisted", "sql": "SELECT COUNT(*) as partitions FROM sketch_ops_kll_partitions\n", "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_kll_partitions\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region STRING, price_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, kll_sketch_agg_double(CAST(l_extendedprice AS DOUBLE))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BINARY\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n DATASKETCHES_KLL_ACCUMULATE(CAST(l_extendedprice AS DOUBLE))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region STRING, price_sketch BYTES\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n KLL_QUANTILES.INIT_INT64(CAST(l_extendedprice AS INT64))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date Date, region String,\n price_sketch AggregateFunction(quantileTDigest(0.5), Float64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n quantileTDigestState(0.5)(toFloat64(l_extendedprice))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_insert_topk_per_shard", "description": "Builds per-shard frequent-items (Top-K accumulator) sketches over l_shipmode", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id INTEGER,\n topk_sketch BLOB\n);\nINSERT INTO sketch_ops_topk\nSELECT\n CAST(l_orderkey % 8 AS INTEGER) AS shard_id,\n datasketch_frequent_items(8, l_shipmode) AS topk_sketch\nFROM lineitem\nGROUP BY l_orderkey % 8;\n", "validation_queries": [{"id": "topk_shards_persisted", "sql": "SELECT COUNT(*) as shards FROM sketch_ops_topk\n", "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_topk\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id INT, topk_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INT),\n approx_top_k_accumulate(l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id INTEGER, topk_sketch BINARY\n);\nINSERT INTO sketch_ops_topk\nSELECT MOD(l_orderkey, 8),\n APPROX_TOP_K_ACCUMULATE(l_shipmode, 1024)\nFROM lineitem GROUP BY MOD(l_orderkey, 8);\n", "bigquery": null, "clickhouse": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id Int32,\n topk_sketch AggregateFunction(topK(8), String)\n) ENGINE = MergeTree() ORDER BY shard_id;\nINSERT INTO sketch_ops_topk\nSELECT toInt32(l_orderkey % 8),\n topKState(8)(l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\n", "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_query_theta_union_merge", "description": "★ Headline — populate theta sketches per partition, then merge across all partitions in a single query and emit an approximate distinct count (compare to aggregation_distinct in read_primitives). BigQuery substitutes HLL_COUNT for Theta (no native Theta surface) — the persist + merge + requery shape is identical, but the underlying sketch family differs.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB, rev_sketch BLOB\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n datasketch_theta(l_orderkey),\n datasketch_theta(CAST(l_extendedprice AS INTEGER))\nFROM lineitem\nGROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_theta_estimate(datasketch_theta(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "validation_queries": [{"id": "theta_estimate_in_range", "sql": "SELECT datasketch_theta_estimate(datasketch_theta(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users\n", "expected_value_min": 14500, "expected_value_max": 15500, "platform_overrides": {"clickhouse": "SELECT uniqMerge(user_sketch) AS distinct_orders\nFROM sketch_ops_daily_users\n", "redshift": "SELECT HLL_CARDINALITY(HLL_COMBINE(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users\n"}}, {"id": "theta_storage_size_duckdb", "sql": "SELECT octet_length(datasketch_theta(user_sketch)) AS sketch_bytes\nFROM sketch_ops_daily_users\n", "expected_value_min": 4000, "expected_value_max": 32000, "platform_overrides": {"clickhouse": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}, {"id": "theta_storage_size_clickhouse", "sql": "SELECT length(toString(uniqMergeState(user_sketch))) AS sketch_bytes\nFROM sketch_ops_daily_users\n", "expected_value_min": 16000, "expected_value_max": 100000, "platform_overrides": {"duckdb": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BINARY, rev_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag, theta_sketch_agg(l_orderkey),\n theta_sketch_agg(CAST(l_extendedprice AS INT))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT theta_sketch_estimate(theta_union_agg(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BINARY, rev_sketch BINARY\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag, DATASKETCHES_THETA_ACCUMULATE(l_orderkey),\n DATASKETCHES_THETA_ACCUMULATE(CAST(l_extendedprice AS INTEGER))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT DATASKETCHES_THETA_ESTIMATE(DATASKETCHES_THETA_COMBINE(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BYTES, rev_sketch BYTES\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag, HLL_COUNT.INIT(l_orderkey),\n HLL_COUNT.INIT(CAST(l_extendedprice AS INT64))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT HLL_COUNT.MERGE(user_sketch) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date Date, region String,\n user_sketch AggregateFunction(uniq, UInt64),\n rev_sketch AggregateFunction(uniq, UInt64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n uniqState(toUInt64(l_orderkey)),\n uniqState(toUInt64(l_extendedprice))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT uniqMerge(user_sketch) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "datafusion": null, "redshift": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25),\n user_sketch HLLSKETCH, rev_sketch HLLSKETCH\n) DISTSTYLE EVEN;\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n HLL_CREATE_SKETCH(l_orderkey),\n HLL_CREATE_SKETCH(CAST(l_extendedprice AS BIGINT))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT HLL_CARDINALITY(HLL_COMBINE(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "sqlite": null, "starrocks": null}} -- {"id": "sketch_query_kll_quantiles_merge", "description": "★ Headline — populate KLL sketches per partition, merge across partitions, extract median price (compare to statistical_percentiles in read_primitives)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, datasketch_kll(200, l_extendedprice)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_kll_quantile(datasketch_kll(200, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions;\n", "validation_queries": [{"id": "kll_median_in_range", "sql": "SELECT datasketch_kll_quantile(datasketch_kll(200, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 30000, "expected_value_max": 40000, "platform_overrides": {"databricks": "SELECT kll_sketch_get_quantile_double(kll_merge_agg_double(price_sketch), 0.5) AS median_price\nFROM sketch_ops_kll_partitions\n", "clickhouse": "SELECT quantileTDigestMerge(0.5)(price_sketch) AS median_price\nFROM sketch_ops_kll_partitions\n"}}, {"id": "kll_storage_size_duckdb", "sql": "SELECT octet_length(datasketch_kll(200, price_sketch::sketch_kll_double)) AS sketch_bytes\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 1000, "expected_value_max": 6000, "platform_overrides": {"clickhouse": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}, {"id": "kll_storage_size_clickhouse", "sql": "SELECT length(toString(quantileTDigestMergeState(0.5)(price_sketch))) AS sketch_bytes\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 1500, "expected_value_max": 8000, "platform_overrides": {"duckdb": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_kll_partitions\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region STRING, price_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, kll_sketch_agg_double(CAST(l_extendedprice AS DOUBLE))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT kll_sketch_get_quantile_double(kll_merge_agg_double(price_sketch), 0.5) AS median_price\nFROM sketch_ops_kll_partitions;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BINARY\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n DATASKETCHES_KLL_ACCUMULATE(CAST(l_extendedprice AS DOUBLE))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT DATASKETCHES_KLL_GET_QUANTILE(DATASKETCHES_KLL_COMBINE(price_sketch), 0.5) AS median_price\nFROM sketch_ops_kll_partitions;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region STRING, price_sketch BYTES\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n KLL_QUANTILES.INIT_INT64(CAST(l_extendedprice AS INT64))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT KLL_QUANTILES.MERGE_POINT_INT64(price_sketch, 0.5) AS median_price\nFROM sketch_ops_kll_partitions;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date Date, region String,\n price_sketch AggregateFunction(quantileTDigest(0.5), Float64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n quantileTDigestState(0.5)(toFloat64(l_extendedprice))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT quantileTDigestMerge(0.5)(price_sketch) AS median_price\nFROM sketch_ops_kll_partitions;\n", "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_query_topk_combine", "description": "★ Headline — populate per-shard frequent-items sketches, combine across shards, count the frequent items (cross-reference approx_top_k_lineitem latency in read_primitives)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INTEGER, topk_sketch BLOB);\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INTEGER),\n datasketch_frequent_items(8, l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(8, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk;\n", "validation_queries": [{"id": "topk_frequent_count_in_range", "sql": "SELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(8, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk\n", "expected_value_min": 6, "expected_value_max": 8, "platform_overrides": {"clickhouse": "SELECT length(topKMerge(8)(topk_sketch)) AS frequent_count\nFROM sketch_ops_topk\n"}}, {"id": "topk_storage_size_duckdb", "sql": "SELECT octet_length(datasketch_frequent_items(8, topk_sketch)) AS sketch_bytes\nFROM sketch_ops_topk\n", "expected_value_min": 300, "expected_value_max": 1200, "platform_overrides": {"clickhouse": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}, {"id": "topk_storage_size_clickhouse", "sql": "SELECT length(toString(topKMergeState(8)(topk_sketch))) AS sketch_bytes\nFROM sketch_ops_topk\n", "expected_value_min": 150, "expected_value_max": 800, "platform_overrides": {"duckdb": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_topk\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INT, topk_sketch BINARY) USING DELTA;\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INT),\n approx_top_k_accumulate(l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT SIZE(approx_top_k_estimate(approx_top_k_combine(topk_sketch))) AS frequent_count\nFROM sketch_ops_topk;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INTEGER, topk_sketch BINARY);\nINSERT INTO sketch_ops_topk\nSELECT MOD(l_orderkey, 8),\n APPROX_TOP_K_ACCUMULATE(l_shipmode, 1024)\nFROM lineitem GROUP BY MOD(l_orderkey, 8);\nSELECT ARRAY_SIZE(APPROX_TOP_K_ESTIMATE(APPROX_TOP_K_COMBINE(topk_sketch))) AS frequent_count\nFROM sketch_ops_topk;\n", "bigquery": null, "clickhouse": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id Int32,\n topk_sketch AggregateFunction(topK(8), String)\n) ENGINE = MergeTree() ORDER BY shard_id;\nINSERT INTO sketch_ops_topk\nSELECT toInt32(l_orderkey % 8),\n topKState(8)(l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT length(topKMerge(8)(topk_sketch)) AS frequent_count\nFROM sketch_ops_topk;\n", "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_drop_persistent_table", "description": "Tests DROP TABLE on a sketch-bearing BINARY-column table to measure cleanup overhead", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB, rev_sketch BLOB\n);\nDROP TABLE sketch_ops_daily_users;\n", "validation_queries": [{"id": "table_dropped", "sql": "SELECT COUNT(*) as cnt\nFROM information_schema.tables\nWHERE table_name = 'sketch_ops_daily_users'\n", "expected_rows": 1}], "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BINARY, rev_sketch BINARY\n) USING DELTA;\nDROP TABLE sketch_ops_daily_users;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BINARY, rev_sketch BINARY\n);\nDROP TABLE sketch_ops_daily_users;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BYTES, rev_sketch BYTES\n);\nDROP TABLE sketch_ops_daily_users;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date Date, region String,\n user_sketch AggregateFunction(uniq, UInt64),\n rev_sketch AggregateFunction(uniq, UInt64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nDROP TABLE sketch_ops_daily_users;\n", "datafusion": null, "redshift": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25),\n user_sketch HLLSKETCH, rev_sketch HLLSKETCH\n) DISTSTYLE EVEN;\nDROP TABLE sketch_ops_daily_users;\n", "sqlite": null, "starrocks": null}} -- {"id": "sketch_cpc_create_persistent_table", "description": "Tests CREATE/DROP overhead for a CPC sketch table (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_cpc_partitions;\nCREATE TABLE sketch_cpc_partitions (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BLOB\n);\n", "validation_queries": [{"id": "cpc_table_exists", "sql": "SELECT COUNT(*) as cnt FROM information_schema.tables\nWHERE table_name = 'sketch_cpc_partitions'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_cpc_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "ddl_create_table_with_constraints", "description": "Tests CREATE TABLE with PRIMARY KEY and NOT NULL constraints to measure constraint overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_constrained (\n id INTEGER PRIMARY KEY,\n name VARCHAR(100) NOT NULL,\n value DECIMAL(15,2) NOT NULL,\n created DATE NOT NULL,\n status VARCHAR(10)\n)\n", "validation_queries": [{"id": "table_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('test_constrained')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_constrained\n", "expected_rows_affected": 0, "platform_overrides": {"datafusion": null, "ducklake": null}} +- {"id": "ddl_create_table_with_index", "description": "Tests CREATE TABLE followed by CREATE INDEX to measure index creation overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_indexed (\n id INTEGER,\n name VARCHAR(100),\n value DECIMAL(15,2)\n);\nCREATE INDEX idx_test_value ON test_indexed(value)\n", "validation_queries": [{"id": "index_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('test_indexed')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_indexed\n", "expected_rows_affected": 0, "platform_overrides": {"databricks": "CREATE OR REPLACE TABLE test_indexed (\n id INTEGER,\n name VARCHAR(100),\n value DECIMAL(15,2)\n)\n", "bigquery": "CREATE OR REPLACE TABLE test_indexed (\n id INTEGER,\n name VARCHAR(100),\n value DECIMAL(15,2)\n)\n", "snowflake": "CREATE OR REPLACE TABLE test_indexed (\n id INTEGER,\n name VARCHAR(100),\n value DECIMAL(15,2)\n)\n", "datafusion": null, "starrocks": null, "ducklake": null}} +- {"id": "ddl_alter_table_add_column", "description": "Tests ALTER TABLE ADD COLUMN to measure schema modification overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_alter (\n id INTEGER,\n name VARCHAR(100)\n);\nALTER TABLE test_alter ADD COLUMN created DATE\n", "validation_queries": [{"id": "column_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('test_alter')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_alter\n", "expected_rows_affected": 0, "platform_overrides": {"datafusion": null}} +- {"id": "ddl_alter_table_drop_column", "description": "Tests ALTER TABLE DROP COLUMN to measure column removal overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_drop_col (\n id INTEGER,\n name VARCHAR(100),\n temp_col VARCHAR(50)\n);\nALTER TABLE test_drop_col DROP COLUMN temp_col\n", "validation_queries": [{"id": "table_modified", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('test_drop_col')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_drop_col\n", "expected_rows_affected": 0, "platform_overrides": {"databricks": null, "datafusion": null}} +- {"id": "ddl_alter_table_rename_column", "description": "Tests ALTER TABLE RENAME COLUMN to measure column renaming overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_rename_col (\n id INTEGER,\n old_name VARCHAR(100)\n);\nALTER TABLE test_rename_col RENAME COLUMN old_name TO new_name\n", "validation_queries": [{"id": "column_renamed", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('test_rename_col')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_rename_col\n", "expected_rows_affected": 0, "platform_overrides": {"databricks": null, "mysql": "CREATE TABLE test_rename_col (\n id INTEGER,\n old_name VARCHAR(100)\n);\nALTER TABLE test_rename_col CHANGE old_name new_name VARCHAR(100)\n", "starrocks": null, "datafusion": null}} +- {"id": "ddl_create_index_on_existing", "description": "Tests CREATE INDEX on populated table to measure index building on existing data", "requires_setup": false, "write_sql": "CREATE TABLE test_idx_existing AS SELECT * FROM orders WHERE o_orderkey <= 100;\nCREATE INDEX idx_existing_price ON test_idx_existing(o_totalprice)\n", "validation_queries": [{"id": "index_created", "sql": "SELECT COUNT(*) as cnt FROM test_idx_existing", "expected_rows": 1, "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_idx_existing\n", "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "datafusion": null, "starrocks": null, "ducklake": null}} +- {"id": "ddl_drop_index", "description": "Tests DROP INDEX to measure index removal overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_drop_idx (id INTEGER, value DECIMAL(15,2));\nCREATE INDEX idx_test_drop ON test_drop_idx(value);\nDROP INDEX idx_test_drop\n", "validation_queries": [{"id": "table_still_exists", "sql": "SELECT table_name\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('test_drop_idx')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS test_drop_idx\n", "expected_rows_affected": 0, "platform_overrides": {"databricks": null, "bigquery": null, "snowflake": null, "mysql": "CREATE TABLE test_drop_idx (id INTEGER, value DECIMAL(15,2));\nCREATE INDEX idx_test_drop ON test_drop_idx(value);\nDROP INDEX idx_test_drop ON test_drop_idx\n", "datafusion": null, "starrocks": null, "ducklake": null}} +- {"id": "ddl_create_view_simple", "description": "Tests CREATE VIEW with simple SELECT to measure view creation overhead", "requires_setup": false, "write_sql": "CREATE VIEW orders_view AS\nSELECT o_orderkey, o_custkey, o_totalprice, o_orderdate\nFROM orders\nWHERE o_orderkey <= 1000\n", "validation_queries": [{"id": "view_exists", "sql": "SELECT table_name\nFROM information_schema.views\nWHERE UPPER(table_name) = UPPER('orders_view')\n", "expected_rows": 1}], "cleanup_sql": "DROP VIEW IF EXISTS orders_view\n", "expected_rows_affected": 0} +- {"id": "ddl_drop_table", "description": "Tests DROP TABLE to measure table removal overhead", "requires_setup": false, "write_sql": "CREATE TABLE test_drop AS SELECT * FROM orders WHERE o_orderkey <= 50;\nDROP TABLE test_drop\n", "validation_queries": [{"id": "table_dropped", "sql": "SELECT COUNT(*) as cnt\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('test_drop')\n", "expected_rows": 1}]} +- {"id": "sketch_ddl_create_persistent_table", "description": "Tests CREATE/DROP overhead for a sketch persistence table with BINARY columns", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BLOB,\n rev_sketch BLOB\n);\n", "validation_queries": [{"id": "table_exists", "sql": "SELECT COUNT(*) as cnt\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('sketch_ops_daily_users')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region STRING,\n user_sketch BINARY,\n rev_sketch BINARY\n) USING DELTA;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BINARY,\n rev_sketch BINARY\n);\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region STRING,\n user_sketch BYTES,\n rev_sketch BYTES\n);\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date Date,\n region String,\n user_sketch AggregateFunction(uniq, UInt64),\n rev_sketch AggregateFunction(uniq, UInt64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\n", "datafusion": null, "redshift": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch HLLSKETCH,\n rev_sketch HLLSKETCH\n)\nDISTSTYLE EVEN;\n", "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_insert_theta_per_partition", "description": "Builds per-partition Theta sketches over l_orderkey + l_extendedprice and persists them", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BLOB,\n rev_sketch BLOB\n);\nINSERT INTO sketch_ops_daily_users\nSELECT\n l_shipdate AS activity_date,\n l_returnflag AS region,\n datasketch_theta(l_orderkey) AS user_sketch,\n datasketch_theta(CAST(l_extendedprice AS INTEGER)) AS rev_sketch\nFROM lineitem\nGROUP BY l_shipdate, l_returnflag;\n", "validation_queries": [{"id": "rows_persisted", "sql": "SELECT COUNT(*) as partitions FROM sketch_ops_daily_users\n", "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BINARY, rev_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n theta_sketch_agg(l_orderkey),\n theta_sketch_agg(CAST(l_extendedprice AS INT))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BINARY, rev_sketch BINARY\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n DATASKETCHES_THETA_ACCUMULATE(l_orderkey),\n DATASKETCHES_THETA_ACCUMULATE(CAST(l_extendedprice AS INTEGER))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BYTES, rev_sketch BYTES\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n HLL_COUNT.INIT(l_orderkey),\n HLL_COUNT.INIT(CAST(l_extendedprice AS INT64))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date Date, region String,\n user_sketch AggregateFunction(uniq, UInt64),\n rev_sketch AggregateFunction(uniq, UInt64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n uniqState(toUInt64(l_orderkey)),\n uniqState(toUInt64(l_extendedprice))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "datafusion": null, "redshift": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25),\n user_sketch HLLSKETCH, rev_sketch HLLSKETCH\n) DISTSTYLE EVEN;\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n HLL_CREATE_SKETCH(l_orderkey),\n HLL_CREATE_SKETCH(CAST(l_extendedprice AS BIGINT))\nFROM lineitem\nGROUP BY l_shipdate, l_returnflag;\n", "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_insert_kll_per_partition", "description": "Builds per-partition KLL quantile sketches over l_extendedprice", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE,\n region VARCHAR(25),\n price_sketch BLOB\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT\n l_shipdate,\n l_returnflag,\n datasketch_kll(200, l_extendedprice)\nFROM lineitem\nGROUP BY l_shipdate, l_returnflag;\n", "validation_queries": [{"id": "kll_partitions_persisted", "sql": "SELECT COUNT(*) as partitions FROM sketch_ops_kll_partitions\n", "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_kll_partitions\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region STRING, price_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, kll_sketch_agg_double(CAST(l_extendedprice AS DOUBLE))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BINARY\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n DATASKETCHES_KLL_ACCUMULATE(CAST(l_extendedprice AS DOUBLE))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region STRING, price_sketch BYTES\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n KLL_QUANTILES.INIT_INT64(CAST(l_extendedprice AS INT64))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date Date, region String,\n price_sketch AggregateFunction(quantileTDigest(0.5), Float64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n quantileTDigestState(0.5)(toFloat64(l_extendedprice))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_insert_topk_per_shard", "description": "Builds per-shard frequent-items (Top-K accumulator) sketches over l_shipmode", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id INTEGER,\n topk_sketch BLOB\n);\nINSERT INTO sketch_ops_topk\nSELECT\n CAST(l_orderkey % 8 AS INTEGER) AS shard_id,\n datasketch_frequent_items(8, l_shipmode) AS topk_sketch\nFROM lineitem\nGROUP BY l_orderkey % 8;\n", "validation_queries": [{"id": "topk_shards_persisted", "sql": "SELECT COUNT(*) as shards FROM sketch_ops_topk\n", "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_topk\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id INT, topk_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INT),\n approx_top_k_accumulate(l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id INTEGER, topk_sketch BINARY\n);\nINSERT INTO sketch_ops_topk\nSELECT MOD(l_orderkey, 8),\n APPROX_TOP_K_ACCUMULATE(l_shipmode, 1024)\nFROM lineitem GROUP BY MOD(l_orderkey, 8);\n", "bigquery": null, "clickhouse": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id Int32,\n topk_sketch AggregateFunction(topK(8), String)\n) ENGINE = MergeTree() ORDER BY shard_id;\nINSERT INTO sketch_ops_topk\nSELECT toInt32(l_orderkey % 8),\n topKState(8)(l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\n", "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_query_theta_union_merge", "description": "★ Headline — populate theta sketches per partition, then merge across all partitions in a single query and emit an approximate distinct count (compare to aggregation_distinct in read_primitives). BigQuery substitutes HLL_COUNT for Theta (no native Theta surface) — the persist + merge + requery shape is identical, but the underlying sketch family differs.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB, rev_sketch BLOB\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n datasketch_theta(l_orderkey),\n datasketch_theta(CAST(l_extendedprice AS INTEGER))\nFROM lineitem\nGROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_theta_estimate(datasketch_theta(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "validation_queries": [{"id": "theta_estimate_in_range", "sql": "SELECT datasketch_theta_estimate(datasketch_theta(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users\n", "expected_value_min": 14500, "expected_value_max": 15500, "platform_overrides": {"clickhouse": "SELECT uniqMerge(user_sketch) AS distinct_orders\nFROM sketch_ops_daily_users\n", "redshift": "SELECT HLL_CARDINALITY(HLL_COMBINE(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users\n"}}, {"id": "theta_storage_size_duckdb", "sql": "SELECT octet_length(datasketch_theta(user_sketch)) AS sketch_bytes\nFROM sketch_ops_daily_users\n", "expected_value_min": 4000, "expected_value_max": 32000, "platform_overrides": {"clickhouse": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}, {"id": "theta_storage_size_clickhouse", "sql": "SELECT length(toString(uniqMergeState(user_sketch))) AS sketch_bytes\nFROM sketch_ops_daily_users\n", "expected_value_min": 16000, "expected_value_max": 100000, "platform_overrides": {"duckdb": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BINARY, rev_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag, theta_sketch_agg(l_orderkey),\n theta_sketch_agg(CAST(l_extendedprice AS INT))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT theta_sketch_estimate(theta_union_agg(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BINARY, rev_sketch BINARY\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag, DATASKETCHES_THETA_ACCUMULATE(l_orderkey),\n DATASKETCHES_THETA_ACCUMULATE(CAST(l_extendedprice AS INTEGER))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT DATASKETCHES_THETA_ESTIMATE(DATASKETCHES_THETA_COMBINE(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BYTES, rev_sketch BYTES\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag, HLL_COUNT.INIT(l_orderkey),\n HLL_COUNT.INIT(CAST(l_extendedprice AS INT64))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT HLL_COUNT.MERGE(user_sketch) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date Date, region String,\n user_sketch AggregateFunction(uniq, UInt64),\n rev_sketch AggregateFunction(uniq, UInt64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n uniqState(toUInt64(l_orderkey)),\n uniqState(toUInt64(l_extendedprice))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT uniqMerge(user_sketch) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "datafusion": null, "redshift": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25),\n user_sketch HLLSKETCH, rev_sketch HLLSKETCH\n) DISTSTYLE EVEN;\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n HLL_CREATE_SKETCH(l_orderkey),\n HLL_CREATE_SKETCH(CAST(l_extendedprice AS BIGINT))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT HLL_CARDINALITY(HLL_COMBINE(user_sketch)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_query_kll_quantiles_merge", "description": "★ Headline — populate KLL sketches per partition, merge across partitions, extract median price (compare to statistical_percentiles in read_primitives)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, datasketch_kll(200, l_extendedprice)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_kll_quantile(datasketch_kll(200, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions;\n", "validation_queries": [{"id": "kll_median_in_range", "sql": "SELECT datasketch_kll_quantile(datasketch_kll(200, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 30000, "expected_value_max": 40000, "platform_overrides": {"databricks": "SELECT kll_sketch_get_quantile_double(kll_merge_agg_double(price_sketch), 0.5) AS median_price\nFROM sketch_ops_kll_partitions\n", "clickhouse": "SELECT quantileTDigestMerge(0.5)(price_sketch) AS median_price\nFROM sketch_ops_kll_partitions\n"}}, {"id": "kll_storage_size_duckdb", "sql": "SELECT octet_length(datasketch_kll(200, price_sketch::sketch_kll_double)) AS sketch_bytes\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 1000, "expected_value_max": 6000, "platform_overrides": {"clickhouse": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}, {"id": "kll_storage_size_clickhouse", "sql": "SELECT length(toString(quantileTDigestMergeState(0.5)(price_sketch))) AS sketch_bytes\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 1500, "expected_value_max": 8000, "platform_overrides": {"duckdb": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_kll_partitions\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region STRING, price_sketch BINARY\n) USING DELTA;\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, kll_sketch_agg_double(CAST(l_extendedprice AS DOUBLE))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT kll_sketch_get_quantile_double(kll_merge_agg_double(price_sketch), 0.5) AS median_price\nFROM sketch_ops_kll_partitions;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BINARY\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n DATASKETCHES_KLL_ACCUMULATE(CAST(l_extendedprice AS DOUBLE))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT DATASKETCHES_KLL_GET_QUANTILE(DATASKETCHES_KLL_COMBINE(price_sketch), 0.5) AS median_price\nFROM sketch_ops_kll_partitions;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region STRING, price_sketch BYTES\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n KLL_QUANTILES.INIT_INT64(CAST(l_extendedprice AS INT64))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT KLL_QUANTILES.MERGE_POINT_INT64(price_sketch, 0.5) AS median_price\nFROM sketch_ops_kll_partitions;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date Date, region String,\n price_sketch AggregateFunction(quantileTDigest(0.5), Float64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag,\n quantileTDigestState(0.5)(toFloat64(l_extendedprice))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT quantileTDigestMerge(0.5)(price_sketch) AS median_price\nFROM sketch_ops_kll_partitions;\n", "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_query_topk_combine", "description": "★ Headline — populate per-shard frequent-items sketches, combine across shards, count the frequent items (cross-reference approx_top_k_lineitem latency in read_primitives)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INTEGER, topk_sketch BLOB);\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INTEGER),\n datasketch_frequent_items(8, l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(8, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk;\n", "validation_queries": [{"id": "topk_frequent_count_in_range", "sql": "SELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(8, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk\n", "expected_value_min": 6, "expected_value_max": 8, "platform_overrides": {"clickhouse": "SELECT length(topKMerge(8)(topk_sketch)) AS frequent_count\nFROM sketch_ops_topk\n"}}, {"id": "topk_storage_size_duckdb", "sql": "SELECT octet_length(datasketch_frequent_items(8, topk_sketch)) AS sketch_bytes\nFROM sketch_ops_topk\n", "expected_value_min": 300, "expected_value_max": 1200, "platform_overrides": {"clickhouse": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}, {"id": "topk_storage_size_clickhouse", "sql": "SELECT length(toString(topKMergeState(8)(topk_sketch))) AS sketch_bytes\nFROM sketch_ops_topk\n", "expected_value_min": 150, "expected_value_max": 800, "platform_overrides": {"duckdb": null, "databricks": null, "snowflake": null, "bigquery": null, "redshift": null, "datafusion": null, "sqlite": null, "starrocks": null}}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_topk\n", "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INT, topk_sketch BINARY) USING DELTA;\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INT),\n approx_top_k_accumulate(l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT SIZE(approx_top_k_estimate(approx_top_k_combine(topk_sketch))) AS frequent_count\nFROM sketch_ops_topk;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INTEGER, topk_sketch BINARY);\nINSERT INTO sketch_ops_topk\nSELECT MOD(l_orderkey, 8),\n APPROX_TOP_K_ACCUMULATE(l_shipmode, 1024)\nFROM lineitem GROUP BY MOD(l_orderkey, 8);\nSELECT ARRAY_SIZE(APPROX_TOP_K_ESTIMATE(APPROX_TOP_K_COMBINE(topk_sketch))) AS frequent_count\nFROM sketch_ops_topk;\n", "bigquery": null, "clickhouse": "DROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (\n shard_id Int32,\n topk_sketch AggregateFunction(topK(8), String)\n) ENGINE = MergeTree() ORDER BY shard_id;\nINSERT INTO sketch_ops_topk\nSELECT toInt32(l_orderkey % 8),\n topKState(8)(l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT length(topKMerge(8)(topk_sketch)) AS frequent_count\nFROM sketch_ops_topk;\n", "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_drop_persistent_table", "description": "Tests DROP TABLE on a sketch-bearing BINARY-column table to measure cleanup overhead", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB, rev_sketch BLOB\n);\nDROP TABLE sketch_ops_daily_users;\n", "validation_queries": [{"id": "table_dropped", "sql": "SELECT COUNT(*) as cnt\nFROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('sketch_ops_daily_users')\n", "expected_rows": 1}], "platform_overrides": {"databricks": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BINARY, rev_sketch BINARY\n) USING DELTA;\nDROP TABLE sketch_ops_daily_users;\n", "snowflake": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BINARY, rev_sketch BINARY\n);\nDROP TABLE sketch_ops_daily_users;\n", "bigquery": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region STRING, user_sketch BYTES, rev_sketch BYTES\n);\nDROP TABLE sketch_ops_daily_users;\n", "clickhouse": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date Date, region String,\n user_sketch AggregateFunction(uniq, UInt64),\n rev_sketch AggregateFunction(uniq, UInt64)\n) ENGINE = MergeTree() ORDER BY (activity_date, region);\nDROP TABLE sketch_ops_daily_users;\n", "datafusion": null, "redshift": "DROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25),\n user_sketch HLLSKETCH, rev_sketch HLLSKETCH\n) DISTSTYLE EVEN;\nDROP TABLE sketch_ops_daily_users;\n", "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_cpc_create_persistent_table", "description": "Tests CREATE/DROP overhead for a CPC sketch table (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_cpc_partitions;\nCREATE TABLE sketch_cpc_partitions (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BLOB\n);\n", "validation_queries": [{"id": "cpc_table_exists", "sql": "SELECT COUNT(*) as cnt FROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('sketch_cpc_partitions')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_cpc_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} - {"id": "sketch_cpc_insert_per_partition", "description": "Builds per-partition CPC sketches over l_orderkey (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_cpc_partitions;\nCREATE TABLE sketch_cpc_partitions (\n activity_date DATE,\n region VARCHAR(25),\n user_sketch BLOB\n);\nINSERT INTO sketch_cpc_partitions\nSELECT l_shipdate, l_returnflag, datasketch_cpc(11, l_orderkey)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "validation_queries": [{"id": "cpc_rows_persisted", "sql": "SELECT COUNT(*) as partitions FROM sketch_cpc_partitions\n", "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_cpc_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} - {"id": "sketch_cpc_query_union_merge", "description": "Headline CPC — populate per-partition CPC sketches, union them across partitions, emit distinct estimate (DuckDB-only). Side-by-side with sketch_query_theta_union_merge for HLL-family algorithm comparison and storage-cost tradeoff.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_cpc_partitions;\nCREATE TABLE sketch_cpc_partitions (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB\n);\nINSERT INTO sketch_cpc_partitions\nSELECT l_shipdate, l_returnflag, datasketch_cpc(11, l_orderkey)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_cpc_estimate(datasketch_cpc_union(11, user_sketch::sketch_cpc)) AS distinct_orders\nFROM sketch_cpc_partitions;\n", "validation_queries": [{"id": "cpc_estimate_in_range", "sql": "SELECT datasketch_cpc_estimate(datasketch_cpc_union(11, user_sketch::sketch_cpc)) AS distinct_orders\nFROM sketch_cpc_partitions\n", "expected_value_min": 14000, "expected_value_max": 16000}, {"id": "cpc_storage_size", "sql": "SELECT octet_length(datasketch_cpc_union(11, user_sketch::sketch_cpc)) AS sketch_bytes\nFROM sketch_cpc_partitions\n", "expected_value_min": 400, "expected_value_max": 4000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_cpc_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} -- {"id": "sketch_cpc_drop_persistent_table", "description": "Tests DROP TABLE on a CPC-bearing BLOB-column table (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_cpc_partitions;\nCREATE TABLE sketch_cpc_partitions (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB\n);\nDROP TABLE sketch_cpc_partitions;\n", "validation_queries": [{"id": "cpc_table_dropped", "sql": "SELECT COUNT(*) as cnt FROM information_schema.tables\nWHERE table_name = 'sketch_cpc_partitions'\n", "expected_rows": 1}], "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} -- {"id": "sketch_req_create_persistent_table", "description": "Tests CREATE/DROP overhead for a REQ sketch table (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_req_partitions;\nCREATE TABLE sketch_req_partitions (\n activity_date DATE,\n region VARCHAR(25),\n price_sketch BLOB\n);\n", "validation_queries": [{"id": "req_table_exists", "sql": "SELECT COUNT(*) as cnt FROM information_schema.tables\nWHERE table_name = 'sketch_req_partitions'\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_req_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_cpc_drop_persistent_table", "description": "Tests DROP TABLE on a CPC-bearing BLOB-column table (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_cpc_partitions;\nCREATE TABLE sketch_cpc_partitions (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB\n);\nDROP TABLE sketch_cpc_partitions;\n", "validation_queries": [{"id": "cpc_table_dropped", "sql": "SELECT COUNT(*) as cnt FROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('sketch_cpc_partitions')\n", "expected_rows": 1}], "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_req_create_persistent_table", "description": "Tests CREATE/DROP overhead for a REQ sketch table (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_req_partitions;\nCREATE TABLE sketch_req_partitions (\n activity_date DATE,\n region VARCHAR(25),\n price_sketch BLOB\n);\n", "validation_queries": [{"id": "req_table_exists", "sql": "SELECT COUNT(*) as cnt FROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('sketch_req_partitions')\n", "expected_rows": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_req_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} - {"id": "sketch_req_insert_per_partition", "description": "Builds per-partition REQ quantile sketches over l_extendedprice (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_req_partitions;\nCREATE TABLE sketch_req_partitions (\n activity_date DATE,\n region VARCHAR(25),\n price_sketch BLOB\n);\nINSERT INTO sketch_req_partitions\nSELECT l_shipdate, l_returnflag, datasketch_req(12, l_extendedprice::FLOAT)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\n", "validation_queries": [{"id": "req_rows_persisted", "sql": "SELECT COUNT(*) as partitions FROM sketch_req_partitions\n", "expected_rows_min": 1}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_req_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} - {"id": "sketch_req_query_quantile_merge", "description": "Headline REQ — populate per-partition REQ sketches, merge across partitions, extract median (DuckDB-only). Side-by-side with sketch_query_kll_quantiles_merge for relative-error vs normalized-rank error comparison.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_req_partitions;\nCREATE TABLE sketch_req_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nINSERT INTO sketch_req_partitions\nSELECT l_shipdate, l_returnflag, datasketch_req(12, l_extendedprice::FLOAT)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_req_quantile(datasketch_req(12, price_sketch::sketch_req_float), 0.5::DOUBLE, true) AS median_price\nFROM sketch_req_partitions;\n", "validation_queries": [{"id": "req_median_in_range", "sql": "SELECT datasketch_req_quantile(datasketch_req(12, price_sketch::sketch_req_float), 0.5::DOUBLE, true) AS median_price\nFROM sketch_req_partitions\n", "expected_value_min": 30000, "expected_value_max": 40000}, {"id": "req_storage_size", "sql": "SELECT octet_length(datasketch_req(12, price_sketch::sketch_req_float)) AS sketch_bytes\nFROM sketch_req_partitions\n", "expected_value_min": 1000, "expected_value_max": 8000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_req_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} -- {"id": "sketch_req_drop_persistent_table", "description": "Tests DROP TABLE on a REQ-bearing BLOB-column table (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_req_partitions;\nCREATE TABLE sketch_req_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nDROP TABLE sketch_req_partitions;\n", "validation_queries": [{"id": "req_table_dropped", "sql": "SELECT COUNT(*) as cnt FROM information_schema.tables\nWHERE table_name = 'sketch_req_partitions'\n", "expected_rows": 1}], "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} -- {"id": "sketch_query_theta_union_merge_lgk10", "description": "Theta sweep variant — lg_k=10 (1024 entries, ~5KB serialized, ~3.1% RSE). Smaller and lossier than the default lg_k=12.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB, rev_sketch BLOB\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n datasketch_theta(10, l_orderkey),\n datasketch_theta(10, CAST(l_extendedprice AS INTEGER))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_theta_estimate(datasketch_theta(10, user_sketch::sketch_theta_uint64)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "validation_queries": [{"id": "theta_lgk10_estimate_in_range", "sql": "SELECT datasketch_theta_estimate(datasketch_theta(10, user_sketch::sketch_theta_uint64)) AS distinct_orders\nFROM sketch_ops_daily_users\n", "expected_value_min": 13500, "expected_value_max": 16500}, {"id": "theta_lgk10_storage_size", "sql": "SELECT octet_length(datasketch_theta(10, user_sketch::sketch_theta_uint64)) AS sketch_bytes\nFROM sketch_ops_daily_users\n", "expected_value_min": 1000, "expected_value_max": 12000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_query_theta_union_merge_lgk14", "description": "Theta sweep variant — lg_k=14 (16384 entries, ~64KB serialized, ~0.8% RSE). Larger and tighter than default lg_k=12.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB, rev_sketch BLOB\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n datasketch_theta(14, l_orderkey),\n datasketch_theta(14, CAST(l_extendedprice AS INTEGER))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_theta_estimate(datasketch_theta(14, user_sketch::sketch_theta_uint64)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "validation_queries": [{"id": "theta_lgk14_estimate_in_range", "sql": "SELECT datasketch_theta_estimate(datasketch_theta(14, user_sketch::sketch_theta_uint64)) AS distinct_orders\nFROM sketch_ops_daily_users\n", "expected_value_min": 14700, "expected_value_max": 15300}, {"id": "theta_lgk14_storage_size", "sql": "SELECT octet_length(datasketch_theta(14, user_sketch::sketch_theta_uint64)) AS sketch_bytes\nFROM sketch_ops_daily_users\n", "expected_value_min": 16000, "expected_value_max": 130000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_query_kll_quantiles_merge_k100", "description": "KLL sweep variant — k=100 (~1.5KB, ~1.65% normalized rank error). Smaller and lossier than default k=200.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, datasketch_kll(100, l_extendedprice)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_kll_quantile(datasketch_kll(100, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions;\n", "validation_queries": [{"id": "kll_k100_median_in_range", "sql": "SELECT datasketch_kll_quantile(datasketch_kll(100, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 30000, "expected_value_max": 40000}, {"id": "kll_k100_storage_size", "sql": "SELECT octet_length(datasketch_kll(100, price_sketch::sketch_kll_double)) AS sketch_bytes\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 500, "expected_value_max": 5000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_kll_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_query_kll_quantiles_merge_k1000", "description": "KLL sweep variant — k=1000 (~15KB, ~0.59% normalized rank error). Larger and tighter than default k=200.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, datasketch_kll(1000, l_extendedprice)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_kll_quantile(datasketch_kll(1000, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions;\n", "validation_queries": [{"id": "kll_k1000_median_in_range", "sql": "SELECT datasketch_kll_quantile(datasketch_kll(1000, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 30000, "expected_value_max": 40000}, {"id": "kll_k1000_storage_size", "sql": "SELECT octet_length(datasketch_kll(1000, price_sketch::sketch_kll_double)) AS sketch_bytes\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 5000, "expected_value_max": 40000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_kll_partitions\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_query_topk_combine_lgmm8", "description": "Top-K sweep variant — explicit lg_max_map_size=8 (256 buckets, ~600B). Same parameter as the default headline op; provided for sweep-symmetry comparisons.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INTEGER, topk_sketch BLOB);\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INTEGER),\n datasketch_frequent_items(8, l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(8, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk;\n", "validation_queries": [{"id": "topk_lgmm8_frequent_count_in_range", "sql": "SELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(8, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk\n", "expected_value_min": 6, "expected_value_max": 8}, {"id": "topk_lgmm8_storage_size", "sql": "SELECT octet_length(datasketch_frequent_items(8, topk_sketch)) AS sketch_bytes\nFROM sketch_ops_topk\n", "expected_value_min": 200, "expected_value_max": 2000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_topk\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} -- {"id": "sketch_query_topk_combine_lgmm10", "description": "Top-K sweep variant — lg_max_map_size=10 (1024 buckets, ~2KB). Larger than default lgmm=8; should still report 7 frequent items deterministically (lineitem has 7 distinct shipmodes).", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INTEGER, topk_sketch BLOB);\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INTEGER),\n datasketch_frequent_items(10, l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(10, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk;\n", "validation_queries": [{"id": "topk_lgmm10_frequent_count_in_range", "sql": "SELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(10, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk\n", "expected_value_min": 6, "expected_value_max": 8}, {"id": "topk_lgmm10_storage_size", "sql": "SELECT octet_length(datasketch_frequent_items(10, topk_sketch)) AS sketch_bytes\nFROM sketch_ops_topk\n", "expected_value_min": 500, "expected_value_max": 5000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_topk\n", "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} +- {"id": "sketch_req_drop_persistent_table", "description": "Tests DROP TABLE on a REQ-bearing BLOB-column table (DuckDB-only)", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_req_partitions;\nCREATE TABLE sketch_req_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nDROP TABLE sketch_req_partitions;\n", "validation_queries": [{"id": "req_table_dropped", "sql": "SELECT COUNT(*) as cnt FROM information_schema.tables\nWHERE UPPER(table_name) = UPPER('sketch_req_partitions')\n", "expected_rows": 1}], "platform_overrides": {"databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null, "trino": null}} +- {"id": "sketch_query_theta_union_merge_lgk10", "description": "Theta sweep variant — lg_k=10 (1024 entries, ~5KB serialized, ~3.1% RSE). Smaller and lossier than the default lg_k=12.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB, rev_sketch BLOB\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n datasketch_theta(10, l_orderkey),\n datasketch_theta(10, CAST(l_extendedprice AS INTEGER))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_theta_estimate(datasketch_theta(10, user_sketch::sketch_theta_uint64)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "validation_queries": [{"id": "theta_lgk10_estimate_in_range", "sql": "SELECT datasketch_theta_estimate(datasketch_theta(10, user_sketch::sketch_theta_uint64)) AS distinct_orders\nFROM sketch_ops_daily_users\n", "expected_value_min": 13500, "expected_value_max": 16500}, {"id": "theta_lgk10_storage_size", "sql": "SELECT octet_length(datasketch_theta(10, user_sketch::sketch_theta_uint64)) AS sketch_bytes\nFROM sketch_ops_daily_users\n", "expected_value_min": 1000, "expected_value_max": 12000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"trino": null, "databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} +- {"id": "sketch_query_theta_union_merge_lgk14", "description": "Theta sweep variant — lg_k=14 (16384 entries, ~64KB serialized, ~0.8% RSE). Larger and tighter than default lg_k=12.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_daily_users;\nCREATE TABLE sketch_ops_daily_users (\n activity_date DATE, region VARCHAR(25), user_sketch BLOB, rev_sketch BLOB\n);\nINSERT INTO sketch_ops_daily_users\nSELECT l_shipdate, l_returnflag,\n datasketch_theta(14, l_orderkey),\n datasketch_theta(14, CAST(l_extendedprice AS INTEGER))\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_theta_estimate(datasketch_theta(14, user_sketch::sketch_theta_uint64)) AS distinct_orders\nFROM sketch_ops_daily_users;\n", "validation_queries": [{"id": "theta_lgk14_estimate_in_range", "sql": "SELECT datasketch_theta_estimate(datasketch_theta(14, user_sketch::sketch_theta_uint64)) AS distinct_orders\nFROM sketch_ops_daily_users\n", "expected_value_min": 14700, "expected_value_max": 15300}, {"id": "theta_lgk14_storage_size", "sql": "SELECT octet_length(datasketch_theta(14, user_sketch::sketch_theta_uint64)) AS sketch_bytes\nFROM sketch_ops_daily_users\n", "expected_value_min": 16000, "expected_value_max": 130000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_daily_users\n", "platform_overrides": {"trino": null, "databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} +- {"id": "sketch_query_kll_quantiles_merge_k100", "description": "KLL sweep variant — k=100 (~1.5KB, ~1.65% normalized rank error). Smaller and lossier than default k=200.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, datasketch_kll(100, l_extendedprice)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_kll_quantile(datasketch_kll(100, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions;\n", "validation_queries": [{"id": "kll_k100_median_in_range", "sql": "SELECT datasketch_kll_quantile(datasketch_kll(100, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 30000, "expected_value_max": 40000}, {"id": "kll_k100_storage_size", "sql": "SELECT octet_length(datasketch_kll(100, price_sketch::sketch_kll_double)) AS sketch_bytes\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 500, "expected_value_max": 5000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_kll_partitions\n", "platform_overrides": {"trino": null, "databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} +- {"id": "sketch_query_kll_quantiles_merge_k1000", "description": "KLL sweep variant — k=1000 (~15KB, ~0.59% normalized rank error). Larger and tighter than default k=200.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_kll_partitions;\nCREATE TABLE sketch_ops_kll_partitions (\n activity_date DATE, region VARCHAR(25), price_sketch BLOB\n);\nINSERT INTO sketch_ops_kll_partitions\nSELECT l_shipdate, l_returnflag, datasketch_kll(1000, l_extendedprice)\nFROM lineitem GROUP BY l_shipdate, l_returnflag;\nSELECT datasketch_kll_quantile(datasketch_kll(1000, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions;\n", "validation_queries": [{"id": "kll_k1000_median_in_range", "sql": "SELECT datasketch_kll_quantile(datasketch_kll(1000, price_sketch::sketch_kll_double), 0.5::DOUBLE, true) AS median_price\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 30000, "expected_value_max": 40000}, {"id": "kll_k1000_storage_size", "sql": "SELECT octet_length(datasketch_kll(1000, price_sketch::sketch_kll_double)) AS sketch_bytes\nFROM sketch_ops_kll_partitions\n", "expected_value_min": 5000, "expected_value_max": 40000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_kll_partitions\n", "platform_overrides": {"trino": null, "databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} +- {"id": "sketch_query_topk_combine_lgmm8", "description": "Top-K sweep variant — explicit lg_max_map_size=8 (256 buckets, ~600B). Same parameter as the default headline op; provided for sweep-symmetry comparisons.", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INTEGER, topk_sketch BLOB);\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INTEGER),\n datasketch_frequent_items(8, l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(8, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk;\n", "validation_queries": [{"id": "topk_lgmm8_frequent_count_in_range", "sql": "SELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(8, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk\n", "expected_value_min": 6, "expected_value_max": 8}, {"id": "topk_lgmm8_storage_size", "sql": "SELECT octet_length(datasketch_frequent_items(8, topk_sketch)) AS sketch_bytes\nFROM sketch_ops_topk\n", "expected_value_min": 200, "expected_value_max": 2000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_topk\n", "platform_overrides": {"trino": null, "databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} +- {"id": "sketch_query_topk_combine_lgmm10", "description": "Top-K sweep variant — lg_max_map_size=10 (1024 buckets, ~2KB). Larger than default lgmm=8; should still report 7 frequent items deterministically (lineitem has 7 distinct shipmodes).", "requires_setup": false, "write_sql": "INSTALL datasketches FROM community;\nLOAD datasketches;\nDROP TABLE IF EXISTS sketch_ops_topk;\nCREATE TABLE sketch_ops_topk (shard_id INTEGER, topk_sketch BLOB);\nINSERT INTO sketch_ops_topk\nSELECT CAST(l_orderkey % 8 AS INTEGER),\n datasketch_frequent_items(10, l_shipmode)\nFROM lineitem GROUP BY l_orderkey % 8;\nSELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(10, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk;\n", "validation_queries": [{"id": "topk_lgmm10_frequent_count_in_range", "sql": "SELECT LENGTH(datasketch_frequent_items_get_frequent(\n datasketch_frequent_items(10, topk_sketch), 'NO_FALSE_POSITIVES'\n)) AS frequent_count\nFROM sketch_ops_topk\n", "expected_value_min": 6, "expected_value_max": 8}, {"id": "topk_lgmm10_storage_size", "sql": "SELECT octet_length(datasketch_frequent_items(10, topk_sketch)) AS sketch_bytes\nFROM sketch_ops_topk\n", "expected_value_min": 500, "expected_value_max": 5000}], "cleanup_sql": "DROP TABLE IF EXISTS sketch_ops_topk\n", "platform_overrides": {"trino": null, "databricks": null, "snowflake": null, "bigquery": null, "clickhouse": null, "datafusion": null, "redshift": null, "sqlite": null, "starrocks": null}} - {"id": "sketch_df_hll_persist_merge", "description": "★ Headline DataFrame — PySpark HLL persist+merge cycle. Builds per-group HLL sketches of l_orderkey via `F.hll_sketch_agg`, persists state to Parquet, then merges via `F.hll_union_agg` and extracts the distinct-count estimate via `F.hll_sketch_estimate`. Pairs with the SQL-side `sketch_query_theta_union_merge` so users can compare DataFrame and SQL paths against the same true-distinct=15000 baseline at SF=0.01.", "requires_setup": false, "write_sql": "", "aggregate_state": {"sketch_type": "hll", "source_table": "lineitem", "target_subdir": "write_primitives/sketch_df_hll_persist_merge", "group_cols": ["l_shipdate", "l_returnflag"], "value_col": "l_orderkey", "sketch_alias": "user_sketch", "supported_platforms": ["pyspark"]}, "validation_queries": [{"id": "hll_distinct_in_range", "sql": "-- Aggregate-state validation runs against the merge-extract\n-- scalar in `metrics.aggregate_value`, not against this SQL.\n-- The placeholder SELECT keeps the loader's \"validation has SQL\"\n-- contract honored without committing to a SQL execution path.\nSELECT 1\n", "expected_value_min": 14250, "expected_value_max": 15750}]} - {"id": "sketch_df_topk_persist_merge", "description": "★ Headline DataFrame — PySpark Top-K persist+merge cycle. Spark 4.1+ only — `F.approx_top_k_accumulate` is the per-group state builder; `F.approx_top_k_combine` + `F.approx_top_k_estimate` perform the merge+extract. Reports the count of frequent items (lineitem has 7 distinct l_shipmodes; SF=0.01 returns 7 deterministically). Skipped cleanly on Spark 3.5.x via `pyspark_supports_approx_top_k` runtime guard.", "requires_setup": false, "write_sql": "", "aggregate_state": {"sketch_type": "topk", "source_table": "lineitem", "target_subdir": "write_primitives/sketch_df_topk_persist_merge", "group_cols": ["l_shipmode"], "value_col": "l_shipmode", "sketch_alias": "topk_sketch", "supported_platforms": ["pyspark"]}, "validation_queries": [{"id": "topk_frequent_count_in_range", "sql": "SELECT 1\n", "expected_value_min": 6, "expected_value_max": 8}]} diff --git a/benchbox/data/cli_help_catalog.yaml b/benchbox/data/cli_help_catalog.yaml index e6d02b0631..86dc3f8521 100644 --- a/benchbox/data/cli_help_catalog.yaml +++ b/benchbox/data/cli_help_catalog.yaml @@ -82,8 +82,8 @@ command_examples: - benchbox run --platform bigquery --benchmark tpch --output gs://bucket/benchbox/ - benchbox run --platform bigquery --benchmark tpcds --scale 100 \ - ' --platform-option project_id=my-project --output gs://my-bucket/results/' - - benchbox run --platform bigquery --benchmark tpch --tuning tuned \ - - ' --output gs://bucket/benchbox/ # Uses partitioning, clustering' + - benchbox run --platform snowflake --benchmark tpch --tuning tuned \ + - ' --output s3://bucket/benchbox/ # Uses clustering (packaged template)' - benchbox run --platform bigquery --benchmark tpch --table-mode external \ - ' --platform-option staging_root=gs://bucket/benchbox/ # External table over GCS Parquet' Redshift: diff --git a/benchbox/datavault.py b/benchbox/datavault.py index b4957be737..aecfd233ed 100644 --- a/benchbox/datavault.py +++ b/benchbox/datavault.py @@ -63,13 +63,20 @@ def generate_data(self) -> dict[str, Any]: """ return self._impl.generate_data() - def get_queries(self) -> dict[str, str]: + def get_queries(self, dialect: Optional[str] = None) -> dict[str, str]: """Get all Data Vault benchmark queries. + Args: + dialect: Optional target SQL dialect; cloud dialects are translated + from the DuckDB source + Returns: Dictionary mapping query IDs (1-22) to query strings """ - return self._impl.get_all_queries() + queries = self._impl.get_all_queries() + if not dialect: + return queries + return {k: self._impl.translate_for_dialect(v, dialect) for k, v in queries.items()} def get_query( self, @@ -83,7 +90,7 @@ def get_query( Args: query_id: The ID of the query to retrieve (1-22) params: Optional parameters (not used, for API compatibility) - **kwargs: Additional parameters (not used) + **kwargs: Additional parameters; ``dialect`` selects the target SQL dialect Returns: The query string adapted for Data Vault schema @@ -97,7 +104,7 @@ def get_query( if not (1 <= query_id <= 22): raise ValueError(f"Query ID must be 1-22, got {query_id}") - return self._impl.get_query(query_id) + return self._impl.get_query(query_id, dialect=kwargs.get("dialect")) def get_schema(self) -> dict[str, Any]: """Get the Data Vault schema definition. diff --git a/benchbox/experimental/load_testing/__init__.py b/benchbox/experimental/load_testing/__init__.py index b75c6ef100..a2c0c48431 100644 --- a/benchbox/experimental/load_testing/__init__.py +++ b/benchbox/experimental/load_testing/__init__.py @@ -26,6 +26,7 @@ ) from benchbox.experimental.load_testing.patterns import ( BurstPattern, + MultiWriterPattern, RampUpPattern, SpikePattern, SteadyPattern, @@ -55,6 +56,7 @@ "SpikePattern", "StepPattern", "WavePattern", + "MultiWriterPattern", # Analysis "LoadAnalyzer", "QueueAnalysis", diff --git a/benchbox/experimental/load_testing/executor.py b/benchbox/experimental/load_testing/executor.py index 203454f763..4d20a40d71 100644 --- a/benchbox/experimental/load_testing/executor.py +++ b/benchbox/experimental/load_testing/executor.py @@ -15,7 +15,7 @@ from dataclasses import dataclass, field from typing import Any, Callable -from benchbox.experimental.load_testing.patterns import SteadyPattern, WorkloadPattern +from benchbox.experimental.load_testing.patterns import SteadyPattern, WorkloadPattern, WorkloadPhase from benchbox.utils.clock import elapsed_seconds, mono_time logger = logging.getLogger(__name__) @@ -70,8 +70,24 @@ class ConcurrentLoadConfig: query_factory: Callable[[int], tuple[str, str]] """Factory returning (query_id, sql) for stream iteration index.""" + role_factories: dict[str, Callable[[int], tuple[str, str]]] | None = field(default=None, kw_only=True) + """Optional per-role query factories for role-aware patterns. + + Maps a stream role (e.g. "writer", "reader") to the factory used for + streams launched in that role. When a pattern phase declares `roles` + targets, launched streams use the matching role factory; phases without + role targets keep using `query_factory`. Every role named by the + pattern must have a factory entry. + """ + connection_factory: Callable[[], Any] - """Factory creating new database connections.""" + """Factory creating new database connections. + + Must return an independent, thread-confined connection per call: one + stream owns its connection for its whole lifetime and closes it on + exit. Never return a shared connection; concurrent streams closing or + writing through the same handle corrupts every in-flight stream. + """ execute_query: Callable[[Any, str], tuple[bool, int | None, str | None]] """Function to execute query: (connection, sql) -> (success, rows, error).""" @@ -177,6 +193,7 @@ def __init__(self, config: ConcurrentLoadConfig): self._lock = threading.Lock() self._stream_results: list[StreamResult] = [] self._active_streams = 0 + self._active_roles: dict[str, int] = {} self._max_concurrency_reached = 0 self._queue: deque[tuple[int, float]] = deque() # (stream_id, enqueue_time) self._resource_samples: list[dict[str, float]] = [] @@ -187,9 +204,14 @@ def run(self) -> ConcurrentLoadResult: Returns: Results from the load test + + Raises: + ValueError: If the pattern declares stream roles but the config + does not provide a matching `role_factories` entry. """ start_time = time.time() pattern = self._config.pattern + self._validate_role_factories(pattern) logger.info( f"Starting concurrent load test: pattern={pattern.__class__.__name__}, " @@ -250,17 +272,63 @@ def run(self) -> ConcurrentLoadResult: return result + def _validate_role_factories(self, pattern: WorkloadPattern) -> None: + """Validate role factory coverage for role-aware patterns. + + Args: + pattern: Workload pattern about to execute. + + Raises: + ValueError: If any phase declares roles without a matching + factory entry, or a role target is negative. + """ + declared_roles: set[str] = set() + for phase in pattern.iter_phases(): + if phase.roles: + for role, target in phase.roles.items(): + if target < 0: + raise ValueError(f"phase '{phase.phase_name}': negative target for role '{role}'") + declared_roles.update(phase.roles) + if declared_roles: + missing = declared_roles - set(self._config.role_factories or {}) + if missing: + raise ValueError( + f"pattern declares roles {sorted(declared_roles)} but role_factories is missing {sorted(missing)}" + ) + + def _role_targets(self, phase: WorkloadPhase) -> dict[str, int]: + """Compute per-role stream launch counts for a phase. + + For role-aware phases, each role is topped up to its declared + target independently, so roles absent from the phase (such as + readers during a writer-only drain) get no replacement streams + once their in-flight streams finish. Phases without role targets + keep the historical behavior of topping up total concurrency. + + Must be called with `self._lock` held. + + Args: + phase: Phase currently executing. + + Returns: + Mapping of role (or "" for undifferentiated phases) to the + number of streams to launch now. + """ + if phase.roles: + return {role: max(0, target - self._active_roles.get(role, 0)) for role, target in phase.roles.items()} + return {"": max(0, phase.concurrency - self._active_streams)} + def _execute_pattern(self, pattern: WorkloadPattern) -> None: """Execute the workload pattern.""" - time.time() stream_counter = 0 # Use ThreadPoolExecutor for managing concurrent streams with ThreadPoolExecutor(max_workers=pattern.max_concurrency) as executor: - futures: dict[Future, int] = {} + futures: dict[Future, tuple[int, str]] = {} phase_streams: dict[str, int] = {} + phases = list(pattern.iter_phases()) - for phase in pattern.iter_phases(): + for index, phase in enumerate(phases): phase_start = mono_time() phase_streams[phase.phase_name] = 0 @@ -271,29 +339,31 @@ def _execute_pattern(self, pattern: WorkloadPattern) -> None: # Launch streams for this phase while elapsed_seconds(phase_start) < phase.duration_seconds: - # Maintain target concurrency with self._lock: current_active = self._active_streams if current_active > self._max_concurrency_reached: self._max_concurrency_reached = current_active + targets = self._role_targets(phase) - streams_to_launch = phase.concurrency - current_active - - for _ in range(max(0, streams_to_launch)): - stream_id = stream_counter - stream_counter += 1 - phase_streams[phase.phase_name] += 1 + for role, streams_to_launch in targets.items(): + for _ in range(max(0, streams_to_launch)): + stream_id = stream_counter + stream_counter += 1 + phase_streams[phase.phase_name] += 1 - # Track queue time - enqueue_time = time.time() if self._config.track_queue_times else 0 + # Track queue time on the monotonic clock: queue_wait + # is computed against mono_time() in _execute_stream, + # so wall-clock time.time() here would corrupt it. + enqueue_time = mono_time() if self._config.track_queue_times else 0.0 - with self._lock: - self._active_streams += 1 - if self._config.track_queue_times: - self._queue.append((stream_id, enqueue_time)) + with self._lock: + self._active_streams += 1 + self._active_roles[role] = self._active_roles.get(role, 0) + 1 + if self._config.track_queue_times: + self._queue.append((stream_id, enqueue_time)) - future = executor.submit(self._execute_stream, stream_id, enqueue_time) - futures[future] = stream_id + future = executor.submit(self._execute_stream, stream_id, enqueue_time, role) + futures[future] = (stream_id, role) # Check for completed streams completed = [f for f in futures if f.done()] @@ -302,7 +372,7 @@ def _execute_pattern(self, pattern: WorkloadPattern) -> None: result = future.result() self._stream_results.append(result) except Exception as e: - stream_id = futures[future] + stream_id = futures[future][0] self._stream_results.append( StreamResult( stream_id=stream_id, @@ -314,20 +384,29 @@ def _execute_pattern(self, pattern: WorkloadPattern) -> None: ) ) finally: + _, role = futures[future] with self._lock: self._active_streams -= 1 + self._decrement_role_locked(role) del futures[future] # Small sleep to prevent busy-waiting time.sleep(0.1) + # Phase boundary: roles excluded from the next phase (readers + # during a writer-only drain) must finish before the next + # phase clock starts, otherwise they bleed into the drain. + # Roles continuing into the next phase keep running. + upcoming = phases[index + 1].roles if index + 1 < len(phases) else None + self._await_role_drain(futures, next_phase_roles=upcoming) + # Wait for remaining streams to complete for future in as_completed(futures): try: result = future.result() self._stream_results.append(result) except Exception as e: - stream_id = futures[future] + stream_id = futures[future][0] self._stream_results.append( StreamResult( stream_id=stream_id, @@ -339,18 +418,71 @@ def _execute_pattern(self, pattern: WorkloadPattern) -> None: ) ) finally: + _, role = futures[future] with self._lock: self._active_streams -= 1 + self._decrement_role_locked(role) + + def _decrement_role_locked(self, role: str) -> None: + """Release one active-stream slot. Call with `self._lock` held.""" + remaining = self._active_roles.get(role, 0) - 1 + if remaining > 0: + self._active_roles[role] = remaining + else: + self._active_roles.pop(role, None) + + def _decrement_role(self, role: str) -> None: + """Release one active-stream slot for a finished stream role.""" + with self._lock: + self._decrement_role_locked(role) + + def _await_role_drain( + self, + futures: dict[Future, tuple[int, str]], + next_phase_roles: dict[str, int] | None, + ) -> None: + """Wait for in-flight streams whose role ends at a phase boundary.""" + if not next_phase_roles: + return + draining = [f for f, (_, role) in futures.items() if role not in next_phase_roles] + for future in as_completed(draining): + try: + self._stream_results.append(future.result()) + except Exception as e: # noqa: BLE001 - record per-stream failures as data + stream_id = futures[future][0] + self._stream_results.append( + StreamResult( + stream_id=stream_id, + queries_executed=0, + queries_succeeded=0, + queries_failed=0, + total_time_seconds=0, + error=str(e), + ) + ) + finally: + _, role = futures[future] + with self._lock: + self._active_streams -= 1 + self._decrement_role_locked(role) + del futures[future] - def _execute_stream(self, stream_id: int, enqueue_time: float) -> StreamResult: - """Execute a single stream of queries.""" + def _execute_stream(self, stream_id: int, enqueue_time: float, role: str = "") -> StreamResult: + """Execute a single stream of queries. + + Args: + stream_id: Unique stream identifier. + enqueue_time: Queue entry timestamp for wait-time tracking. + role: Stream role used to select a per-role query factory. + """ stream_start = mono_time() queue_wait = stream_start - enqueue_time if enqueue_time > 0 else 0 + query_factory = self._query_factory_for(role) - # Remove from queue + # Remove from queue by identity: ThreadPoolExecutor does not start + # streams in enqueue order, so head-of-queue removal strands entries. with self._lock: - if self._queue and self._queue[0][0] == stream_id: - self._queue.popleft() + self._queue = deque((sid, ts) for sid, ts in self._queue if sid != stream_id) executions: list[QueryExecution] = [] queries_succeeded = 0 @@ -362,7 +494,7 @@ def _execute_stream(self, stream_id: int, enqueue_time: float) -> StreamResult: try: for i in range(self._config.queries_per_stream): - query_id, sql = self._config.query_factory(i) + query_id, sql = query_factory(i) query_start = time.time() try: @@ -427,6 +559,19 @@ def _execute_stream(self, stream_id: int, enqueue_time: float) -> StreamResult: query_executions=executions, ) + def _query_factory_for(self, role: str) -> Callable[[int], tuple[str, str]]: + """Select the query factory for a stream role. + + Args: + role: Stream role; "" denotes an undifferentiated stream. + + Returns: + The per-role factory when one is configured, else the default. + """ + if role and self._config.role_factories and role in self._config.role_factories: + return self._config.role_factories[role] + return self._config.query_factory + def _monitor_resources(self) -> None: """Background thread for resource monitoring.""" try: diff --git a/benchbox/experimental/load_testing/patterns.py b/benchbox/experimental/load_testing/patterns.py index 5b8b965af0..d8d55a5779 100644 --- a/benchbox/experimental/load_testing/patterns.py +++ b/benchbox/experimental/load_testing/patterns.py @@ -11,6 +11,12 @@ from dataclasses import dataclass +def _require_at_least(name: str, value: float, minimum: float, expectation: str) -> None: + """Raise ValueError unless a numeric pattern argument meets its floor.""" + if value < minimum: + raise ValueError(f"{name} must be {expectation}") + + @dataclass class WorkloadPhase: """A phase in the workload pattern.""" @@ -18,6 +24,11 @@ class WorkloadPhase: concurrency: int duration_seconds: float phase_name: str = "" + roles: dict[str, int] | None = None + # Optional per-role stream targets for role-aware execution. + # Maps a stream role (e.g. "writer") to the desired concurrent stream + # count during this phase. When None, the phase is undifferentiated + # and executors fill `concurrency` streams with the default workload. class WorkloadPattern(ABC): @@ -475,14 +486,12 @@ def __init__( period_seconds: Duration of one complete wave cycle num_periods: Number of wave cycles to execute """ - if min_concurrency < 1: - raise ValueError("min_concurrency must be at least 1") + _require_at_least("min_concurrency", min_concurrency, 1, "at least 1") if max_concurrency < min_concurrency: raise ValueError("max_concurrency must be >= min_concurrency") if period_seconds <= 0: raise ValueError("period_seconds must be positive") - if num_periods < 1: - raise ValueError("num_periods must be at least 1") + _require_at_least("num_periods", num_periods, 1, "at least 1") self._min = min_concurrency self._max = max_concurrency @@ -522,3 +531,121 @@ def total_duration(self) -> float: @property def max_concurrency(self) -> int: return self._max + + +class MultiWriterPattern(WorkloadPattern): + """Concurrent-writer pattern: N writers plus M readers ("multiplayer"). + + Models the multi-client concurrent-writer story: several writers issue + INSERT/UPDATE statements against one table while readers run SELECTs. + The writer and reader phases overlap in time so lock contention, + snapshot isolation, and write-serialization behavior are exercised. + Readers scale down first so the run ends with writers draining. + """ + + def __init__( + self, + writers: int, + readers: int, + duration_seconds: float, + drain_seconds: float = 5.0, + ) -> None: + """Initialize the multi-writer pattern. + + Args: + writers: Number of concurrent writer streams. + readers: Number of concurrent reader streams. + duration_seconds: Overlapped read/write duration. + drain_seconds: Writer-only drain tail after readers stop. + """ + _require_at_least("writers", writers, 1, "at least 1") + _require_at_least("readers", readers, 1, "at least 1") + if duration_seconds <= 0: + raise ValueError("duration_seconds must be positive") + _require_at_least("drain_seconds", drain_seconds, 0, "non-negative") + + self._writers = writers + self._readers = readers + self._duration = duration_seconds + self._drain = drain_seconds + + def get_phases(self) -> list[WorkloadPhase]: + phases = [ + WorkloadPhase( + concurrency=self._writers + self._readers, + duration_seconds=self._duration, + phase_name="read-write", + roles={"writer": self._writers, "reader": self._readers}, + ) + ] + if self._drain > 0: + phases.append( + WorkloadPhase( + concurrency=self._writers, + duration_seconds=self._drain, + phase_name="write-drain", + roles={"writer": self._writers}, + ) + ) + return phases + + def writers_in_phase(self, phase_name: str) -> int: + """Return the writer stream target for a phase of this pattern. + + Args: + phase_name: Phase to inspect ("read-write" or "write-drain"). + + Raises: + ValueError: If the phase name is unknown for this pattern. + """ + if phase_name == "read-write": + return self._writers + if phase_name == "write-drain": + if self._drain <= 0: + raise ValueError("write-drain phase is absent when drain_seconds is 0") + return self._writers + raise ValueError(f"unknown MultiWriterPattern phase: {phase_name!r}") + + def readers_in_phase(self, phase_name: str) -> int: + """Return the reader stream target for a phase of this pattern. + + Args: + phase_name: Phase to inspect ("read-write" or "write-drain"). + + Raises: + ValueError: If the phase name is unknown for this pattern. + """ + if phase_name == "read-write": + return self._readers + if phase_name == "write-drain": + if self._drain <= 0: + raise ValueError("write-drain phase is absent when drain_seconds is 0") + return 0 + raise ValueError(f"unknown MultiWriterPattern phase: {phase_name!r}") + + def get_concurrency_at(self, elapsed_seconds: float) -> int: + if elapsed_seconds < 0: + return 0 + if elapsed_seconds <= self._duration: + return self._writers + self._readers + if elapsed_seconds <= self._duration + self._drain: + return self._writers + return 0 + + @property + def total_duration(self) -> float: + return self._duration + self._drain + + @property + def max_concurrency(self) -> int: + return self._writers + self._readers + + @property + def writer_count(self) -> int: + """Number of writer streams.""" + return self._writers + + @property + def reader_count(self) -> int: + """Number of reader streams.""" + return self._readers diff --git a/benchbox/metadata_primitives.py b/benchbox/metadata_primitives.py index 780c53b077..093269e432 100644 --- a/benchbox/metadata_primitives.py +++ b/benchbox/metadata_primitives.py @@ -41,6 +41,8 @@ class MetadataPrimitives(QueryCategoryFacadeMixin, QueryFacadeMixin, BaseBenchma - Large catalogs (100-500+ tables) """ + SKIP_DATA_LOADING = True + def __init__( self, scale_factor: float = 1.0, diff --git a/benchbox/platforms/__init__.py b/benchbox/platforms/__init__.py index 4f1df89a4b..5e96a34153 100644 --- a/benchbox/platforms/__init__.py +++ b/benchbox/platforms/__init__.py @@ -75,6 +75,8 @@ TimescaleDBAdapter|.timescaledb PgDuckDBAdapter|.pg_duckdb PgMooncakeAdapter|.pg_mooncake +ParadeDBAdapter|.paradedb +CitusAdapter|.citus CedarDBAdapter|.cedardb QuestDBAdapter|.questdb DatabricksAdapter|.databricks @@ -627,6 +629,7 @@ def _register_specs(platform: str, *specs) -> None: databricks|staging_root|Cloud storage path for staging data (e.g., dbfs:/Volumes/..., s3://..., abfss://...)|{} databricks|databricks_clustering_strategy|Databricks SQL tuning strategy override (z_order, liquid_clustering, liquid_clustering_auto, none)|{'choices': ('z_order', 'liquid_clustering', 'liquid_clustering_auto', 'none')} databricks|liquid_clustering_columns|Comma-separated Databricks liquid clustering columns|{} +databricks|disable_result_cache|Disable Databricks serverless result cache during benchmark execution|{'parser': 'parse_bool', 'default': True} bigquery|staging_root|GCS path for staging data (e.g., gs://bucket/path)|{} bigquery|storage_bucket|GCS bucket name for data staging (alternative to staging_root)|{} bigquery|storage_prefix|GCS path prefix within bucket for data staging|{} @@ -696,7 +699,7 @@ def _register_specs(platform: str, *specs) -> None: pg-duckdb|compare_native|Run native DuckDB comparison for matched queries|{'parser': 'parse_bool', 'default': False} pg-duckdb|duckdb_db_path|Path to the DuckDB database file pg_duckdb attaches|{} ducklake|metadata_path|DuckLake catalog metadata file path (.ducklake)|{} -ducklake|data_path|DuckLake Parquet data directory (local path or s3:// URI)|{} +ducklake|data_path|DuckLake Parquet data directory (local path or cloud URI: s3://, gs://, az://)|{} ducklake|deployment_mode|DuckLake deployment mode: local, local_catalog_s3, postgres_catalog, or postgres_catalog_s3|{'choices': ('local', 'local_catalog_s3', 'postgres_catalog', 'postgres_catalog_s3')} ducklake|catalog|DuckLake catalog backend: duckdb, sqlite, or postgres|{'choices': ('duckdb', 'sqlite', 'postgres'), 'default': 'duckdb'} ducklake|pg_host|PostgreSQL hostname for the postgres catalog backend|{'default': 'localhost'} @@ -707,6 +710,10 @@ def _register_specs(platform: str, *specs) -> None: ducklake|s3_key_id|AWS access key ID for S3 DATA_PATH (omit to use the credential_chain provider)|{} ducklake|s3_secret|AWS secret access key for S3 DATA_PATH|{} ducklake|s3_region|AWS region for S3 DATA_PATH|{} +ducklake|gcs_key_id|GCS HMAC access ID for gs:// DATA_PATH (both HMAC keys required)|{} +ducklake|gcs_secret|GCS HMAC secret for gs:// DATA_PATH (both HMAC keys required)|{} +ducklake|azure_connection_string|Azure connection string for az:// DATA_PATH (wins over account-name auth)|{} +ducklake|azure_account_name|Azure storage account name for az:// DATA_PATH ambient credential_chain auth|{} pg-mooncake|host|PostgreSQL server hostname (with pg_mooncake installed)|{'default': 'localhost'} pg-mooncake|port|PostgreSQL server port|{'parser': 'int', 'default': 5432} pg-mooncake|database|PostgreSQL database name (auto-generated if not specified)|{} @@ -721,6 +728,31 @@ def _register_specs(platform: str, *specs) -> None: pg-mooncake|max_parallel_workers_per_gather|PostgreSQL max_parallel_workers_per_gather setting|{'parser': 'int', 'default': 2} pg-mooncake|storage_mode|Storage backend: local (disk) or s3 (object storage)|{'choices': ('local', 's3'), 'default': 'local'} pg-mooncake|mooncake_bucket|S3/GCS bucket URL for columnstore data (required when storage_mode=s3)|{} +paradedb|host|PostgreSQL server hostname (with pg_analytics installed)|{'default': 'localhost'} +paradedb|port|PostgreSQL server port|{'parser': 'int', 'default': 5432} +paradedb|database|PostgreSQL database name (auto-generated if not specified)|{} +paradedb|username|PostgreSQL username|{'default': 'postgres'} +paradedb|password|PostgreSQL password|{} +paradedb|schema|PostgreSQL schema name|{'default': 'public'} +paradedb|admin_database|Database used for CREATE/DROP DATABASE operations|{'default': 'postgres'} +paradedb|sslmode|PostgreSQL SSL mode|{'default': 'prefer'} +paradedb|work_mem|PostgreSQL work_mem setting for queries|{'default': '256MB'} +paradedb|maintenance_work_mem|PostgreSQL maintenance_work_mem for VACUUM/CREATE INDEX|{'default': '512MB'} +paradedb|effective_cache_size|PostgreSQL effective_cache_size planner hint|{'default': '1GB'} +paradedb|max_parallel_workers_per_gather|PostgreSQL max_parallel_workers_per_gather setting|{'parser': 'int', 'default': 2} +citus|host|Citus coordinator hostname (with citus extension installed)|{'default': 'localhost'} +citus|port|Citus coordinator port|{'parser': 'int', 'default': 5432} +citus|database|PostgreSQL database name (auto-generated if not specified)|{} +citus|username|PostgreSQL username|{'default': 'postgres'} +citus|password|PostgreSQL password|{} +citus|schema|PostgreSQL schema name|{'default': 'public'} +citus|admin_database|Database used for CREATE/DROP DATABASE operations|{'default': 'postgres'} +citus|sslmode|PostgreSQL SSL mode|{'default': 'prefer'} +citus|work_mem|PostgreSQL work_mem setting for queries|{'default': '256MB'} +citus|maintenance_work_mem|PostgreSQL maintenance_work_mem for VACUUM/CREATE INDEX|{'default': '512MB'} +citus|effective_cache_size|PostgreSQL effective_cache_size planner hint|{'default': '1GB'} +citus|max_parallel_workers_per_gather|PostgreSQL max_parallel_workers_per_gather setting|{'parser': 'int', 'default': 2} +citus|distribution_column|Distribute every benchmark table on this column (unset keeps coordinator-local tables)|{} questdb|host|QuestDB server hostname|{'default': 'localhost'} questdb|pg_port|QuestDB PostgreSQL wire protocol port|{'default': '8812'} questdb|http_port|QuestDB REST API HTTP port (BenchBox Docker uses 19000; native default is 9000)|{'default': '9000'} @@ -916,6 +948,10 @@ def _register_spec_rows(rows: str) -> None: PlatformHookRegistry.register_config_builder( "pg-mooncake", _make_lazy_config_builder(".pg_mooncake", "_build_pg_mooncake_config") ) + PlatformHookRegistry.register_config_builder( + "paradedb", _make_lazy_config_builder(".paradedb", "_build_paradedb_config") + ) + PlatformHookRegistry.register_config_builder("citus", _make_lazy_config_builder(".citus", "_build_citus_config")) PlatformHookRegistry.register_config_builder( "questdb", _make_lazy_config_builder(".questdb", "_build_questdb_config") ) diff --git a/benchbox/platforms/base/adapter.py b/benchbox/platforms/base/adapter.py index c44c80a15a..796f9e3cc8 100644 --- a/benchbox/platforms/base/adapter.py +++ b/benchbox/platforms/base/adapter.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import Any +from benchbox.core.loaded_tables import require_loaded_tables from benchbox.core.results.query_plan_models import DEFAULT_PLAN_MAX_DEPTH from benchbox.core.results.schema import compute_plan_capture_stats from benchbox.core.throughput.containment import await_quiescence @@ -256,6 +257,7 @@ def __init__(self, **config): # construction for the honest status, the physical-identity hash, and the # .applied.json companion. Reset per run; None-safe on non-tuned paths. self._applied_tuning_ledger: AppliedTuningLedger | None = None + self._tuning_marker_save_failed = False # Verbose logging configuration self.apply_verbosity(VerbositySettings.from_mapping(config)) @@ -281,6 +283,7 @@ def __init__(self, **config): # Track latest throughput metrics for phase construction self._last_throughput_test_result = None + self._last_power_workload_timing: tuple[str, str, int] | None = None # Latest per-table load timings for result construction (reset per run) self._last_per_table_timings: dict[str, Any] | None = None self._sorted_ingestion_applied_tables: list[str] = [] @@ -293,8 +296,10 @@ def __init__(self, **config): def _reset_run_scoped_state(self) -> None: """Reset mutable state that belongs to one benchmark execution.""" self.database_was_reused = False + self._existing_db_decided = False self._last_power_test_result = None self._last_throughput_test_result = None + self._last_power_workload_timing = None self._last_per_table_timings: dict[str, Any] | None = None self._sorted_ingestion_applied_tables = [] self._sorted_ingestion_total_apply_seconds = 0.0 @@ -482,6 +487,27 @@ def load_data( where per_table_timings is optional dict with detailed timing per table """ + def materialize_schema_only_tables(self, benchmark, connection: Any) -> dict[str, int]: + """Materialize catalog objects for schema-only benchmarks. + + The ``SKIP_DATA_LOADING`` path bypasses ``load_data()``, but some + adapters only create catalog objects there (DataFusion builds empty + tables from the schema recorded by ``create_schema()``). Overrides + must create empty tables without loading files; the default is a + no-op for adapters whose ``create_schema()`` already materializes. + + Args: + benchmark: Benchmark instance with schema definitions + connection: Database connection + + Returns: + Mapping of table name to row count (zeros for empty tables) + """ + # Default: no-op. The debug statement keeps this hook structurally + # distinct from bare dict-returning helpers for clone detection. + self.logger.debug(f"materialize_schema_only_tables not implemented for {self.__class__.__name__}") + return {} + def create_external_tables( self, benchmark: Any, connection: Any, data_dir: Path ) -> tuple[dict[str, int], float, dict[str, Any] | None]: @@ -810,29 +836,70 @@ def validate_platform_capabilities(self, benchmark_type: str) -> ValidationResul details=platform_info, ) - def run_enhanced_benchmark(self, benchmark, **run_config) -> EnhancedBenchmarkResults: - """Run complete benchmark with enhanced phase tracking.""" - start_time = mono_time() - execution_id = str(uuid.uuid4())[:8] - self._reset_run_scoped_state() - plan_capture_config = { + def _apply_run_plan_flags(self, run_config: Mapping[str, Any]) -> dict[str, Any]: + """Snapshot run-scoped plan flags, then apply the run's values. + + Returns the snapshot so the caller can restore it in a finally block; + adapters are reused across runs and these flags must not leak. + `show_query_plans` (--show-plans) is display-only: it stays in + run-input provenance and never enters plan-capture metadata. + + Application is atomic: every value is read and coerced into locals + before any attribute is assigned, so a bad value raises before + partial mutation (the caller then has nothing to restore). + """ + snapshot = { "capture_plans": self.capture_plans, + "show_query_plans": self.show_query_plans, "analyze_plans": self.analyze_plans, "strict_plan_capture": self.strict_plan_capture, + "normalize_plan_literals": self.normalize_plan_literals, "plan_capture_timeout_seconds": self.plan_capture_timeout_seconds, } - if "capture_plans" in run_config: - self.capture_plans = bool(run_config.get("capture_plans")) + new_capture_plans = bool(run_config["capture_plans"]) if "capture_plans" in run_config else self.capture_plans + # show_query_plans is tri-state in RunConfig: only override the + # adapter's value when the database option was explicitly set + # (non-None); None leaves the adapter default (e.g. from + # platform_config or a preconfigured adapter). + new_show_query_plans = ( + bool(run_config["show_query_plans"]) + if run_config.get("show_query_plans") is not None + else self.show_query_plans + ) # analyze_plans is tri-state in RunConfig: only override the adapter's value # when the first-class flag was set (non-None); None leaves the adapter default. - if run_config.get("analyze_plans") is not None: - self.analyze_plans = bool(run_config.get("analyze_plans")) - if "strict_plan_capture" in run_config: - self.strict_plan_capture = bool(run_config.get("strict_plan_capture")) - if "normalize_plan_literals" in run_config: - self.normalize_plan_literals = bool(run_config.get("normalize_plan_literals")) - if "plan_capture_timeout_seconds" in run_config: - self.plan_capture_timeout_seconds = int(run_config.get("plan_capture_timeout_seconds")) + new_analyze_plans = ( + bool(run_config["analyze_plans"]) if run_config.get("analyze_plans") is not None else self.analyze_plans + ) + new_strict_plan_capture = ( + bool(run_config["strict_plan_capture"]) if "strict_plan_capture" in run_config else self.strict_plan_capture + ) + new_normalize_plan_literals = ( + bool(run_config["normalize_plan_literals"]) + if "normalize_plan_literals" in run_config + else self.normalize_plan_literals + ) + # None means unset: fall back to the adapter default instead of + # crashing int(None). + new_plan_capture_timeout_seconds = ( + int(run_config["plan_capture_timeout_seconds"]) + if run_config.get("plan_capture_timeout_seconds") is not None + else self.plan_capture_timeout_seconds + ) + self.capture_plans = new_capture_plans + self.show_query_plans = new_show_query_plans + self.analyze_plans = new_analyze_plans + self.strict_plan_capture = new_strict_plan_capture + self.normalize_plan_literals = new_normalize_plan_literals + self.plan_capture_timeout_seconds = new_plan_capture_timeout_seconds + return snapshot + + def run_enhanced_benchmark(self, benchmark, **run_config) -> EnhancedBenchmarkResults: + """Run complete benchmark with enhanced phase tracking.""" + start_time = mono_time() + execution_id = str(uuid.uuid4())[:8] + self._reset_run_scoped_state() + plan_capture_config = self._apply_run_plan_flags(run_config) try: # Step 1: Data generation phase (handled by run_benchmark_lifecycle before this call) @@ -966,6 +1033,8 @@ def run_enhanced_benchmark(self, benchmark, **run_config) -> EnhancedBenchmarkRe # applied_ledger_hash - in true execution chronology (ddl -> post_load # -> session). Internally guarded; a no-op when the attribute is empty. self._fold_layout_operations_into_ledger() + if self.tuning_enabled and effective_tuning_config and not self.database_was_reused: + quiet_console.print(self._applied_tuning_ledger.describe_outcome()) benchmark_type = run_config.get("benchmark_type", "olap") # Wrap the connection so session-configuration SETs are captured into @@ -1032,7 +1101,11 @@ def run_enhanced_benchmark(self, benchmark, **run_config) -> EnhancedBenchmarkRe ) execution_phases, total_exec_time, power_test_phase, throughput_test_phase = self._build_execution_phases( - query_results, query_executions, run_config, setup_phase + query_results, + query_executions, + run_config, + setup_phase, + power_workload_timing=self._last_power_workload_timing, ) platform_info, normalized_metadata = self._collect_platform_metadata( @@ -1153,8 +1226,10 @@ def run_enhanced_benchmark(self, benchmark, **run_config) -> EnhancedBenchmarkRe finally: self.capture_plans = plan_capture_config["capture_plans"] + self.show_query_plans = plan_capture_config["show_query_plans"] self.analyze_plans = plan_capture_config["analyze_plans"] self.strict_plan_capture = plan_capture_config["strict_plan_capture"] + self.normalize_plan_literals = plan_capture_config["normalize_plan_literals"] self.plan_capture_timeout_seconds = plan_capture_config["plan_capture_timeout_seconds"] if hasattr(self, "connection") and self.connection: self._close_run_connection() @@ -1286,7 +1361,7 @@ def _collect_platform_metadata(self, connection: Any) -> tuple[dict[str, Any], d When the statement overhead probe timed out, its abandoned worker may still hold the connection: any further use (even driver-level locks) can hang or crash this completed run, so collect nothing more from it - and degrade to the safely collected client_link block. + and retain only safely cached evidence without touching the connection. """ if self._link_probe_timed_out: self.logger.warning( @@ -1300,12 +1375,23 @@ def _collect_platform_metadata(self, connection: Any) -> tuple[dict[str, Any], d def _client_link_only_metadata(self) -> dict[str, Any]: """Degraded normalized metadata for a probe-tainted connection. - Carries only the safely collected ``client_link`` block so a timed-out - probe degrades the bundle instead of risking the completed run. + Retains safely cached client-link and cache-control evidence without + driver calls. Failed cache receipts must survive degradation so they + cannot become legacy receiptless evidence during bundle validation. """ + from benchbox.platforms.cloud_shared import empty_cache_control_receipt, sanitize_cache_control_receipt + + metadata: dict[str, Any] = {} if self._client_link_metadata: - return {"execution_environment": {"client_link": dict(self._client_link_metadata)}} - return {} + metadata["execution_environment"] = {"client_link": dict(self._client_link_metadata)} + cached_receipt = getattr(self, "_cache_control_receipt", None) + if cached_receipt is not None: + receipt = sanitize_cache_control_receipt(cached_receipt) + if receipt is None: + receipt = empty_cache_control_receipt() + receipt["errors"].append("Cached cache-control receipt has an unsupported shape") + metadata["platform_compute"] = {"cache_control": receipt} + return metadata def _resolve_normalized_metadata(self, connection: Any, platform_info: Mapping[str, Any] | None) -> dict[str, Any]: """Obtain normalized metadata and ensure run-scoped client_link metadata is included.""" @@ -1486,18 +1572,36 @@ def _setup_fresh_database_phases(self, benchmark, connection: Any, effective_tun tuning_metadata_saved = False if self.tuning_enabled and effective_tuning_config: quiet_console.print("Applying unified tuning configuration...") + apply_ledger = self._applied_tuning_ledger or AppliedTuningLedger() + ledger_before_apply = apply_ledger.snapshot() self.apply_unified_tuning(effective_tuning_config, connection) - quiet_console.print("✅ Unified tuning configuration applied") + quiet_console.print(apply_ledger.describe_apply_step(ledger_before_apply)) quiet_console.print("Saving tuning metadata...") tuning_metadata_saved = self.save_tuning_metadata(connection) - if tuning_metadata_saved: - quiet_console.print("✅ Tuning metadata saved") - else: + if not tuning_metadata_saved: quiet_console.print("⚠️ Failed to save tuning metadata") + elif self._tuning_marker_save_failed: + quiet_console.print( + "⚠️ Tuning metadata saved without section markers; drift checks have reduced coverage" + ) + else: + quiet_console.print("✅ Tuning metadata saved") + + if getattr(type(benchmark), "SKIP_DATA_LOADING", False): + quiet_console.print("Benchmark uses schema only; skipping data loading") + # Some adapters only materialize catalog objects inside load_data() + # (e.g. DataFusion creates empty tables from the recorded schema), + # so give them a hook to retain that step without loading files. + schema_only_stats = self.materialize_schema_only_tables(benchmark, connection) + data_loading_phase = self._create_enhanced_data_loading_phase(schema_only_stats, 0.0, {}) + data_loading_phase.status = "SKIPPED" + self._last_per_table_timings = {} + return schema_time, schema_creation_phase, 0.0, schema_only_stats, data_loading_phase, tuning_metadata_saved quiet_console.print("Loading benchmark data...") table_stats, loading_time, per_table_timings = self.load_data(benchmark, connection, data_dir) + require_loaded_tables(benchmark, table_stats) quiet_console.print(f"✅ Data loading completed in {loading_time:.2f}s") data_loading_phase = self._create_enhanced_data_loading_phase(table_stats, loading_time, per_table_timings) self._last_per_table_timings = per_table_timings diff --git a/benchbox/platforms/base/config_utils.py b/benchbox/platforms/base/config_utils.py index 1d19cce4e0..1167e7d7ee 100644 --- a/benchbox/platforms/base/config_utils.py +++ b/benchbox/platforms/base/config_utils.py @@ -205,6 +205,23 @@ def make_registered_platform_config_builder( ) +# Plan display/capture keys established alongside TUNING_FORWARD_KEYS. Every +# from_config implementation built on build_adapter_config forwards these +# verbatim when present in the input config -- no per-platform bespoke channel +# names. ``None`` means unset and is skipped so adapter defaults apply (this +# matters for the int-coerced keys, where an explicit None would crash). +PLAN_FORWARD_KEYS: tuple[str, ...] = ( + "show_query_plans", + "capture_plans", + "analyze_plans", + "strict_plan_capture", + "normalize_plan_literals", + "plan_queries", + "plan_capture_timeout_seconds", + "plan_max_depth", +) + + def build_adapter_config( config: dict[str, Any], *, @@ -238,4 +255,12 @@ def build_adapter_config( if key in config: adapter_config[key] = config[key] + # Same contract for plan display/capture keys: a from_config built on this + # helper must not silently drop --show-plans/--capture-plans. See + # PLAN_FORWARD_KEYS. None means unset and is skipped so adapter defaults + # (including int-coerced timeouts) apply. + for key in PLAN_FORWARD_KEYS: + if key in config and config[key] is not None: + adapter_config[key] = config[key] + return adapter_config diff --git a/benchbox/platforms/base/connection_lifecycle.py b/benchbox/platforms/base/connection_lifecycle.py index 4c12e2836c..9a8ed11b56 100644 --- a/benchbox/platforms/base/connection_lifecycle.py +++ b/benchbox/platforms/base/connection_lifecycle.py @@ -15,8 +15,9 @@ import logging import sys +from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Iterator from benchbox.utils.printing import quiet_console @@ -184,6 +185,23 @@ def check_benchmark_tables_exist(self, **connection_config) -> bool | None: """ return None + @contextmanager + def non_destructive_connection_context(self) -> Iterator[None]: + """Guard extra mid-run connections against create/drop handling. + + Helpers that must open their own connection mid-run (metadata capture, + introspection, link probe, statistics) wrap the open in this context so + the resulting `handle_existing_database` call takes the validation + early-return instead of validating an unfinished database and removing + it. Restores the prior flag value on exit; safe to nest. + """ + prior = getattr(self, "_validating_database", False) + self._validating_database = True + try: + yield + finally: + self._validating_database = prior + def handle_existing_database(self, **connection_config) -> None: """Handle existing database non-interactively for core/programmatic usage. @@ -218,6 +236,16 @@ def handle_existing_database(self, **connection_config) -> None: self.log_very_verbose("Inside validation context - skipping reuse/recreate logic.") return + # Once-per-run decision: the first call above decides reuse/recreate for + # the whole run. Any later connection (metadata, introspection, link + # probe, statistics helpers) must not re-validate an empty database and + # drop the run's own database mid-run. Reset per run (see + # _reset_run_scoped_state and _execute_load_only_mode). + if getattr(self, "_existing_db_decided", False): + self.log_very_verbose("Existing-database decision already made for this run - skipping.") + return + self._existing_db_decided = True + self.log_very_verbose("Checking if database exists...") if not self.check_database_exists(**connection_config): self.log_very_verbose("Database does not exist. Returning.") @@ -248,8 +276,14 @@ def handle_existing_database(self, **connection_config) -> None: self.logger.warning(f"⚠️ {warning}") if validation_result.issues: + from benchbox.core.tuning.metadata import NO_TUNING_METADATA_ERROR + + fresh_database_issue = f"Tuning: {NO_TUNING_METADATA_ERROR}" for issue in validation_result.issues: - self.logger.error(f"❌ {issue}") + if issue == fresh_database_issue and validation_result.database_empty: + self.log_verbose(issue) + else: + self.logger.error(f"❌ {issue}") if validation_result.is_valid: self.log_verbose("Database is configured for this run") @@ -285,6 +319,8 @@ def _remove_database(self, is_file_based: bool, db_path: str, **connection_confi self.logger.warning(f"Deleted database directory: {db_path_obj}") else: self.logger.warning("Database path exists but is neither file nor directory") + elif self.reset_database_in_place(**connection_config): + self.logger.warning("Emptied existing tables in place; schema kept for reload") else: self.drop_database(**connection_config) self.logger.warning("Dropped database") @@ -292,6 +328,16 @@ def _remove_database(self, is_file_based: bool, db_path: str, **connection_confi self.logger.error(f"Failed to remove database: {e}") raise RuntimeError(f"Could not remove existing database: {e}") from e + def reset_database_in_place(self, **connection_config) -> bool: + """Empty a server-side database for reload without dropping it. + + Return True when the database was reset in place, so the caller skips + ``drop_database``. The default does nothing and returns False. Override + it where dropping objects is costly, for example where dropped tables + keep counting against a catalog quota. + """ + return False + def drop_database(self, **connection_config) -> None: """Drop/remove database on server (for server-based databases). diff --git a/benchbox/platforms/base/data_loading.py b/benchbox/platforms/base/data_loading.py index a35c9ff418..887279022a 100644 --- a/benchbox/platforms/base/data_loading.py +++ b/benchbox/platforms/base/data_loading.py @@ -15,10 +15,11 @@ import logging import os import re +import shutil import subprocess import tempfile from abc import ABC, abstractmethod -from collections.abc import Iterator +from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass from datetime import date, datetime @@ -673,6 +674,18 @@ def _get_case_insensitive(mapping: dict[str, Any], key: str) -> Any: """Return a mapping value using exact or lower-case key lookup.""" return mapping.get(key, mapping.get(key.lower())) + @staticmethod + def _infer_format_from_paths(paths: set[Path]) -> str | None: + """Infer the data format from file extensions. + + v1 manifests carry no table_formats entry, so derive the format + (e.g. "tbl") from the replacement paths, transparent to compression + suffixes. Returns None when no path carries a recognized extension. + """ + inferred = [get_data_extension(path) for path in sorted(paths)] + inferred = [ext[1:] for ext in inferred if ext] + return inferred[0] if inferred else None + def _select_manifest_override_tables( self, source: DataSource, @@ -690,6 +703,8 @@ def _select_manifest_override_tables( selector = lambda paths: any(path.suffix.lower() != ".parquet" for path in paths) elif platform_name == "redshift" and table_mode != "external": selector = lambda paths: any(path.exists() and path.is_dir() for path in paths) + elif platform_name == "bigquery" and table_mode == "native": + selector = lambda paths: bool(paths) else: return @@ -707,8 +722,21 @@ def _select_manifest_override_tables( if replacement is None: continue + if platform_name == "bigquery": + current_paths = set(self._normalize_paths(table_paths)) + replacement_paths = set(self._normalize_paths(replacement)) + replacement_format = self._get_case_insensitive(manifest_source.table_formats, table_name) + if replacement_format is None: + # v1 manifests carry no table_formats; infer from the + # replacement paths so legacy manifests keep working. + replacement_format = self._infer_format_from_paths(replacement_paths) + if replacement_format != "tbl" or not current_paths < replacement_paths: + continue + source.tables[table_name] = replacement replacement_format = self._get_case_insensitive(manifest_source.table_formats, table_name) + if replacement_format is None and platform_name == "bigquery": + replacement_format = self._infer_format_from_paths(set(self._normalize_paths(replacement))) if replacement_format: source.table_formats[table_name.lower()] = str(replacement_format).lower() @@ -809,9 +837,18 @@ def open(self, file_path: Path) -> Iterator[Any]: File object for reading decompressed content Raises: + DataLoadingError: If the ``zstd`` command is not installed. Raised + when the first file is opened, before any table loads, instead + of surfacing later as an empty load. subprocess.CalledProcessError: If zstd decompression fails - FileNotFoundError: If zstd command not found """ + if shutil.which("zstd") is None: + raise DataLoadingError( + f"Cannot load zstd-compressed file '{file_path.name}': the 'zstd' command was not found " + "on PATH. Install it (e.g. 'brew install zstd' or 'apt-get install zstd') or regenerate " + "uncompressed data." + ) + # Log decompression start if adapter supports verbosity if self.adapter and hasattr(self.adapter, "log_verbose"): self.adapter.log_verbose(f"Decompressing {file_path.name} using system zstd command...") @@ -2678,11 +2715,17 @@ def load(self) -> tuple[dict[str, int], float]: table_stats = {} - # Resolve data source + # Resolve data source. A missing source -- or a source naming zero + # tables, such as a stale empty datagen manifest -- fails the run for + # benchmarks that expect data instead of loading nothing and + # validating vacuously. Benchmarks that legitimately skip data + # loading keep the previous empty result. data_source = self.resolver.resolve(self.benchmark, self.data_dir) - if not data_source: - self.adapter.log_very_verbose("No data source found") - return table_stats, elapsed_seconds(start_time) + if not data_source or not data_source.tables: + if getattr(self.benchmark, "SKIP_DATA_LOADING", False): + self.adapter.log_very_verbose("No data source found (data loading skipped)") + return table_stats, elapsed_seconds(start_time) + raise ValueError("No data files found. Ensure benchmark.generate_data() was called first.") table_stats = self._load_file_based_data(data_source) @@ -2925,6 +2968,122 @@ def _create_custom_handler(self, file_path: Path, table_name: str, data_source: return self.handler_factory(file_path, self.adapter, self.benchmark) +def run_staged_table_loads( + adapter: Any, + *, + tables: Mapping[str, Any], + stat_key: Callable[[str], str], + filter_files: Callable[[Any], list[Path]], + load_one: Callable[[str, list[Path]], int], + on_table_loaded: Callable[[str, str, int], None] | None = None, + record_timings: bool, + fail_fast: bool, + success_log: Callable[[str], None] | None = None, + summary_log: Callable[[str], None] | None = None, + describe_start: Callable[[str, str], str] | None = None, + phase_start: float | None = None, +) -> tuple[dict[str, int], float, dict[str, Any] | None]: + """Drive the shared staged-load orchestration loop for cloud adapters. + + Redshift and Snowflake independently implemented the same per-table loop: + skip tables with no valid files, run the platform load command, record + per-table row counts keyed by engine-case-folded names, and log success + or failure. The copies drifted in diagnostic fidelity (truncated versus + full error text) while sharing the mechanics, so this template owns the + mechanics and each caller supplies its platform hooks. + + Deliberately caller-owned behavior, preserved exactly per platform: + + - ``stat_key`` folds the stats key (``str.lower`` for Redshift, + ``str.upper`` for Snowflake) to match each engine's DDL case. + Downstream consumers only aggregate the values, but published payloads + carry the folded keys, so the template never normalizes them itself. + - ``fail_fast`` re-raises a table failure after recording zeros + (Snowflake full-refresh semantics); otherwise the loop continues. + - ``record_timings`` returns per-table ``{"total_ms": ...}`` timings or + ``None`` for adapters that do not report them yet (Redshift). + - ``on_table_loaded`` runs caller post-load work such as CTAS sorting + inside the measured per-table window, so timings and success lines + cover it. + + Error reporting always logs the full error text: truncation hid the + cause class on the adopting call sites with no consumer depending on it. + + Args: + adapter: Platform adapter used for ``logger`` and ``log_verbose``. + tables: Mapping of table name to resolved file paths. + stat_key: Fold a table name to its stats-dict key. + filter_files: Keep the valid files for one table's paths. + load_one: Load one table's files, returning its row count. + on_table_loaded: Optional hook called with + ``(table_name, stats_key, row_count)`` after a successful load. + record_timings: Record per-table wall-clock timings. + fail_fast: Re-raise table failures instead of continuing. + success_log: Sink for per-table success lines (defaults to + ``adapter.logger.info``; Snowflake keeps its verbose-gated form). + summary_log: Sink for the total-rows summary (same defaulting). + describe_start: Build the per-table start line from + ``(table_name, chunk_info)``. Defaults to + ``"Loading data for table: {table}{chunk}"``; Redshift's direct + INSERT branch keeps its distinct wording through this hook. + phase_start: Caller-owned phase clock captured before caller-side + setup (cursor creation, query-tag/file-format setup, file + resolution, S3 client creation). Each adapter previously timed + from ``load_data`` entry, so the template defaults to its own + loop entry only when the caller passes nothing. + + Returns: + Tuple of (table_stats, total_seconds, per_table_timings or None). + """ + table_stats: dict[str, int] = {} + per_table_timings: dict[str, Any] = {} + start_time = mono_time() if phase_start is None else phase_start + log_success = success_log if success_log is not None else adapter.logger.info + log_summary = summary_log if summary_log is not None else adapter.logger.info + if describe_start is None: + + def describe_start(table_name: str, chunk_info: str) -> str: + return f"Loading data for table: {table_name}{chunk_info}" + + for table_name, file_paths in tables.items(): + key = stat_key(table_name) + valid_files = filter_files(file_paths) + + if not valid_files: + adapter.logger.warning(f"Skipping {table_name} - no valid data files") + table_stats[key] = 0 + if record_timings: + per_table_timings[key] = {"total_ms": 0} + continue + + chunk_info = f" from {len(valid_files)} file(s)" if len(valid_files) > 1 else "" + adapter.log_verbose(describe_start(table_name, chunk_info)) + + try: + load_start = mono_time() + row_count = load_one(table_name, valid_files) + table_stats[key] = row_count + if on_table_loaded is not None: + on_table_loaded(table_name, key, row_count) + load_time = elapsed_seconds(load_start) + if record_timings: + per_table_timings[key] = {"total_ms": load_time * 1000} + log_success(f"✅ Loaded {row_count:,} rows into {key}{chunk_info} in {load_time:.2f}s") + except Exception as exc: + error_message = str(exc) or repr(exc) or type(exc).__name__ + adapter.logger.error(f"Failed to load {table_name}: {error_message}") + table_stats[key] = 0 + if record_timings: + per_table_timings[key] = {"total_ms": 0} + if fail_fast: + raise + + total_time = elapsed_seconds(start_time) + total_rows = sum(table_stats.values()) + log_summary(f"✅ Loaded {total_rows:,} total rows in {total_time:.2f}s") + return table_stats, total_time, per_table_timings if record_timings else None + + class SchemaHelpersMixin: """Mixin providing schema creation and platform metadata helpers. @@ -3189,6 +3348,7 @@ def _log_constraint_configuration(self, enable_primary_keys: bool, enable_foreig "InMemoryDataHandler", "FileFormatRegistry", "DataLoader", + "run_staged_table_loads", "validate_sql_identifier", "escape_sql_string_literal", ] diff --git a/benchbox/platforms/base/dialect_translation.py b/benchbox/platforms/base/dialect_translation.py index a7c76eb1a3..3cc25b8375 100644 --- a/benchbox/platforms/base/dialect_translation.py +++ b/benchbox/platforms/base/dialect_translation.py @@ -64,6 +64,7 @@ def translate_sql( return sql from benchbox.utils.dialect_utils import ( + NO_IDENTIFY_DIALECTS, SQLTranslationError, SqlTranslationOutcome, _fingerprint_sql, @@ -84,7 +85,8 @@ def translate_sql( try: import sqlglot - should_identify = tgt not in ("clickhouse", "postgres", "snowflake") + # Targets in NO_IDENTIFY_DIALECTS skip quoting (shared constant). + should_identify = tgt not in NO_IDENTIFY_DIALECTS translated_statements = sqlglot.transpile(sql, read=src, write=tgt, identify=should_identify) diff --git a/benchbox/platforms/base/execution.py b/benchbox/platforms/base/execution.py index 4e6ad7796e..2e58f067d4 100644 --- a/benchbox/platforms/base/execution.py +++ b/benchbox/platforms/base/execution.py @@ -60,6 +60,7 @@ class from `benchbox.core.{tpch,tpcds}.*`, and funnels the structured open_stream_connection, require_throughput_stream_capability, ) +from benchbox.utils.clock import elapsed_seconds, mono_time from benchbox.utils.dialect_utils import SQLTranslationError from benchbox.utils.printing import quiet_console @@ -754,27 +755,37 @@ def _execute_queries_by_type(self, benchmark, connection: Any, run_config: dict) and getattr(self, "plan_capture_phase_eligible", False) and not getattr(self, "dry_run_mode", False) ) - if not phase_eligible: - results = self._dispatch_queries_by_type(benchmark, connection, run_config) - self._contain_outstanding_throughput_work(run_config) - return results - # Isolate capture: record executed queries during the timed run, capture after. # ``_captured_plans`` is the per-run accumulator keyed by capture key; it lets # capture happen in more than one isolated pass (a pre-mutation checkpoint plus # the final pass) while plans are attached to result rows exactly once at the # end. Reset here so a reused adapter never carries plans across runs. - self._plan_capture_phase_active = True - self._phase_recorded_queries = {} - self._captured_plans = {} + self._last_power_workload_timing = None + if phase_eligible: + self._plan_capture_phase_active = True + self._phase_recorded_queries = {} + self._captured_plans = {} + workload_start_time = datetime.now().isoformat() + workload_start = mono_time() try: results = self._dispatch_queries_by_type(benchmark, connection, run_config) finally: - self._plan_capture_phase_active = False + if phase_eligible: + self._plan_capture_phase_active = False + workload_duration_ms = int(elapsed_seconds(workload_start) * 1000) + workload_end_time = datetime.now().isoformat() + effective_type = run_config.get("_effective_execution_type") or run_config.get( + "test_execution_type", "standard" + ) + if effective_type in {"standard", "power"} and not self.is_dry_run: + # Include driver preparation, warmups and history lookups, but stop + # before isolated EXPLAIN capture or any post-workload probes. + self._last_power_workload_timing = (workload_start_time, workload_end_time, workload_duration_ms) if not self._contain_outstanding_throughput_work(run_config): return results - self._capture_plans_post_measurement(connection, dict(self._phase_recorded_queries), results) + if phase_eligible: + self._capture_plans_post_measurement(connection, dict(self._phase_recorded_queries), results) return results def _contain_outstanding_throughput_work(self, run_config: dict[str, Any]) -> bool: @@ -987,7 +998,14 @@ def _get_runtime_platform_version(self, connection: Any | None) -> str | None: return None return str(version) - def _get_dialect_queries(self, benchmark, benchmark_slug: str, connection: Any | None = None) -> dict: + def _get_dialect_queries( + self, + benchmark, + benchmark_slug: str, + connection: Any | None = None, + *, + strict_translation: bool = False, + ) -> dict: """Get queries with platform-specific dialect translation if supported. Args: @@ -1022,7 +1040,11 @@ def _get_dialect_queries(self, benchmark, benchmark_slug: str, connection: Any | return benchmark.get_queries() except SQLTranslationError: raise - except Exception: + except Exception as exc: + if strict_translation: + raise RuntimeError( + f"Dialect query extraction failed for benchmark {benchmark_slug or 'generic'}" + ) from exc return benchmark.get_queries() return benchmark.get_queries() @@ -1156,6 +1178,16 @@ def _execute_single_query( def _execute_operation_query(self, benchmark, connection: Any, query_id: str) -> dict[str, Any]: """Execute a benchmark operation (INSERT/UPDATE/DELETE) and return result dict.""" + # Operation benchmarks drive every statement (setup probes, writes, + # validation reads, cleanup) through ``connection.execute``. Embedded + # engines expose that directly, but several adapters hand back handles + # without it (Snowflake's DBAPI connection, BigQuery's Client), which + # previously failed every op with "Invalid connection type". Adapt such + # handles through PlatformAdapterConnection so statements flow through + # the adapter's own execute_query path. Handles that already expose + # ``.execute`` keep the existing direct path unchanged. + if not hasattr(connection, "execute"): + connection = PlatformAdapterConnection(connection, self) op_kwargs: dict[str, Any] = {} # Engines sharing a SQL dialect but differing in capability (e.g. DuckLake # on the DuckDB dialect) set operation_platform_key so benchmarks resolve diff --git a/benchbox/platforms/base/models.py b/benchbox/platforms/base/models.py index 828db844c5..314fe50e02 100644 --- a/benchbox/platforms/base/models.py +++ b/benchbox/platforms/base/models.py @@ -85,6 +85,7 @@ class DatabaseValidationResult: tuning_valid: bool | None = None tables_valid: bool | None = None row_counts_valid: bool | None = None + database_empty: bool = False __all__ = [ diff --git a/benchbox/platforms/base/mysql_wire.py b/benchbox/platforms/base/mysql_wire.py index ad71f45e1c..dc8e3eeb3d 100644 --- a/benchbox/platforms/base/mysql_wire.py +++ b/benchbox/platforms/base/mysql_wire.py @@ -10,37 +10,64 @@ from benchbox.utils.sql_identifier import is_valid_sql_identifier +def _skip_comment(sql: str, i: int, n: int) -> int | None: + """Return next index if sql[i] starts a comment, else None.""" + if sql[i] == "-" and i + 1 < n and sql[i + 1] == "-": + i += 2 + while i < n and sql[i] != "\n": + i += 1 + return i + if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*": + i += 2 + while i < n and not (sql[i] == "*" and i + 1 < n and sql[i + 1] == "/"): + i += 1 + return min(i + 2, n) + return None + + +def _skip_quoted(sql: str, i: int, n: int, quote: str) -> int: + """Return next index after closing quote for literal or identifier starting at i.""" + i += 1 + while i < n: + if sql[i] == "\\" and i + 1 < n: + i += 2 + continue + if sql[i] == quote: + if quote in ("'", '"') and i + 1 < n and sql[i + 1] == quote: + i += 2 + continue + return i + 1 + i += 1 + return n + + def split_sql_statements(sql: str) -> list[str]: - """Split SQL on semicolons outside single-quoted string literals.""" + """Split SQL on semicolons outside string literals, identifiers, and comments.""" statements: list[str] = [] current: list[str] = [] i = 0 - while i < len(sql): + n = len(sql) + while i < n: char = sql[i] - if char == "'": - start = i - i += 1 - while i < len(sql): - if sql[i] == "\\" and i + 1 < len(sql): - i += 2 - continue - if sql[i] == "'": - if i + 1 < len(sql) and sql[i + 1] == "'": - i += 2 - continue - i += 1 - break - i += 1 - current.append(sql[start:i]) - elif char == ";": + comment_end = _skip_comment(sql, i, n) + if comment_end is not None: + current.append(sql[i:comment_end]) + i = comment_end + continue + if char in ("'", '"', "`"): + quote_end = _skip_quoted(sql, i, n, char) + current.append(sql[i:quote_end]) + i = quote_end + continue + if char == ";": stmt = "".join(current).strip() if stmt: statements.append(stmt) current = [] i += 1 - else: - current.append(char) - i += 1 + continue + current.append(char) + i += 1 stmt = "".join(current).strip() if stmt: statements.append(stmt) diff --git a/benchbox/platforms/base/result_capture.py b/benchbox/platforms/base/result_capture.py index 1a7c948362..2b53ba786d 100644 --- a/benchbox/platforms/base/result_capture.py +++ b/benchbox/platforms/base/result_capture.py @@ -711,6 +711,11 @@ def _collect_resource_utilization(self) -> dict[str, Any]: def display_query_plan_if_enabled(self, connection: Any, query: str, query_id: str) -> None: """Display query execution plan if show_query_plans is enabled. + Suppressed while capture_plans is active: capture already runs EXPLAIN + in the isolated post-measurement phase, so displaying here would issue + EXPLAIN a second time. Centralized here (rather than at each call + site) so new adapters cannot accidentally double-EXPLAIN. + Args: connection: Database connection query: SQL query text @@ -718,6 +723,8 @@ def display_query_plan_if_enabled(self, connection: Any, query: str, query_id: s """ if not self.show_query_plans: return + if self.capture_plans: + return try: plan = self.get_query_plan(connection, query) @@ -1099,6 +1106,54 @@ def _merge_plan_capture_into_result( if plan_capture_time_ms is not None: result["plan_capture_time_ms"] = plan_capture_time_ms + def execute_query_with_plan_capture( + self, + execute: Callable[..., dict[str, Any]], + connection: Any, + query: str, + query_id: str, + benchmark_type: str | None = None, + scale_factor: float | None = None, + validate_row_count: bool = True, + stream_id: int | None = None, + ) -> dict[str, Any]: + """Run one query through the shared executor, then merge plan capture. + + Firebolt, Presto/Trino, PostgreSQL, SingleStore, and Doris each wrapped + the shared cursor execution with the same two lines: delegate to the + parent executor, then merge SUCCESS-guarded plan fields into the + result. This method owns that idiom so the copies cannot drift; each + adapter keeps its thin ``execute_query`` override (and its platform + docstring) and forwards its own ``super().execute_query`` as + ``execute``. Adapters with genuinely different semantics — Redshift's + FAILED guard and display logic, pg_mooncake's transaction retry, + QuestDB's rewriter path — keep their bespoke overrides. + + Args: + execute: Bound parent ``execute_query`` to delegate to. + connection: Database connection. + query: SQL query text. + query_id: Query identifier. + benchmark_type: Benchmark family for validation. + scale_factor: Scale factor for validation. + validate_row_count: Whether to validate row counts. + stream_id: Throughput stream identifier. + + Returns: + Query result dict with plan fields merged when captured. + """ + result = execute( + connection=connection, + query=query, + query_id=query_id, + benchmark_type=benchmark_type, + scale_factor=scale_factor, + validate_row_count=validate_row_count, + stream_id=stream_id, + ) + self._merge_plan_capture_into_result(result, connection, query, query_id) + return result + def validate_loaded_data(self, connection: Any, benchmark_type: str, scale_factor: float) -> ValidationResult: """Validate database state after data loading using platform-specific methods. @@ -1854,7 +1909,15 @@ def _log_plan_capture_summary(self, query_results: list[dict[str, Any]]) -> None log_fn = self.logger.warning if capture_failures else self.logger.info log_fn(summary_message) - def _build_execution_phases(self, query_results, query_executions, run_config, setup_phase) -> tuple: + def _build_execution_phases( + self, + query_results, + query_executions, + run_config, + setup_phase, + *, + power_workload_timing: tuple[str, str, int] | None = None, + ) -> tuple: """Build power/throughput test phases and return execution phases with metrics. Returns: @@ -1881,10 +1944,20 @@ def _build_execution_phases(self, query_results, query_executions, run_config, s power_test_phase = None if execution_type not in {"throughput"}: + # Only a standalone executed power workload supplies wall boundaries. + # Keep query aggregates independent for latency and cost consumers; + # combined/maintenance callers retain their existing phase accounting. + # Wall-based effective cost per hour uses this phase duration, while + # query-based spend inputs remain unchanged. + start_time, end_time, duration_ms = power_workload_timing or ( + _datetime.now().isoformat(), + _datetime.now().isoformat(), + int(total_exec_time * 1000), + ) power_test_phase = PowerTestPhase( - start_time=_datetime.now().isoformat(), - end_time=_datetime.now().isoformat(), - duration_ms=int(total_exec_time * 1000), + start_time=start_time, + end_time=end_time, + duration_ms=duration_ms, query_executions=query_executions, geometric_mean_time=avg_time, power_at_size=power_at_size_value, @@ -1961,6 +2034,20 @@ def _build_execution_metadata(self, run_config: dict) -> tuple: }, "sorted_ingestion": self.get_sorted_ingestion_metadata(), } + from benchbox.core.results.builder import normalize_benchmark_id + + if normalize_benchmark_id(str(run_config.get("benchmark_name") or "")) == "tpch": + from benchbox.core.tpch.benchmark import describe_query_parameters + + seed = run_config.get("seed") + execution_type = run_config.get("test_execution_type", "standard") + if run_config.get("_effective_execution_type") is not None: + execution_type = "standard" + execution_metadata["run_config"]["query_parameters"] = describe_query_parameters( + None if seed is None else int(seed), + execution_type=execution_type, + requested_phases=set((run_config.get("options") or {}).get("requested_phases") or []), + ) tuning_profile_metadata = self._build_tuning_profile_metadata(run_config) if tuning_profile_metadata: execution_metadata["tuning_profile"] = tuning_profile_metadata diff --git a/benchbox/platforms/base/spark_execution_mixin.py b/benchbox/platforms/base/spark_execution_mixin.py index cf7f6baa9c..4826cc8fa1 100644 --- a/benchbox/platforms/base/spark_execution_mixin.py +++ b/benchbox/platforms/base/spark_execution_mixin.py @@ -261,8 +261,8 @@ def _load_data_spark( data_source = resolver.resolve(benchmark, Path(data_dir)) if not data_source or not data_source.tables: - if getattr(benchmark, "get_data_source_benchmark", lambda: object())() is None: - self.logger.info("Benchmark declares no data source; skipping Spark data load") + if getattr(type(benchmark), "SKIP_DATA_LOADING", False): + self.logger.info("Benchmark is schema-only; skipping Spark data load") return table_stats, elapsed_seconds(start_time), per_table_timings raise ValueError( f"No data files found in {data_dir}. Ensure benchmark.generate_data() was called first." diff --git a/benchbox/platforms/base/tuning_config.py b/benchbox/platforms/base/tuning_config.py index 71e5225991..44af7a0a91 100644 --- a/benchbox/platforms/base/tuning_config.py +++ b/benchbox/platforms/base/tuning_config.py @@ -52,6 +52,7 @@ class TuningConfigMixin: canonical_platform_type: str logger: logging.Logger tuning_enabled: bool + _tuning_marker_save_failed: bool def apply_unified_tuning(self, unified_config: UnifiedTuningConfiguration, connection: Any) -> None: """Apply unified tuning configuration to the database. @@ -133,10 +134,12 @@ def _validate_database_tunings(self, **connection_config): temp_connection = self.create_connection(**connection_config) try: - metadata_manager = TuningMetadataManager(self, connection_config=connection_config) + metadata_manager = TuningMetadataManager( + self, connection_config=connection_config, connection=temp_connection + ) effective_config = self.get_effective_tuning_configuration() - if effective_config: + if effective_config and self.tuning_enabled: result = metadata_manager.validate_unified_tunings(effective_config) else: existing_tunings = metadata_manager.load_unified_tunings() @@ -176,6 +179,7 @@ def save_tuning_metadata(self, connection: Any) -> bool: Returns: True if metadata was saved successfully, False otherwise """ + self._tuning_marker_save_failed = False effective_config = self.get_effective_tuning_configuration() if not self.tuning_enabled or not effective_config: return True @@ -183,8 +187,9 @@ def save_tuning_metadata(self, connection: Any) -> bool: try: from benchbox.core.tuning.metadata import TuningMetadataManager - metadata_manager = TuningMetadataManager(self) + metadata_manager = TuningMetadataManager(self, connection=connection) saved = metadata_manager.save_unified_tunings(effective_config) + self._tuning_marker_save_failed = bool(metadata_manager.marker_save_failed) if metadata_manager.marker_save_failed: from benchbox.core.tuning.metadata import MetadataValidationResult diff --git a/benchbox/platforms/base/validation.py b/benchbox/platforms/base/validation.py index 4a14a1a0e3..7af534aa81 100644 --- a/benchbox/platforms/base/validation.py +++ b/benchbox/platforms/base/validation.py @@ -21,6 +21,7 @@ class ValidationResult: is_valid: bool errors: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) + database_empty: bool = False def add_error(self, error: str) -> None: """Add an error message.""" @@ -158,6 +159,7 @@ def validate(self, connection: Any) -> ValidationResult: # Handle missing tables if missing_tables: result.add_error(f"Missing tables: {', '.join(sorted(missing_tables))}") + result.database_empty = missing_tables == expected_tables # Handle extra tables (after filtering system tables) if extra_tables: @@ -518,6 +520,7 @@ def validate(self) -> DatabaseValidationResult: tuning_valid = None tables_valid = None row_counts_valid = None + database_empty = False try: # Create temporary connection for validation @@ -531,6 +534,7 @@ def validate(self) -> DatabaseValidationResult: # 2. Validate schema and tables schema_result = self.schema_validator.validate(connection) tables_valid = schema_result.is_valid + database_empty = schema_result.database_empty issues.extend(schema_result.errors) warnings.extend(schema_result.warnings) @@ -567,6 +571,7 @@ def validate(self) -> DatabaseValidationResult: tuning_valid=tuning_valid, tables_valid=tables_valid, row_counts_valid=row_counts_valid, + database_empty=database_empty, ) def _determine_validity( diff --git a/benchbox/platforms/bigquery.py b/benchbox/platforms/bigquery.py index 8af00fd978..3e420bca7a 100644 --- a/benchbox/platforms/bigquery.py +++ b/benchbox/platforms/bigquery.py @@ -17,11 +17,13 @@ import tempfile import time from collections.abc import Mapping +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import TYPE_CHECKING, Any from benchbox.core.errors import PlanCaptureError from benchbox.platforms.base.config_utils import make_registered_platform_config_builder +from benchbox.platforms.base.mysql_wire import split_sql_statements from benchbox.platforms.base.tuning import make_informational_constraint_applier from benchbox.utils.clock import elapsed_seconds, mono_time @@ -658,6 +660,81 @@ def get_target_dialect(self) -> str: """Return the target SQL dialect for BigQuery.""" return "bigquery" + def preprocess_operation_sql(self, query_id: str, operation: Any) -> str | None: + """Rewrite operation write SQL for BigQuery-only dialect gaps. + + Respects catalog ``bigquery`` overrides (including skip ``None``): + rewrites the override when present, otherwise the default write SQL. + Four rewrites, each linear and single-level by construction of the + catalog SQL they target (verified live: the unmodified forms fail + server-side with ``Type not found: VARCHAR`` and ``INT64`` interval + complaints while COUNT(*) validations kept passing): + + - ``CAST(x AS VARCHAR)`` -> ``CAST(x AS STRING)`` + - ``INTERVAL 'N' UNIT`` -> ``INTERVAL N UNIT`` + - a missing ``WHERE`` on an UPDATE/DELETE statement gains + ``WHERE true`` (BigQuery rejects filter-less DML; constant-true + preserves the full-table intent) + - a trailing bare ``WHEN NOT MATCHED ... THEN INSERT`` gains + ``ROW`` (Snowflake shorthand for inserting the source row; + BigQuery requires ``INSERT ROW``) + """ + import re + + overrides = getattr(operation, "platform_overrides", None) or {} + if "bigquery" in overrides: + base = overrides["bigquery"] + if base is None: + return None + else: + base = operation.write_sql + rewritten = re.sub( + r"\bCAST\(([^()]+?)\s+AS\s+VARCHAR\s*\)", + r"CAST(\1 AS STRING)", + base, + flags=re.IGNORECASE, + ) + rewritten = re.sub(r"\bINTERVAL\s+'(\d+)'\s+([A-Za-z]+)", r"INTERVAL \1 \2", rewritten) + rewritten = self._numeric_decimal_literals(rewritten) + statements = split_sql_statements(rewritten) + parts = [] + changed = False + for statement in statements: + if re.match(r"(?i)^\s*(UPDATE|DELETE)\b", statement) and not re.search(r"(?i)\bWHERE\b", statement): + statement = statement.rstrip() + "\nWHERE true" + changed = True + parts.append(statement) + if changed: + rewritten = ";\n".join(parts) + if base.endswith("\n"): + rewritten += "\n" + # A trailing bare ``WHEN NOT MATCHED ... THEN INSERT`` (Snowflake + # shorthand for "insert the source row", verified live on Snowflake) + # is a BigQuery syntax error: spell it ``INSERT ROW``. + bare_insert = re.search(r"(?i)(WHEN\s+NOT\s+MATCHED\b[^\n;]*?THEN\s+)INSERT(\s*)$", rewritten) + if bare_insert: + rewritten = rewritten[: bare_insert.start()] + bare_insert.group(1) + "INSERT ROW" + bare_insert.group(2) + return rewritten + + @staticmethod + def _numeric_decimal_literals(sql: str) -> str: + """Type bare decimal literals in operation SQL as NUMERIC. + + BigQuery types ``1000.0`` as FLOAT64 and refuses to insert FLOAT64 into + the NUMERIC money and discount columns of the TPC-H staging tables + (verified live: "Query column 4 has type FLOAT64 which cannot be + inserted into column o_totalprice, which has type NUMERIC"). Every + decimal literal in the write catalogs targets those columns, so + ``NUMERIC '1000.0'`` keeps the arithmetic exact and the insert legal. + String literals and identifiers are left untouched. + """ + import re + + parts = re.split(r"('(?:[^'\\]|\\.|'')*')", sql) + for index in range(0, len(parts), 2): + parts[index] = re.sub(r"(? dict[str, Any]: """Get standardized connection parameters.""" return { @@ -1045,8 +1122,17 @@ def create_schema(self, benchmark, connection: Any) -> float: # Use common schema creation helper schema_sql = self._create_schema_with_tuning(benchmark, source_dialect="standard") - # Split schema into individual statements and execute - statements = [stmt.strip() for stmt in schema_sql.split(";") if stmt.strip()] + # Split schema into individual statements and execute. Chunks that + # hold only decorative "--" comments (a ";" inside a comment + # splits one off) carry no DDL: skip them instead of submitting + # a comment-only query job. + from benchbox.platforms.cloud_shared import split_leading_sql_comments + + statements = [ + stmt.strip() + for stmt in schema_sql.split(";") + if stmt.strip() and split_leading_sql_comments(stmt)[1].strip() + ] for statement in statements: # Convert to BigQuery table definition (normalizing table name to uppercase @@ -1250,54 +1336,61 @@ def _load_table_via_cloud_storage( benchmark: Any | None = None, ) -> int: """Load one table through GCS staging.""" + if not valid_files: + raise ValueError(f"No source files for BigQuery table {table_name}") + if len(valid_files) > 10_000: + raise ValueError(f"BigQuery table {table_name} exceeds the 10,000 source URI limit") resolved_name, table_ref = self._resolve_target_table(connection, table_name) - for file_idx, file_path in enumerate(valid_files): - chunk_info = f" (chunk {file_idx + 1}/{len(valid_files)})" if len(valid_files) > 1 else "" - - blob_name = f"{self.storage_prefix}/{table_name}_{file_idx}{''.join(file_path.suffixes)}" - self.log_very_verbose(f"Uploading to Cloud Storage{chunk_info}: {blob_name}") - blob = bucket.blob(blob_name) - blob.upload_from_filename(str(file_path)) - - write_disposition = ( - bigquery.WriteDisposition.WRITE_TRUNCATE if file_idx == 0 else bigquery.WriteDisposition.WRITE_APPEND - ) - job_config = self._build_load_job_config( + job_configs = [ + self._build_load_job_config( file_path, - write_disposition=write_disposition, + write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, allow_quoted_newlines=True, table_name=table_name, data_source=data_source, benchmark=benchmark, ) - if file_idx > 0: - # BigQuery limits table update operations to 5 per 10s per table. - # Small throttle between chunks prevents hitting rate limits on small multi-file tables. - time.sleep(1.0) + for file_path in valid_files + ] + job_config = job_configs[0] + if any(config.to_api_repr() != job_config.to_api_repr() for config in job_configs[1:]): + raise ValueError(f"BigQuery source files for {table_name} have incompatible load settings") + + def upload_file(item: tuple[int, Any]) -> str: + file_idx, file_path = item + if is_cloud_path(str(file_path)): + return str(file_path) + path = Path(file_path) + blob_name = f"{self.storage_prefix}/{table_name}_{file_idx}{''.join(path.suffixes)}" + self.log_very_verbose(f"Uploading to Cloud Storage: {blob_name}") + bucket.blob(blob_name).upload_from_filename(str(path)) + return f"gs://{self.storage_bucket}/{blob_name}" - uri = f"gs://{self.storage_bucket}/{blob_name}" - max_retries = 5 - load_job = None - for attempt in range(max_retries): - try: - if load_job is None: - # A submission 429 means BigQuery rejected the - # request, so resubmitting is safe. - load_job = connection.load_table_from_uri(uri, table_ref, job_config=job_config) - # A polling 429 means the accepted job may already be - # running or done server-side: re-poll the same job - # instead of submitting a duplicate append. - load_job.result() - break - except TooManyRequests as e: - if attempt >= max_retries - 1: - raise - sleep_seconds = 2.5 * (2**attempt) - self.logger.warning( - f"Hit BigQuery rate limit on {table_name} chunk {file_idx + 1}, retrying in {sleep_seconds:.1f}s: {e}" - ) - time.sleep(sleep_seconds) + with ThreadPoolExecutor(max_workers=min(8, len(valid_files))) as uploads: + uris = list(uploads.map(upload_file, enumerate(valid_files))) + + max_retries = 5 + load_job = None + for attempt in range(max_retries): + try: + if load_job is None: + # A submission 429 means BigQuery rejected the request; + # a polling 429 must re-poll the accepted job. + load_job = connection.load_table_from_uri(uris, table_ref, job_config=job_config) + load_job.result() + break + except TooManyRequests as e: + if attempt >= max_retries - 1: + raise + sleep_seconds = 2.5 * (2**attempt) + self.logger.warning( + "Hit BigQuery rate limit loading %s, retrying in %.1fs: %s", + table_name, + sleep_seconds, + e, + ) + time.sleep(sleep_seconds) return self._get_table_row_count(connection, resolved_name) @@ -1678,18 +1771,28 @@ def execute_query( start_time = mono_time() try: - # Replace table references with fully qualified names + # Route DDL through the standard converter first: benchmark + # setup() CREATEs arrive here (not via create_schema), and + # without conversion they land lowercase while every probe and + # query resolves the UPPERCASE adapter convention (BigQuery + # identifiers are case-sensitive). Non-CREATE statements pass + # through unchanged. + translated_query = self._convert_to_bigquery_table(query) + # Replace table references with fully qualified names. The + # query-time connection carries no default dataset, so + # unqualified names fail with "must be qualified with a dataset". # Note: Query dialect translation is now handled automatically by the base adapter - # Skip qualification if query has backtick-quoted identifiers (from sqlglot) - # because default_dataset handles unqualified names and regex breaks backticks - if "`" in query: + if "`" in translated_query: # Query processed by sqlglot with identify=True # Normalize lowercase table names to UPPERCASE to match TPC-DS schema # (BigQuery backtick-quoted identifiers are case-sensitive) - translated_query = self._normalize_table_names_case(query) + translated_query = self._normalize_table_names_case(translated_query) + # Translated backtick queries skip the static fallback: when + # the parser cannot see into the statement, rewriting TPC-H + # names blindly could corrupt it. + translated_query = self._qualify_table_names(translated_query, allow_fallback=("`" not in query)) else: - # Non-translated queries (e.g., raw TPC-H) need explicit qualification - translated_query = self._qualify_table_names(query) + translated_query = self._qualify_table_names(translated_query) # TPC-DI uses SQL Server/SQLite idioms (BIT flag literals, # JULIANDAY, DATE('now')) that BigQuery rejects. Config names @@ -1807,88 +1910,234 @@ def execute_query( "error_type": type(e).__name__, } - def _convert_to_bigquery_table(self, statement: str) -> str: - """Convert CREATE TABLE statement to BigQuery format. - - Makes tables idempotent by using CREATE OR REPLACE TABLE. - Table names are normalized to UPPERCASE to match the adapter-wide - convention used by loads, validation, row counts, and query - normalization (TPC-DS DDL sources use lowercase names). + @staticmethod + def _inline_pk_columns(search_text: str) -> list[str]: + """Column names carrying an inline PRIMARY KEY. + + The column scan backtracks catastrophically on statements with no + PRIMARY KEY at all (verified hang on a 47-char CTAS that never + reaches BigQuery), so it only runs when the keywords are present. + The presence test is linear; on a hit the scan behaves exactly as + the historical inline findall. """ import re - if not statement.strip().upper().startswith("CREATE"): - return statement + if not re.search(r"PRIMARY\s+KEY", search_text, flags=re.IGNORECASE): + return [] + _pk_keywords = {"PRIMARY", "CONSTRAINT", "FOREIGN", "UNIQUE", "CHECK", "KEY"} - pattern = re.compile( - r"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([a-zA-Z0-9_.]+)`?\s*(\(.*)", - re.IGNORECASE | re.DOTALL, - ) - match = pattern.match(statement) - if match: - # Uppercase only the table segment; a qualified name keeps its - # project/dataset case (BigQuery table identifiers are - # case-sensitive while the adapter convention is UPPERCASE tables). - table_name = match.group(1) - if "." in table_name: - *qualifier, bare = table_name.split(".") - table_name = ".".join([*qualifier, bare.upper()]) - else: - table_name = table_name.upper() - rest = match.group(2) - # BigQuery table identifiers are case-sensitive: normalize the - # table segment to UPPERCASE to match the adapter-wide convention - # used by loads, validation, row counts, and query qualification. - # Project and dataset segments keep their configured case. - if f"{self.dataset_id}." not in table_name: - qualified_table = f"`{self.project_id}.{self.dataset_id}.{table_name.upper()}`" - elif not table_name.startswith("`"): - *qualifier, bare = table_name.split(".") - qualified_table = "`" + ".".join([*qualifier, bare.upper()]) + "`" + def _match_segment(segment: str) -> str | None: + match = re.search( + r"[`\"']?(\w+)[`\"']?\s+(?:[A-Z0-9_]+\s*(?:\([^()]*\))?\s*)+?" + r"PRIMARY\s+KEY(?!\s*\()", + segment, + flags=re.IGNORECASE, + ) + if match and match.group(1).upper() not in _pk_keywords: + return match.group(1) + return None + + # Column definitions may share one line (single-line DDL), so match + # per comma-separated segment rather than per line. Split on commas + # outside parentheses so parameterized types (DECIMAL(10, 2)) stay + # in one segment. + names = [] + depth = 0 + current: list[str] = [] + for char in search_text: + if char == "(": + depth += 1 + elif char == ")": + depth = max(0, depth - 1) + if char == "," and depth == 0: + found = _match_segment("".join(current)) + if found is not None: + names.append(found) + current = [] else: - qualified_table = table_name - statement = f"CREATE OR REPLACE TABLE {qualified_table} {rest}" - else: - if "CREATE TABLE" in statement and "OR REPLACE" not in statement.upper(): - statement = statement.replace("CREATE TABLE", "CREATE OR REPLACE TABLE", 1) + current.append(char) + found = _match_segment("".join(current)) + if found is not None: + names.append(found) + return names + + @staticmethod + def _normalize_bq_column_types(text: str) -> str: + """Map DECIMAL/NUMERIC exceeding scale 9 to BIGNUMERIC, and VARCHAR to STRING. + + BigQuery NUMERIC caps scale at 9 (precision 38). Decimal columns + exceeding that (e.g. DECIMAL(18,14) latitude/longitude) must use + BIGNUMERIC (precision 76.76, scale 38); DECIMAL is only an alias for + NUMERIC and inherits its limits. BigQuery also has no VARCHAR + type: benchmark staging DDL spells text columns VARCHAR(n) + (valid on Snowflake/Postgres/DuckDB); map to STRING. + """ + import re - # BigQuery NUMERIC caps scale at 9 (precision 38). Decimal columns - # exceeding that (e.g. DECIMAL(18,14) latitude/longitude) must use - # BIGNUMERIC (precision 76.76, scale 38); DECIMAL is only an alias for - # NUMERIC and inherits its limits. def _decimal_to_bignumeric(match: re.Match[str]) -> str: precision, scale = int(match.group(2)), int(match.group(3)) if scale > 9 or precision > 38: return f"BIGNUMERIC({precision},{scale})" return match.group(0) - statement = re.sub( + text = re.sub( r"\b(DECIMAL|NUMERIC)\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)", _decimal_to_bignumeric, - statement, + text, flags=re.IGNORECASE, ) + return re.sub( + r"\bVARCHAR\s*(\(\s*\d+\s*\))?", + "STRING", + text, + flags=re.IGNORECASE, + ) + + def _qualify_table_target(self, raw_target: str) -> str: + """Dataset-qualify a table target identifier and uppercase the table segment.""" + target_bare = raw_target.strip("`") + if "." in target_bare: + *qualifier, bare = target_bare.split(".") + normalized = ".".join([*qualifier, bare.upper()]) + else: + normalized = target_bare.upper() + + if f"{self.dataset_id}." not in normalized: + return f"`{self.project_id}.{self.dataset_id}.{normalized}`" + elif not normalized.startswith("`"): + *qualifier, bare = normalized.split(".") + return "`" + ".".join([*qualifier, bare.upper()]) + "`" + return normalized + + def _qualify_ctas_target(self, work: str) -> str: + """Dataset-qualify a CTAS target while preserving create semantics. + + CTAS has no parenthesized column list, so the main CREATE branch + never fires for it: qualify here instead, since the later + table-name pass only rewrites FROM/JOIN/DML positions and BigQuery + rejects unqualified targets. Preserves IF NOT EXISTS when present; + bare CREATE TABLE converts to CREATE OR REPLACE TABLE for idempotency. + """ + import re + + ctas = re.match( + r"^(\s*CREATE\s+(.+?)\s+)(`?[A-Za-z0-9_.]+`?)(\s+AS\b.*)", + work, + flags=re.IGNORECASE | re.DOTALL, + ) + if not ctas: + return work + + raw_prefix = ctas.group(1) + modifiers = ctas.group(2).upper() + raw_target = ctas.group(3) + as_and_rest = ctas.group(4) + + # The pattern above matches any CREATE AS shape, + # including CREATE VIEW / CREATE TEMP VIEW / CREATE MATERIALIZED + # VIEW (the ddl_create_view_simple operation emits plain CREATE + # VIEW). Only table creators may be rewritten: rewriting a view + # into CREATE OR REPLACE TABLE would materialize a physical table, + # breaking information_schema.views validation and DROP VIEW + # cleanup. Table modifiers are empty, OR REPLACE, and IF NOT + # EXISTS (in any combination); anything mentioning VIEW (VIEW, + # TEMP VIEW, TEMPORARY VIEW, MATERIALIZED VIEW) passes through. + # TEMP and TEMPORARY tables also pass through: BigQuery scopes them + # to the session, so dataset-qualifying the target or dropping the + # TEMP keyword would convert them into permanent dataset tables. + # A plain CREATE VIEW keeps its shape but still needs its target + # qualified: the query-time connection carries no default dataset. + if "VIEW" in modifiers or "TEMP" in modifiers or "TEMPORARY" in modifiers: + if re.fullmatch( + r"(?:VIEW|OR\s+REPLACE\s+VIEW|MATERIALIZED\s+VIEW|OR\s+REPLACE\s+MATERIALIZED\s+VIEW)", + modifiers.strip(), + flags=re.IGNORECASE, + ): + qualified_target = self._qualify_table_target(raw_target) + return raw_prefix + qualified_target + as_and_rest + return work + + leading_space = raw_prefix[: len(raw_prefix) - len(raw_prefix.lstrip())] + if "IF NOT EXISTS" in modifiers: + verb = f"{leading_space}CREATE TABLE IF NOT EXISTS " + else: + verb = f"{leading_space}CREATE OR REPLACE TABLE " + + qualified_target = self._qualify_table_target(raw_target) + return verb + qualified_target + as_and_rest + + def _convert_to_bigquery_table(self, statement: str) -> str: + """Convert CREATE TABLE statement to BigQuery format. + + Preserves CREATE TABLE IF NOT EXISTS semantics when specified (preventing + destruction of reused staging tables or shared provenance manifests). + Converts bare CREATE TABLE to CREATE OR REPLACE TABLE for idempotency. + Table names are normalized to UPPERCASE to match the adapter-wide + convention used by loads, validation, row counts, and query + normalization (TPC-DS DDL sources use lowercase names). + """ + import re + + from benchbox.platforms.cloud_shared import split_leading_sql_comments + + # Schema chunks can carry decorative "--" header lines before the real + # CREATE (naive ";" splitting preserves them). Gate and match on the + # remainder so those chunks still get dataset-qualified; the prefix is + # re-attached unchanged at the end. + prefix, work = split_leading_sql_comments(statement) + + if not work.strip().upper().startswith("CREATE"): + return statement + + pattern = re.compile( + r"^\s*CREATE\s+(?:(OR\s+REPLACE)\s+)?TABLE\s+(?:(IF\s+NOT\s+EXISTS)\s+)?`?([a-zA-Z0-9_.]+)`?\s*(\(.*)", + re.IGNORECASE | re.DOTALL, + ) + match = pattern.match(work) + if match: + has_if_not_exists = bool(match.group(2)) + create_verb = "CREATE TABLE IF NOT EXISTS" if has_if_not_exists else "CREATE OR REPLACE TABLE" + qualified_table = self._qualify_table_target(match.group(3)) + rest = self._normalize_bq_column_types(match.group(4)) + work = f"{create_verb} {qualified_table} {rest}" + else: + ctas_work = self._qualify_ctas_target(work) + if ctas_work != work: + work = ctas_work + else: + if ( + re.search(r"\bCREATE\s+TABLE\b", work, flags=re.IGNORECASE) + and not re.search(r"\bOR\s+REPLACE\b", work, flags=re.IGNORECASE) + and not re.search(r"\bIF\s+NOT\s+EXISTS\b", work, flags=re.IGNORECASE) + ): + work = re.sub( + r"\bCREATE\s+TABLE\b", + "CREATE OR REPLACE TABLE", + work, + count=1, + flags=re.IGNORECASE, + ) + work = self._normalize_bq_column_types(work) # BigQuery rejects enforced PRIMARY KEY; it only supports informational # NOT ENFORCED table constraints. Convert inline column PRIMARY KEYs # (e.g. JoinOrder's `id INTEGER PRIMARY KEY`) to a table constraint. - _pk_keywords = {"PRIMARY", "CONSTRAINT", "FOREIGN", "UNIQUE", "CHECK", "KEY"} - pk_cols = [ - name - for name in re.findall( - r"^\s*[`\"']?(\w+)[`\"']?\s+(?:[A-Z0-9_]+\s*(?:\([^()]*\))?\s*)+?" - r"PRIMARY\s+KEY(?!\s*\()", - rest if match else statement, - flags=re.IGNORECASE | re.MULTILINE, - ) - if name.upper() not in _pk_keywords - ] + pk_cols = self._inline_pk_columns(rest if match else work) if match: rest = re.sub(r"\s+PRIMARY\s+KEY(?!\s*\()\b", "", rest, flags=re.IGNORECASE) has_table_pk = re.search(r"PRIMARY\s+KEY\s*\(", rest, flags=re.IGNORECASE) is not None if pk_cols and not has_table_pk: + # Scan masked text so a parenthesis inside a string default + # (DEFAULT "done)") or a comment (/* note ) */) cannot end + # the column list early and inject the constraint inside it. + scan = re.sub( + r"'(?:[^'\\]|\\.|'')*'|\"(?:[^\"\\]|\\.|\"\")*\"|--[^\n]*|/\*.*?\*/", + lambda match: " " * len(match.group(0)), + rest, + flags=re.DOTALL, + ) depth = 0 - for i, ch in enumerate(rest): + for i, ch in enumerate(scan): if ch == "(": depth += 1 elif ch == ")": @@ -1896,29 +2145,68 @@ def _decimal_to_bignumeric(match: re.Match[str]) -> str: if depth == 0: rest = rest[:i] + f", PRIMARY KEY ({', '.join(pk_cols)}) NOT ENFORCED" + rest[i:] break - statement = f"CREATE OR REPLACE TABLE {qualified_table} {rest}" + work = f"{create_verb} {qualified_table} {rest}" else: - statement = re.sub(r"\s+PRIMARY\s+KEY(?!\s*\()\b", "", statement, flags=re.IGNORECASE) + work = re.sub(r"\s+PRIMARY\s+KEY(?!\s*\()\b", "", work, flags=re.IGNORECASE) # BigQuery rejects enforced PRIMARY KEY table constraints too (e.g. # nyctaxi's `PRIMARY KEY (cols)`). Mark surviving table-level keys # NOT ENFORCED; already-marked constraints are left untouched. - statement = re.sub( + work = re.sub( r"PRIMARY\s+KEY\s*\(([^()]*)\)(?!\s*NOT\s+ENFORCED)", r"PRIMARY KEY (\1) NOT ENFORCED", - statement, + work, flags=re.IGNORECASE, ) + # Foreign keys follow the same rule, and BigQuery also requires the + # referenced table to be dataset-qualified ("Table ... must be + # qualified with a dataset"). Data Vault links reference their hubs. + # The match runs against a literal/comment-masked copy (the mask is + # length-preserving, so spans align) and splices replacements into the + # original: an unmasked regex would also rewrite REFERENCES text inside + # string defaults such as DEFAULT 'REFERENCES parent(id)', silently + # changing stored values. + references_pattern = re.compile( + r"REFERENCES\s+(`?[a-zA-Z0-9_.]+`?)\s*\(([^()]*)\)(?!\s*NOT\s+ENFORCED)", + flags=re.IGNORECASE, + ) + masked_work = re.sub( + r"'(?:[^'\\]|\\.|'')*'|\"(?:[^\"\\]|\\.|\"\")*\"|--[^\n]*|/\*.*?\*/", + lambda match: " " * len(match.group(0)), + work, + flags=re.DOTALL, + ) + pieces: list[str] = [] + cursor = 0 + for match in references_pattern.finditer(masked_work): + # Groups are sliced from the ORIGINAL statement at the masked + # match spans: group text itself may cover a masked region (e.g. a + # string literal inside the column list), and the masked copy holds + # blanks there. + original_match = work[match.start() : match.end()] + inner = re.match( + r"REFERENCES\s+(`?[a-zA-Z0-9_.]+`?)\s*\(([^()]*)\)(?!\s*NOT\s+ENFORCED)", + original_match, + flags=re.IGNORECASE, + ) + if inner is None: # Mask boundary artifact; leave untouched. + continue + pieces.append(work[cursor : match.start()]) + pieces.append(f"REFERENCES {self._qualify_table_target(inner.group(1))} ({inner.group(2)}) NOT ENFORCED") + cursor = match.end() + pieces.append(work[cursor:]) + work = "".join(pieces) + # Include partitioning and clustering if configured - if "PARTITION BY" not in statement.upper() and self.partitioning_field: - statement += f" PARTITION BY DATE({self.partitioning_field})" + if "PARTITION BY" not in work.upper() and self.partitioning_field: + work += f" PARTITION BY DATE({self.partitioning_field})" - if "CLUSTER BY" not in statement.upper() and self.clustering_fields: + if "CLUSTER BY" not in work.upper() and self.clustering_fields: clustering = ", ".join(self.clustering_fields) - statement += f" CLUSTER BY {clustering}" + work += f" CLUSTER BY {clustering}" - return statement + return prefix + work # Fallback table list when query parsing is unavailable. Covers TPC-H; # parser-extracted names handle every other benchmark. @@ -1933,6 +2221,45 @@ def _decimal_to_bignumeric(match: re.Match[str]) -> str: "LINEITEM", ) + # Clause keywords that can follow a table occurrence: their presence + # means the occurrence carries no alias (see _qualify_has_alias). + _QUALIFY_CLAUSE_KEYWORDS = frozenset( + { + "AS", + "AND", + "OR", + "ON", + "USING", + "WHERE", + "GROUP", + "ORDER", + "HAVING", + "QUALIFY", + "WINDOW", + "LIMIT", + "OFFSET", + "JOIN", + "INNER", + "LEFT", + "RIGHT", + "FULL", + "CROSS", + "NATURAL", + "SET", + "WHEN", + "THEN", + "ELSE", + "END", + "UNION", + "INTERSECT", + "EXCEPT", + "SELECT", + "FROM", + "VALUES", + "TABLE", + } + ) + def _extract_unqualified_tables(self, query: str) -> list[str] | None: """Return UPPERCASE names of unqualified tables referenced by the query. @@ -1946,72 +2273,313 @@ def _extract_unqualified_tables(self, query: str) -> list[str] | None: except ImportError: return None try: - tree = sqlglot.parse_one(query) + # Parse as BigQuery: the default dialect rejects backtick-quoted + # identifiers, which sqlglot translations and benchmark setup + # probes use throughout. + trees = sqlglot.parse(query, read="bigquery") except Exception: return None - if tree is None: + if not trees or any(tree is None for tree in trees): return None - cte_names = {cte.alias_or_name.upper() for cte in tree.find_all(exp.CTE)} + cte_names = set() + for tree in trees: + for cte in tree.find_all(exp.CTE): + name = (cte.alias_or_name or "").upper() + if name: + cte_names.add(name) tables: list[str] = [] - for table in tree.find_all(exp.Table): - if table.db or table.catalog: - continue - name = (table.name or "").upper() - if not name or name in cte_names or name in tables: - continue - tables.append(name) + for tree in trees: + # A CTE name shadows later references to that name, but a table + # with the same spelling inside the CTE's own body is still the + # base table. Walk each CTE body first so shadowed base tables + # are collected, then skip only the shadowing outer references. + # A body may also reference a sibling CTE defined earlier in the + # same WITH clause; that name is a CTE, not a base table. + for cte in tree.find_all(exp.CTE): + earlier_ctes = set() + with_clause = cte.parent + if isinstance(with_clause, exp.With): + for sibling in with_clause.expressions: + if sibling is cte: + break + earlier_ctes.add((sibling.alias_or_name or "").upper()) + for table in cte.this.find_all(exp.Table): + if table.db or table.catalog: + continue + name = (table.name or "").upper() + if name and name not in tables and name not in earlier_ctes: + tables.append(name) + for table in tree.find_all(exp.Table): + if table.db or table.catalog: + continue + name = (table.name or "").upper() + if not name or name in cte_names or name in tables: + continue + tables.append(name) return tables - def _qualify_table_names(self, query: str) -> str: - """Add full qualification to table names in query. - - Note: Only used for non-translated queries (e.g., raw TPC-H queries without sqlglot). - Queries processed by sqlglot with identify=True should skip this method to avoid - conflicts with backtick-quoted identifiers. When default_dataset is configured, - BigQuery automatically resolves unqualified table names. - - Table names are extracted with a SQL parser so benchmarks beyond TPC-H - resolve; BigQuery table identifiers are case-sensitive, so every name - is normalized to UPPERCASE to match created tables. Falls back to the - static TPC-H list when parsing is unavailable. - - Only occurrences in table position (after FROM / JOIN or a - comma-separated FROM item) are rewritten: string literals and comments - are masked first, so same-named columns, aliases, and literal text are - left alone. - """ + def _qualify_single_statement(self, statement: str, allow_fallback: bool = True) -> str: + """Add full qualification to table names in a single SQL statement.""" import re - table_names = self._extract_unqualified_tables(query) + table_names = self._extract_unqualified_tables(statement) if table_names is None: + if not allow_fallback: + return statement table_names = list(self._FALLBACK_QUALIFY_TABLES) + if self._batch_temp_tables: + table_names = [name for name in table_names if name not in self._batch_temp_tables] # Blank string literals and comments length-preservingly so matches - # found in the masked copy align with the original query. + # found in the masked copy align with the original statement. literal_pattern = r"'(?:[^'\\]|\\.|'')*'|\"(?:[^\"\\]|\\.|\"\")*\"|--[^\n]*|/\*.*?\*/" def _mask(text: str) -> str: return re.sub(literal_pattern, lambda match: " " * len(match.group(0)), text, flags=re.DOTALL) - masked = _mask(query) + masked = _mask(statement) for table_name in table_names: - # Replace unqualified table names + # Replace unqualified table names. The name alternative also + # matches a backtick-quoted occurrence (`ORDERS`): benchmark + # setup code quotes identifiers with backticks, and BigQuery + # resolves the query-time connection without a default dataset, + # so bare backtick names must be qualified here too. Only + # table-position names reach this loop (sqlglot exp.Table), so + # backticked column references are never rewritten. Qualified + # references (db.table) are excluded by the extractor, and the + # keyword prefix guard keeps them excluded here as well. + # A comma matches only inside the FROM clause (between FROM and + # the next clause keyword): elsewhere a comma-separated + # identifier is a projection column, function argument, or + # INSERT column, and qualifying it corrupts the statement. qualified_name = f"`{self.project_id}.{self.dataset_id}.{table_name}`" + # A qualified path creates no implicit range variable on + # BigQuery, so `name.column` references elsewhere in the statement + # would stop resolving once the table is qualified. When such + # references exist, the occurrence carries no alias, and the + # grammar permits aliases at this position, append one spelling + # the original name (proven live: every bare-prefix UPDATE/DELETE + # failed server-side with Unrecognized name). BigQuery syntax + # forbids aliases on target tables of INSERT, CREATE, DROP, + # TRUNCATE, and ALTER statements. Translated queries backtick + # identifiers, so `lineitem`.`l_partkey` counts as a reference too. + has_refs = ( + re.search(rf"(? str: + """Add full qualification to table names in query. + + Table names are extracted with a SQL parser so benchmarks beyond TPC-H + resolve; BigQuery table identifiers are case-sensitive, so every name + is normalized to UPPERCASE to match created tables. Falls back to the + static TPC-H list when parsing is unavailable (unless allow_fallback + is False, in which case an unparseable query is returned unchanged - + used for already-translated backtick queries where a static rewrite + could corrupt a statement the parser cannot see into). + + Qualification is performed per-statement so multi-statement batches + resolve tables referenced only in later statements. Only occurrences + in table position are rewritten: after FROM / JOIN, a comma-separated + FROM item, or a DML/DDL target keyword (INSERT INTO, UPDATE, + DELETE FROM via FROM, TRUNCATE TABLE, DROP TABLE, ALTER TABLE, + CREATE TABLE). String literals and comments are masked first, so + same-named columns, aliases, and literal text are left alone. + Synthesized AS aliases are restricted to grammar-permitted positions + (withheld from INSERT, CREATE, DROP, TRUNCATE, and ALTER targets). + """ + if not query or not query.strip(): + return query + + statements = split_sql_statements(query) + if not statements: + return query + + # Script-scoped temporary tables live outside the dataset, so their + # names must stay bare everywhere in the batch (verified live: a + # qualified temp-table name fails "Not found: Table ...TEMP_..."). + import re + + temp_tables = { + m.group(1).upper() + for stmt in statements + for m in re.finditer( + r"(?i)\bCREATE\s+(?:OR\s+REPLACE\s+)?TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?", + stmt, + ) + } + previous_temp_tables = self._batch_temp_tables + self._batch_temp_tables = temp_tables + try: + qualified_parts = [ + self._qualify_single_statement(stmt, allow_fallback=allow_fallback) for stmt in statements + ] + finally: + self._batch_temp_tables = previous_temp_tables + result = ";\n".join(qualified_parts) + if query.rstrip().endswith(";"): + result += ";" + if query.endswith("\n"): + result += "\n" + return result + + _FROM_SCAN_TOKEN = None + # Temp-table names created in the batch currently being qualified. + _batch_temp_tables: frozenset[str] | set[str] = frozenset() + + @staticmethod + def _from_scan_token(): + """Compiled token pattern for FROM-list scanning (built once).""" + import re + + if BigQueryAdapter._FROM_SCAN_TOKEN is None: + BigQueryAdapter._FROM_SCAN_TOKEN = re.compile( + r"(?P\()|(?P\))|(?P\bFROM\b|\bJOIN\b|\bWHERE\b|\bGROUP\s+BY\b" + r"|\bORDER\s+BY\b|\bHAVING\b|\bLIMIT\b|\bWINDOW\b|\bQUALIFY\b|\bOVER\b|\bON\b" + r"|\bUSING\b|\bINTO\b|\bSET\b|\bVALUES\b)|(?P,)|(?P;)", + flags=re.IGNORECASE, + ) + return BigQueryAdapter._FROM_SCAN_TOKEN + + @staticmethod + def _from_scan_keyword(stack: list[list], depth: int, first: str) -> None: + """Fold one clause keyword into the FROM-list stack (in place).""" + if first == "FROM": + stack.append([depth, False]) + elif first == "JOIN": + # A JOIN target continues the enclosing list; a JOIN + # inside a predicate's parens starts nothing. + if stack and stack[-1][0] == depth: + stack[-1][1] = False + elif first in ("WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "WINDOW", "QUALIFY", "OVER"): + while stack and stack[-1][0] >= depth: + stack.pop() + elif first == "ON": + if stack and stack[-1][0] == depth: + stack[-1][1] = True + elif first == "USING": + # JOIN ... USING (cols) is a predicate; DELETE ... USING + # opens a table list when no list is open at this depth. + if stack and stack[-1][0] == depth: + stack[-1][1] = True + else: + stack.append([depth, False]) + elif first in ("INTO", "SET", "VALUES"): + while stack and stack[-1][0] >= depth: + stack.pop() + + @staticmethod + def _in_from_clause(masked: str, pos: int) -> bool: + """Whether a comma at a position separates tables in a FROM list. + + Scans from the statement start tracking parenthesis depth, FROM + lists, and JOIN predicates: a comma counts only at the depth of an + open FROM list while no JOIN predicate is open. A new FROM, JOIN, + or comma-separated table reopens the list; WHERE/GROUP/ORDER and + friends close it; ON opens a predicate that commas cannot belong + to; the predicate closes at WHERE or at a comma followed by a new + table at the list depth. Keywords inside deeper parens (a + subquery's own FROM/ON) never touch the outer list. A comma that + is itself outside any FROM list (a projection comma, a function + argument, an INSERT column) is skipped rather than ending the + scan, so a later comma-separated FROM item still qualifies. + """ + import re + + depth = 0 + # Each entry: [depth, predicate_open] for a FROM list still open. + stack: list[list] = [] + token = BigQueryAdapter._from_scan_token() + for match in token.finditer(masked[:pos]): + kind = match.lastgroup + word = (match.group("kw") or "").upper().split() + first = word[0] if word else "" + if kind == "lparen": + depth += 1 + elif kind == "rparen": + depth = max(0, depth - 1) + while stack and stack[-1][0] > depth: + stack.pop() + elif kind == "semi": + stack.clear() + elif kind == "kw": + BigQueryAdapter._from_scan_keyword(stack, depth, first) + elif kind == "comma": + if stack and stack[-1][0] == depth and not stack[-1][1]: + return True + # A comma after a completed predicate (ON ...
) ends + # the predicate when a query-valued tail follows: treat it + # as a separator and close the predicate. + if stack and stack[-1][0] == depth and stack[-1][1]: + tail = masked[match.end() :] + if re.match(r"\s*[A-Za-z_][\w$]*", tail): + stack[-1][1] = False + return True + # Otherwise the comma is outside any open FROM list (a + # projection comma, a function argument, an INSERT column, + # or a comma inside a JOIN predicate): skip it and keep + # scanning so a later FROM-list comma can still match. + continue + return False + + @staticmethod + def _qualify_has_alias(after: str) -> bool: + """Whether a rewritten table occurrence already carries an alias. + + Only explicit ``AS alias`` and bare-identifier aliases count; a + following clause keyword, punctuation, or end of input means the + occurrence is unaliased. Checked against the literal-masked query. + Backtick-quoted aliases (which sqlglot emits with ``identify=True``) + count, and over-long inline comments between the table and its alias + are skipped rather than truncating the scan. + """ + import re - return query + # Skip block comments of any length before looking for the alias. + scan = re.sub(r"/\*.*?\*/", " ", after, flags=re.DOTALL) + as_match = re.match(r"\s+AS\s+(?:`([^`]+)`|([A-Za-z_]\w*))", scan, flags=re.IGNORECASE) + if as_match: + if as_match.group(1) is not None: + # A quoted alias is always an identifier, even when its + # spelling matches a clause keyword (`order`, `where`). + return True + return as_match.group(2).upper() not in BigQueryAdapter._QUALIFY_CLAUSE_KEYWORDS + bare_match = re.match(r"\s+(?:`([^`]+)`|([A-Za-z_]\w*))", scan) + if bare_match: + if bare_match.group(1) is not None: + return True + return bare_match.group(2).upper() not in BigQueryAdapter._QUALIFY_CLAUSE_KEYWORDS + return False def _normalize_table_names_case(self, query: str) -> str: """Normalize backtick-quoted table names to UPPERCASE for case-sensitive matching. diff --git a/benchbox/platforms/citus.py b/benchbox/platforms/citus.py new file mode 100644 index 0000000000..be396107f1 --- /dev/null +++ b/benchbox/platforms/citus.py @@ -0,0 +1,325 @@ +"""Citus platform adapter for BenchBox benchmarking. + +Extends PostgreSQL adapter with Citus-specific functionality: +- citus extension verification (distributed PostgreSQL tables) +- Opt-in table distribution via create_distributed_table() + +Citus is a PostgreSQL extension that transparently distributes tables +across a coordinator and worker nodes. Without distribution, benchmark +tables stay coordinator-local and the run measures single-node Postgres; +pass --platform-option distribution_column=to distribute every +benchmark table on that column after schema creation. Per-table control +is future work. + +Copyright 2026 Joe Harris / BenchBox Project + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from .base.config_utils import ( + POSTGRES_FAMILY_BASE_OPTIONS, + POSTGRES_FAMILY_PLATFORM_FIELDS, + make_platform_config_builder, +) +from .postgresql import ( + POSTGRES_DIALECT, + PostgreSQLAdapter, + _build_postgres_connection_kwargs, + ensure_postgres_extension, +) + +logger = logging.getLogger(__name__) + +try: + import psycopg +except ImportError: + psycopg = None + +CITUS_EXTENSION = "citus" + + +class CitusAdapter(PostgreSQLAdapter): + """Citus platform adapter with distributed-table support. + + Extends PostgreSQLAdapter with Citus-specific features: + - citus extension presence check with create-or-install guidance + - Opt-in distribution of benchmark tables via create_distributed_table + - Version reporting in platform info for result provenance + + Requires PostgreSQL 14+ with the citus extension installed on the + coordinator (and workers for real distribution). + """ + + plan_capture_phase_eligible = True + + @property + def platform_name(self) -> str: + return "citus" + + def get_target_dialect(self) -> str: + """Return the target SQL dialect for Citus (PostgreSQL-compatible).""" + return POSTGRES_DIALECT + + @staticmethod + def add_cli_arguments(parser) -> None: + """Add Citus-specific CLI arguments.""" + if not hasattr(parser, "add_argument"): + return + try: + # Inherit PostgreSQL connection arguments + parser.add_argument( + "--citus-host", + dest="host", + default="localhost", + help="Citus coordinator hostname (with citus extension installed)", + ) + parser.add_argument( + "--citus-port", + dest="port", + type=int, + default=5432, + help="Citus coordinator port", + ) + parser.add_argument( + "--citus-database", + dest="database", + help="PostgreSQL database name (auto-generated if not specified)", + ) + parser.add_argument( + "--citus-username", + dest="username", + default="postgres", + help="PostgreSQL username", + ) + parser.add_argument( + "--citus-password", + dest="password", + help="PostgreSQL password", + ) + parser.add_argument( + "--citus-schema", + dest="schema", + default="public", + help="PostgreSQL schema name", + ) + # Citus-specific options + parser.add_argument( + "--citus-distribution-column", + dest="distribution_column", + default=None, + help="Distribute every benchmark table on this column " + "(e.g. l_orderkey for TPC-H). Unset keeps coordinator-local tables.", + ) + except Exception: + pass + + @classmethod + def from_config(cls, config: dict[str, Any]) -> CitusAdapter: + """Create Citus adapter from unified configuration.""" + adapter_config = _build_postgres_connection_kwargs(config) + adapter_config["distribution_column"] = config.get("distribution_column") + return cls(**adapter_config) + + def __init__(self, **config): + super().__init__(**config) + self.distribution_column: str | None = config.get("distribution_column") + + def create_connection(self, **connection_config) -> Any: + """Create PostgreSQL connection and verify the citus extension.""" + conn = super().create_connection(**connection_config) + ensure_postgres_extension( + conn, self.logger, CITUS_EXTENSION, "https://github.com/citusdata/citus", cascade=True + ) + return conn + + def _apply_stream_session_state(self, connection: Any) -> None: + """Delegate per-stream session state to the PostgreSQL parent path. + + ``create_connection`` adds only extension verification/creation above + the parent implementation - one-time database setup that must NOT be + repeated per stream - and no additional session-scoped GUCs, so there + is no Citus-specific state to reapply. Table distribution happens in + ``create_schema`` (DDL scope), not per session. The explicit + delegation (rather than an inherited silent no-op) records that + equivalence was checked for this subclass. + """ + super()._apply_stream_session_state(connection) + + def create_schema(self, benchmark, connection: Any) -> float: + """Create schema, then distribute tables when a column is configured.""" + elapsed = super().create_schema(benchmark, connection) + if self.distribution_column: + self._distribute_benchmark_tables(benchmark, connection) + return elapsed + + def _benchmark_table_names(self, benchmark: Any) -> list[str]: + """Return benchmark table names in dependency order when available.""" + get_tables = getattr(benchmark, "_get_active_tables", None) + if callable(get_tables): + try: + names = get_tables() + if names: + return [str(name) for name in names] + except Exception: + pass + schema_getter = getattr(benchmark, "get_schema", None) + if callable(schema_getter): + try: + schema = schema_getter() + if isinstance(schema, dict): + return list(schema.keys()) + except Exception: + pass + tables = getattr(benchmark, "tables", None) + if isinstance(tables, dict): + return list(tables.keys()) + return [] + + def _validated_distribution_column(self) -> str: + """Return the configured distribution column after identifier validation.""" + column = self.distribution_column + assert column is not None + if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", column): + raise ValueError(f"Invalid Citus distribution column {column!r}: must be a plain SQL identifier.") + return column + + @staticmethod + def _table_has_column(cursor: Any, table_name: str, column: str) -> bool: + """Check column presence explicitly instead of inferring it from a failed DDL.""" + cursor.execute( + "SELECT 1 FROM information_schema.columns " + "WHERE table_schema = current_schema() AND table_name = %s AND column_name = %s", + (table_name, column), + ) + return cursor.fetchone() is not None + + @staticmethod + def _distributed_column(cursor: Any, table_name: str) -> str | None: + """Return the column a table is distributed on, or None when undistributed.""" + cursor.execute("SELECT partkey FROM pg_dist_partition WHERE logicalrelid = %s::regclass", (table_name,)) + row = cursor.fetchone() + if not row: + return None + return row[0] or None + + def _distribute_single_table(self, cursor: Any, connection: Any, table_name: str, column: str) -> None: + """Distribute one table and commit immediately. + + Each successful distribution commits on its own so a later skipped + or failed table can never roll back an earlier success back to + coordinator-local. + """ + quoted_table = '"' + table_name.replace('"', '""') + '"' + cursor.execute(f"SELECT create_distributed_table('{quoted_table}', '{column}')") + connection.commit() + self.logger.info(f"Distributed Citus table {table_name} on column {column}") + + def _distribute_benchmark_tables(self, benchmark: Any, connection: Any) -> None: + """Distribute every benchmark table on the configured column. + + Runs after the inherited schema creation so COPY loading fans out to + workers. Tables lacking the column are left coordinator-local with a + warning rather than failing the run: dimension tables rarely carry + the fact-table distribution key. Operational distribution failures + propagate so the run aborts instead of benchmarking an unintended + coordinator-local topology. + """ + column = self._validated_distribution_column() + cursor = connection.cursor() + try: + for table_name in self._benchmark_table_names(benchmark): + if not self._table_has_column(cursor, table_name, column): + self.logger.warning( + f"Table {table_name} lacks distribution column {column}; left coordinator-local" + ) + continue + self._distribute_single_table(cursor, connection, table_name, column) + finally: + cursor.close() + + def _ensure_distribution_on_reused_database(self, benchmark: Any, connection: Any) -> None: + """Verify or apply the requested distribution on a reused database. + + The reuse lifecycle skips schema creation, so distribution requested + for this run would otherwise silently not happen. Tables already + distributed on the requested column are verified and kept; tables + distributed on a different column reject the run (benchmarking them + would measure a different topology than requested); undistributed + tables carrying the column are distributed now. + """ + column = self._validated_distribution_column() + cursor = connection.cursor() + try: + for table_name in self._benchmark_table_names(benchmark): + existing = self._distributed_column(cursor, table_name) + if existing == column: + self.logger.info(f"Citus table {table_name} already distributed on column {column}") + continue + if existing is not None: + raise RuntimeError( + f"Citus table {table_name} is already distributed on {existing!r}, " + f"but distribution_column={column!r} was requested; recreate the database " + "or rerun with the matching column." + ) + if not self._table_has_column(cursor, table_name, column): + self.logger.warning( + f"Table {table_name} lacks distribution column {column}; left coordinator-local" + ) + continue + self._distribute_single_table(cursor, connection, table_name, column) + finally: + cursor.close() + + def _setup_reused_database_phases(self, benchmark: Any, connection: Any) -> tuple: + """Run the reuse phases, then verify or apply the requested distribution.""" + phases = super()._setup_reused_database_phases(benchmark, connection) + if self.distribution_column: + self._ensure_distribution_on_reused_database(benchmark, connection) + return phases + + def get_platform_info(self, connection: Any = None) -> dict[str, Any]: + """Get Citus platform information.""" + platform_info = super().get_platform_info(connection) + + # Override platform type and name + platform_info["platform_type"] = "citus" + platform_info["platform_name"] = "citus" + + # Add Citus-specific configuration + platform_info["configuration"]["distribution_column"] = self.distribution_column + + if connection: + try: + cursor = connection.cursor() + + # Get citus version + cursor.execute(f"SELECT extversion FROM pg_extension WHERE extname = '{CITUS_EXTENSION}'") + result = cursor.fetchone() + if result: + platform_info["citus_version"] = result[0] + + cursor.close() + except Exception as e: + self.logger.debug(f"Error getting citus info: {e}") + + return platform_info + + _supported_tuning_type_names = ("PARTITIONING", "CLUSTERING", "PRIMARY_KEYS", "FOREIGN_KEYS") + + +_build_citus_config = make_platform_config_builder( + "citus", + __name__, + "citus", + "psycopg", + POSTGRES_FAMILY_PLATFORM_FIELDS + ("distribution_column",), + base_options={ + **POSTGRES_FAMILY_BASE_OPTIONS, + }, +) diff --git a/benchbox/platforms/clickhouse/_dependencies.py b/benchbox/platforms/clickhouse/_dependencies.py index cf652e5e26..32cbecc413 100644 --- a/benchbox/platforms/clickhouse/_dependencies.py +++ b/benchbox/platforms/clickhouse/_dependencies.py @@ -1,5 +1,11 @@ """Optional dependencies for ClickHouse platform support.""" +from __future__ import annotations + +import importlib +import os +from types import ModuleType + try: from clickhouse_driver import Client as ClickHouseClient from clickhouse_driver.errors import Error as ClickHouseError @@ -12,9 +18,36 @@ except ImportError: # pragma: no cover - optional dependency clickhouse_connect = None -try: - import chdb -except ImportError: # pragma: no cover - optional dependency - chdb = None +chdb: ModuleType | None = None + + +def _import_with_cwd_restore(module_name: str) -> ModuleType: + """Import an optional module without leaking a temporary working directory.""" + original_cwd = os.getcwd() + try: + return importlib.import_module(module_name) + finally: + os.chdir(original_cwd) + + +def import_chdb() -> ModuleType: + """Import chDB and restore the caller's cwd even when native loading fails.""" + global chdb + chdb = _import_with_cwd_restore("chdb") + return chdb + + +def import_chdb_session() -> ModuleType: + """Import chDB's session module through the cwd-safe package import.""" + import_chdb() + return _import_with_cwd_restore("chdb.session") + -__all__ = ["ClickHouseClient", "ClickHouseError", "clickhouse_connect", "chdb"] +__all__ = [ + "ClickHouseClient", + "ClickHouseError", + "clickhouse_connect", + "chdb", + "import_chdb", + "import_chdb_session", +] diff --git a/benchbox/platforms/clickhouse/client.py b/benchbox/platforms/clickhouse/client.py index d467f16370..0d82059943 100644 --- a/benchbox/platforms/clickhouse/client.py +++ b/benchbox/platforms/clickhouse/client.py @@ -86,16 +86,15 @@ class ClickHouseLocalClient: def __init__(self, db_path: str | None = None): """Initialize local client with optional persistent storage path.""" self._initialized = True + from ._dependencies import import_chdb, import_chdb_session + # Use persistent session if path provided, otherwise use in-memory connection if db_path: - from chdb.session import Session - + Session = import_chdb_session().Session self._conn = Session(path=db_path) self._is_persistent = True else: - import chdb - - self._conn = chdb.connect() + self._conn = import_chdb().connect() self._is_persistent = False def execute(self, query: str, params=None): @@ -137,7 +136,9 @@ def _arrow_to_dataframe(result): TODO(chdb-tuple-dataframe): once chDB's `format="DataFrame"` stops SIGABRTing on Tuple-typed columns, the explicit ArrowStream + pyarrow decode here can be reverted to a direct DataFrame fetch. File / track - this in the chdb upstream issue tracker. + this in the chdb upstream issue tracker (https://github.com/chdb-io/chdb), + then link the issue URL here. Indexed with removal criteria in + docs/platforms/workaround-index.md. """ import pandas as pd diff --git a/benchbox/platforms/clickhouse/metadata.py b/benchbox/platforms/clickhouse/metadata.py index 7cb4dbcc6c..4cc4ab3a30 100644 --- a/benchbox/platforms/clickhouse/metadata.py +++ b/benchbox/platforms/clickhouse/metadata.py @@ -4,6 +4,7 @@ from typing import Any +from ._dependencies import import_chdb from .deployment_mode import resolve_clickhouse_deployment_mode @@ -175,9 +176,7 @@ def _apply_clickhouse_mode_configuration(self, platform_info: dict[str, Any]) -> def _detect_clickhouse_client_version(self) -> str | None: try: if self.deployment_mode == "local": - import chdb - - return getattr(chdb, "__version__", None) + return getattr(import_chdb(), "__version__", None) from clickhouse_driver import __version__ as ch_version return ch_version diff --git a/benchbox/platforms/clickhouse_cloud.py b/benchbox/platforms/clickhouse_cloud.py index 6a9481bfba..c538eab64e 100644 --- a/benchbox/platforms/clickhouse_cloud.py +++ b/benchbox/platforms/clickhouse_cloud.py @@ -30,6 +30,7 @@ import logging import os +import uuid from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING, Any @@ -51,6 +52,18 @@ def _compact_metadata(payload: Mapping[str, Any]) -> dict[str, Any]: return {str(key): value for key, value in payload.items() if value not in (None, "", {}, [], ())} +def _names_contain_glob_syntax(names: list[str]) -> bool: + """Return True when any staged name could change a glob's meaning. + + A ``*`` in a table or file name would widen the table-prefix glob beyond + its own files (or match nothing the uploader wrote); ``?[{`` have the + same effect on ClickHouse's glob matcher. Callers fall back to the + per-file loop for such tables so the glob path never silently loads the + wrong files. + """ + return any(char in name for name in names for char in ("*", "?", "{", "[")) + + class ClickHouseCloudAdapter(ClickHouseAdapter): """ClickHouse Cloud platform adapter - managed ClickHouse service. @@ -755,6 +768,54 @@ def _resolve_cloud_data_files(self, benchmark, data_dir: Path) -> dict[str, list data_source = resolve_adapter_data_source(self, benchmark, data_dir) return {table: [Path(p) for p in paths] for table, paths in data_source.tables.items()} + def _ingest_staged_glob( + self, + connection: Any, + kind: str, + sql_fn: str, + safe_key: str, + safe_secret: str, + table_name_lower: str, + valid_files: list[Path], + upload_fn: Any, + glob_url_fn: Any, + ) -> int: + """Upload every file, then ingest the table with one glob INSERT. + + Each staged file parses independently under ``CSVWithNames``, so one + header row per file is expected and correct: unlike byte-stream + concatenation, no header skipping is needed. Returns the post-ingest + row count for the table. + """ + for file_path in valid_files: + upload_start = mono_time() + self.log_verbose(f"Uploading {file_path.name} to {kind.upper()} staging") + upload_fn(file_path, table_name_lower, file_path.name) + upload_time = elapsed_seconds(upload_start) + self.log_verbose(f"Uploaded {file_path.name} in {upload_time:.2f}s") + + glob_url = glob_url_fn(table_name_lower) + ingest_sql = ( + f"INSERT INTO {table_name_lower} " + f"SELECT * FROM {sql_fn}(" + f"'{glob_url}', " + f"'{safe_key}', " + f"'{safe_secret}', " + f"'CSVWithNames'" + f")" + ) + + ingest_start = mono_time() + connection.execute(ingest_sql) + ingest_time = elapsed_seconds(ingest_start) + + count_result = connection.execute(f"SELECT COUNT(*) FROM {table_name_lower}") + total_rows_loaded = count_result[0][0] if count_result and count_result[0] else 0 + self.log_verbose( + f"Ingested {len(valid_files)} file(s) via {kind.upper()} glob in {ingest_time:.2f}s", + ) + return total_rows_loaded + def _load_data_via_object_storage( self, benchmark, @@ -766,6 +827,7 @@ def _load_data_via_object_storage( sql_fn: str, creds: tuple[str, str], upload_fn: Any, + glob_url_fn: Any | None = None, extra_metadata: dict[str, Any] | None = None, ) -> tuple[dict[str, int], float, dict[str, Any] | None]: """Shared core for S3 and GCS staging loads. @@ -777,6 +839,11 @@ def _load_data_via_object_storage( creds: (key, secret) pair, already SQL-quote-escaped. upload_fn: Callable ``(file_path, table_name_lower, file_name) -> sql_url``. Uploads the file and returns the URL to embed in the SQL statement. + glob_url_fn: Optional callable ``(table_name_lower) -> glob_url`` covering + every file staged under that table's prefix. When present + (and no staged name carries glob metacharacters), all of + the table's files upload first and a single INSERT reads + them through one glob instead of one INSERT per file. extra_metadata: Additional keys to merge into the returned loading_metadata dict. """ start_time = mono_time() @@ -805,30 +872,45 @@ def _load_data_via_object_storage( table_name_lower = table_name.lower() total_rows_loaded = 0 - for file_path in valid_files: - upload_start = mono_time() - self.log_verbose(f"Uploading {file_path.name} to {kind.upper()} staging") - sql_url = upload_fn(file_path, table_name_lower, file_path.name) - upload_time = elapsed_seconds(upload_start) - self.log_verbose(f"Uploaded {file_path.name} in {upload_time:.2f}s") - - ingest_sql = ( - f"INSERT INTO {table_name_lower} " - f"SELECT * FROM {sql_fn}(" - f"'{sql_url}', " - f"'{safe_key}', " - f"'{safe_secret}', " - f"'CSVWithNames'" - f")" + if glob_url_fn is not None and not _names_contain_glob_syntax( + [table_name_lower, *(file_path.name for file_path in valid_files)] + ): + total_rows_loaded = self._ingest_staged_glob( + connection, + kind, + sql_fn, + safe_key, + safe_secret, + table_name_lower, + valid_files, + upload_fn, + glob_url_fn, ) - - ingest_start = mono_time() - connection.execute(ingest_sql) - ingest_time = elapsed_seconds(ingest_start) - - count_result = connection.execute(f"SELECT COUNT(*) FROM {table_name_lower}") - total_rows_loaded = count_result[0][0] if count_result and count_result[0] else 0 - self.log_verbose(f"Ingested from {file_path.name} via {kind.upper()} in {ingest_time:.2f}s") + else: + for file_path in valid_files: + upload_start = mono_time() + self.log_verbose(f"Uploading {file_path.name} to {kind.upper()} staging") + sql_url = upload_fn(file_path, table_name_lower, file_path.name) + upload_time = elapsed_seconds(upload_start) + self.log_verbose(f"Uploaded {file_path.name} in {upload_time:.2f}s") + + ingest_sql = ( + f"INSERT INTO {table_name_lower} " + f"SELECT * FROM {sql_fn}(" + f"'{sql_url}', " + f"'{safe_key}', " + f"'{safe_secret}', " + f"'CSVWithNames'" + f")" + ) + + ingest_start = mono_time() + connection.execute(ingest_sql) + ingest_time = elapsed_seconds(ingest_start) + + count_result = connection.execute(f"SELECT COUNT(*) FROM {table_name_lower}") + total_rows_loaded = count_result[0][0] if count_result and count_result[0] else 0 + self.log_verbose(f"Ingested from {file_path.name} via {kind.upper()} in {ingest_time:.2f}s") table_stats[table_name_lower] = total_rows_loaded @@ -888,13 +970,21 @@ def _load_data_via_s3( s3_client_kwargs["region_name"] = self.s3_region s3_client = boto3.client("s3", **s3_client_kwargs) s3_bucket, s3_prefix = self._parse_s3_url(self.s3_staging_url) + # Isolate this load under a unique prefix so the bulk glob INSERT + # reads exactly the files uploaded by this invocation. Reusing the + # bare table prefix would also match stale objects from earlier runs + # (e.g. obsolete shards after rerunning with fewer files). + s3_load_token = uuid.uuid4().hex[:12] def upload_to_s3(file_path: Path, table_name_lower: str, file_name: str) -> str: - s3_key = f"{s3_prefix}{table_name_lower}/{file_name}" + s3_key = f"{s3_prefix}{table_name_lower}/{s3_load_token}/{file_name}" s3_url = f"s3://{s3_bucket}/{s3_key}" s3_client.upload_file(str(file_path), s3_bucket, s3_key) return s3_url + def s3_glob_url(table_name_lower: str) -> str: + return f"s3://{s3_bucket}/{s3_prefix}{table_name_lower}/{s3_load_token}/*" + return self._load_data_via_object_storage( benchmark, connection, @@ -904,6 +994,7 @@ def upload_to_s3(file_path: Path, table_name_lower: str, file_name: str) -> str: sql_fn="s3", creds=(aws_key_id.replace("'", "''"), aws_secret_key.replace("'", "''")), upload_fn=upload_to_s3, + glob_url_fn=s3_glob_url, extra_metadata={"s3_region": getattr(self, "s3_region", None)}, ) @@ -930,14 +1021,20 @@ def _load_data_via_gcs( gcs_client = gcs_storage.Client() gcs_bucket_name, gcs_prefix = self._parse_gcs_url(self.gcs_staging_url) gcs_bucket_obj = gcs_client.bucket(gcs_bucket_name) + # Same run isolation as the S3 path: the bulk glob INSERT must read + # only this invocation's files, never stale objects under the table prefix. + gcs_load_token = uuid.uuid4().hex[:12] def upload_to_gcs(file_path: Path, table_name_lower: str, file_name: str) -> str: - blob_name = f"{gcs_prefix}{table_name_lower}/{file_name}" + blob_name = f"{gcs_prefix}{table_name_lower}/{gcs_load_token}/{file_name}" blob = gcs_bucket_obj.blob(blob_name) blob.upload_from_filename(str(file_path)) # ClickHouse gcs() requires https:// URL, not gs:// return f"https://storage.googleapis.com/{gcs_bucket_name}/{blob_name}" + def gcs_glob_url(table_name_lower: str) -> str: + return f"https://storage.googleapis.com/{gcs_bucket_name}/{gcs_prefix}{table_name_lower}/{gcs_load_token}/*" + return self._load_data_via_object_storage( benchmark, connection, @@ -947,6 +1044,7 @@ def upload_to_gcs(file_path: Path, table_name_lower: str, file_name: str) -> str sql_fn="gcs", creds=(gcs_hmac_key.replace("'", "''"), gcs_hmac_secret.replace("'", "''")), upload_fn=upload_to_gcs, + glob_url_fn=gcs_glob_url, ) @staticmethod diff --git a/benchbox/platforms/cloud_shared.py b/benchbox/platforms/cloud_shared.py index 0916dcb157..ec7c28084e 100644 --- a/benchbox/platforms/cloud_shared.py +++ b/benchbox/platforms/cloud_shared.py @@ -252,6 +252,25 @@ def rewrite_tpcdi_for_bigquery(query: str) -> str: ) +_LEADING_SQL_COMMENTS_RE = re.compile(r"\A(?:\s|--[^\n]*(?:\n|\Z)|/\*.*?\*/)*", re.DOTALL) + + +def split_leading_sql_comments(statement: str) -> tuple[str, str]: + """Split leading whitespace/comments from a SQL statement chunk. + + Schema builders emit decorative header blocks (``--`` lines, or ``/* */`` + after dialect translation) that survive naive ``";"`` splitting, so a + chunk can start with comments before the real ``CREATE TABLE``. + Converters that gate on ``startswith("CREATE")`` miss those chunks + (BigQuery then sends an unqualified CREATE; Snowflake and Databricks + skip their idempotent ``OR REPLACE`` rewrite). Match against the + returned remainder instead, and re-attach the prefix unchanged. + """ + match = _LEADING_SQL_COMMENTS_RE.match(statement) + prefix = match.group(0) if match else "" + return prefix, statement[len(prefix) :] + + def rewrite_tpcdi_for_databricks(query: str) -> str: """TPC-DI SQLite idioms with Databricks' JULIANDAY rendering.""" return rewrite_tpcdi_sqlite_idioms( diff --git a/benchbox/platforms/databricks/adapter.py b/benchbox/platforms/databricks/adapter.py index 60142ed0a2..61048e4baa 100644 --- a/benchbox/platforms/databricks/adapter.py +++ b/benchbox/platforms/databricks/adapter.py @@ -345,10 +345,17 @@ def __init__(self, **config): force_upload_val = config.get("force_upload") self.force_upload = bool(force_upload_val if force_upload_val is not None else False) - # Result cache control - disable by default for accurate benchmarking - self.disable_result_cache = config.get("disable_result_cache", True) + # Result cache control - disable by default for accurate benchmarking. + # None-safe: the config builder inserts every platform field with None + # when no source provides it, so config.get(key, True) returns None + # (key present) and a bare truthiness gate would skip the disable. + disable_result_cache = config.get("disable_result_cache", True) + self.disable_result_cache = True if disable_result_cache is None else bool(disable_result_cache) self._liquid_clustering_operations: list[dict[str, Any]] = [] self._z_order_operations: list[dict[str, Any]] = [] + self._cache_disabled_sessions: Any = None + self._cache_disable_failed = False + self._cache_control_receipt: dict[str, Any] | None = None self._applied_layout_operations: list[dict[str, Any]] = [] self._skipped_layout_operations: list[dict[str, Any]] = [] @@ -376,6 +383,9 @@ def platform_name(self) -> str: def _reset_run_scoped_state(self) -> None: super()._reset_run_scoped_state() + self._cache_disabled_sessions = None + self._cache_disable_failed = False + self._cache_control_receipt = None self._liquid_clustering_operations = [] self._z_order_operations = [] self._applied_layout_operations = [] @@ -893,9 +903,17 @@ def get_normalized_result_metadata( config = info.get("configuration") if isinstance(info.get("configuration"), Mapping) else {} compute = info.get("compute_configuration") if isinstance(info.get("compute_configuration"), Mapping) else {} + from benchbox.platforms.cloud_shared import sanitize_cache_control_receipt + + receipt = sanitize_cache_control_receipt(self._cache_control_receipt) + config = dict(cast(Mapping[str, Any], config)) + config["result_cache_enabled"] = not receipt["cache_disabled"] if receipt and receipt["validated"] else None + metadata["platform_deployment"] = self._databricks_deployment_metadata(config, compute) metadata["platform_cloud"] = self._databricks_cloud_metadata(config) metadata["platform_compute"] = self._databricks_compute_metadata(config, compute) + if receipt is not None: + metadata["platform_compute"]["cache_control"] = receipt metadata["platform_storage"] = self._databricks_storage_metadata(config) return metadata @@ -1068,6 +1086,41 @@ def get_target_dialect(self) -> str: """Return the target SQL dialect for Databricks.""" return "databricks" + def preprocess_operation_sql(self, query_id: str, operation: Any) -> str | None: + """Rewrite operation write SQL for Databricks-only dialect gaps. + + Respects catalog ``databricks`` overrides (including skip ``None``): + rewrites the override when present, otherwise the default write SQL. + + - ``CAST(x AS VARCHAR)`` -> ``CAST(x AS STRING)`` (Databricks + VARCHAR requires a length parameter; verified live with + DATATYPE_MISSING_SIZE on batch inserts) + - ``unnest(generate_series(a, b))`` -> ``explode(sequence(a, b))`` + (Databricks has neither function; verified live with + UNRESOLVED_ROUTINE ``unnest``) + """ + import re + + overrides = getattr(operation, "platform_overrides", None) or {} + if "databricks" in overrides: + base = overrides["databricks"] + if base is None: + return None + else: + base = operation.write_sql + base = re.sub( + r"\bunnest\(\s*generate_series\(([^()]*)\)\s*\)", + r"explode(sequence(\1))", + base, + flags=re.IGNORECASE, + ) + return re.sub( + r"\bCAST\(([^()]+?)\s+AS\s+VARCHAR\s*\)", + r"CAST(\1 AS STRING)", + base, + flags=re.IGNORECASE, + ) + def _get_connection_params(self, **connection_config) -> dict[str, Any]: """Get standardized connection parameters.""" return { @@ -1112,6 +1165,27 @@ def check_server_database_exists(self, **connection_config) -> bool: if "connection" in locals(): connection.close() + def reset_database_in_place(self, **connection_config) -> bool: + catalog = connection_config.get("catalog", self.catalog) + schema = connection_config.get("schema", self.schema) + connection = None + try: + connection = self._create_admin_connection(**connection_config) + cursor = connection.cursor() + cursor.execute(f"SHOW TABLES IN {catalog}.{schema}") + tables = [row[1] for row in cursor.fetchall() if not (len(row) > 2 and row[2])] + for table in tables: + cursor.execute(f"TRUNCATE TABLE {catalog}.{schema}.`{table}`") + self.log_verbose(f"Truncated {len(tables)} tables in {catalog}.{schema} for reload") + self._schema_reset_in_place = True + return True + except Exception as e: + self.log_verbose(f"In-place reset of {catalog}.{schema} failed: {e}") + raise + finally: + if connection is not None: + connection.close() + def drop_database(self, **connection_config) -> None: """Drop schema in Databricks catalog.""" try: @@ -1151,15 +1225,33 @@ def create_connection(self, **connection_config) -> Any: cursor.fetchall() self.log_very_verbose("Databricks connection test successful") - # Set catalog and schema context - # If database is being reused, schema already exists - set it now - # If database is new, schema will be created in create_schema() which will also set it - cursor.execute(f"USE CATALOG {self.catalog}") - if self.database_was_reused: + # Set catalog and schema context on every connection. Pooled + # connections never pass through create_schema(), so deferring + # USE SCHEMA there leaves them on the default schema and every + # unqualified probe fails (verified live: staging COUNT(*) probes + # failed while DDL landed in the default schema). CREATE SCHEMA + # is still authorized even when the schema exists, so only + # ensure it for fresh databases: on a reused catalog/schema the + # principal may hold USE SCHEMA without catalog-level + # CREATE SCHEMA, and requiring it here would block reconnects. + # USE CATALOG is likewise deferred when this connection is meant + # to create the catalog: create_schema() owns catalog creation, + # and selecting a not-yet-created catalog fails before it runs. + # create_schema() keeps owning table creation. + if not (getattr(self, "create_catalog", False) and not getattr(self, "database_was_reused", False)): + cursor.execute(f"USE CATALOG {self.catalog}") + if not getattr(self, "database_was_reused", False) and not getattr(self, "_validating_database", False): + cursor.execute(f"CREATE SCHEMA IF NOT EXISTS {self.catalog}.{self.schema}") cursor.execute(f"USE SCHEMA {self.schema}") - self.log_very_verbose(f"Set schema context to {self.catalog}.{self.schema} (database reused)") - else: - self.log_very_verbose(f"Set catalog to {self.catalog}, schema will be set during schema creation") + self.log_very_verbose(f"Set schema context to {self.catalog}.{self.schema}") + + try: + self._ensure_session_cache_disabled(cursor, session=connection) + except Exception: + connection.close() + raise + finally: + cursor.close() self.log_operation_complete( "Databricks connection", @@ -1193,6 +1285,9 @@ def create_schema(self, benchmark, connection: Any) -> float: cursor.execute(f"CREATE CATALOG IF NOT EXISTS {self.catalog}") cursor.execute(f"CREATE SCHEMA IF NOT EXISTS {self.catalog}.{self.schema}") self.log_verbose(f"Created catalog and schema: {self.catalog}.{self.schema}") + # The catalog now exists: later connections take the normal + # context-selection path instead of deferring again. + self.create_catalog = False else: # Just create schema if catalog already exists cursor.execute(f"CREATE SCHEMA IF NOT EXISTS {self.catalog}.{self.schema}") @@ -1225,7 +1320,15 @@ def create_schema(self, benchmark, connection: Any) -> float: self.log_verbose(f"SQL length changed after Databricks syntax fix: {original_len} -> {len(schema_sql)}") # Split schema into individual statements and execute - statements = [stmt.strip() for stmt in schema_sql.split(";") if stmt.strip()] + from benchbox.platforms.cloud_shared import split_leading_sql_comments + + # Skip chunks holding only decorative "--" comments (a ";" inside + # a comment splits one off): they carry no DDL. + statements = [ + stmt.strip() + for stmt in schema_sql.split(";") + if stmt.strip() and split_leading_sql_comments(stmt)[1].strip() + ] # Debug: Log statement count self.log_verbose(f"Parsed {len(statements)} CREATE TABLE statements from schema SQL") @@ -1916,7 +2019,7 @@ def load_data( existing_tables, data_source, ) - table_stats[table_name.upper()] = row_count + table_stats[table_name.lower()] = row_count load_time = elapsed_seconds(load_start) per_table_timings[table_name.upper()] = { @@ -1930,7 +2033,7 @@ def load_data( except Exception as e: self.logger.error(f"Failed to load {table_name}: {str(e)[:200]}") - table_stats[table_name.upper()] = 0 + table_stats[table_name.lower()] = 0 per_table_timings[table_name.upper()] = { "copy_into_ms": 0, "optimize_ms": 0, @@ -2176,29 +2279,70 @@ def _resolve_csv_null_marker( """ return self._resolve_copy_dialect(data_source, table_name, file_path, benchmark).null_marker - def _get_column_list_for_table(self, benchmark, table_name: str) -> str: - """Get explicit column mapping from benchmark schema for COPY INTO.""" - if not hasattr(benchmark, "get_schema"): + def _get_column_list_for_table(self, benchmark, table_name: str, cursor: Any | None = None) -> str: + """Get the explicit column list for a headerless COPY INTO. + + Without a column list COPY INTO maps CSV fields by position against + the Delta schema and rejects the load with + COPY_INTO_SCHEMA_MISMATCH_WITH_TARGET_TABLE. Columns come from the + benchmark schema when it describes the table (dict entries or schema + objects with a ``columns`` attribute). Benchmarks also load tables + their ``get_schema()`` omits, such as the TPC-H base tables behind + Transaction Primitives or the TPC-DS sources behind TPC-DS OBT, so + the target table's own DESCRIBE output is the fallback. + """ + table_name_upper = table_name.upper() + columns = self._schema_columns_for_table(benchmark, table_name) + if not columns and cursor is not None: + columns = self._describe_table_columns(cursor, table_name_upper) + if not columns: + self.logger.warning( + f"No column list resolved for {table_name_upper}; COPY INTO will map CSV fields by position" + ) return "" + self.log_very_verbose(f"Using explicit column mapping for {table_name_upper}: {len(columns)} columns") + return f" ({', '.join(columns)})" + + def _schema_columns_for_table(self, benchmark, table_name: str) -> list[str]: + """Return column names for a table from ``benchmark.get_schema()``, or [].""" + if not hasattr(benchmark, "get_schema"): + return [] try: schema = benchmark.get_schema() - table_name_upper = table_name.upper() - table_schema = schema.get(table_name.lower()) - if not table_schema: - table_schema = schema.get(table_name_upper.lower()) - if not table_schema: - table_schema = schema.get(table_name) - - if table_schema and "columns" in table_schema: - columns = [col["name"] for col in table_schema["columns"]] - if columns: - self.log_very_verbose( - f"Using explicit column mapping for {table_name_upper}: {len(columns)} columns" - ) - return f" ({', '.join(columns)})" except Exception as e: - self.log_very_verbose(f"Could not get column list for {table_name}: {e}") - return "" + self.logger.warning(f"Could not read benchmark schema for {table_name}: {e}") + return [] + if not isinstance(schema, dict): + return [] + table_schema = schema.get(table_name.lower()) or schema.get(table_name) + if table_schema is None: + return [] + raw_columns = ( + table_schema.get("columns") if isinstance(table_schema, dict) else getattr(table_schema, "columns", None) + ) + names = [] + for col in raw_columns or []: + name = col.get("name") if isinstance(col, dict) else getattr(col, "name", None) + if name: + names.append(str(name)) + return names + + def _describe_table_columns(self, cursor: Any, table_name_upper: str) -> list[str]: + """Return the target table's column names in declaration order, or [].""" + try: + cursor.execute(f"DESCRIBE TABLE {table_name_upper}") + rows = list(cursor.fetchall() or []) + except Exception as e: + self.logger.warning(f"DESCRIBE TABLE {table_name_upper} failed while resolving COPY columns: {e}") + return [] + names = [] + for row in rows: + col = str(row[0]).strip() if len(row) > 0 and row[0] is not None else "" + # DESCRIBE appends partition/metadata sections after a blank or '#' row. + if not col or col.startswith("#"): + break + names.append(col) + return names def _target_cast_select(self, cursor: Any, table_name_upper: str) -> str: """Build a SELECT list casting source fields to the Delta column types. @@ -2255,7 +2399,6 @@ def _load_single_table( data_source=data_source, benchmark=benchmark, ) - column_list = self._get_column_list_for_table(benchmark, table_name) # Mirror the dialect-path derivation in _resolve_file_uri_and_delimiter so # the null marker resolves for the same file the delimiter came from. if isinstance(file_path, list) and file_path: @@ -2291,25 +2434,34 @@ def _load_single_table( is_parquet = copy_sources[0].lower().split("?")[0].endswith(".parquet") if copy_sources else False use_cast_select = is_parquet or copy_dialect.has_header cast_select = "" + column_list = "" if use_cast_select: self.log_very_verbose(f"Using cast SELECT load for {table_name_upper}") cast_select = self._target_cast_select(cursor, table_name_upper) + else: + column_list = self._get_column_list_for_table(benchmark, table_name, cursor) + # COPY INTO skips files it has already loaded into the target table. + # Staging file URIs are reused across runs, and a reload can target a + # table that was truncated rather than dropped, so force every load; + # the row count check below still catches a double load. + copy_options = " COPY_OPTIONS('force' = 'true')" copy_time = 0.0 for source_uri in copy_sources: if is_parquet: copy_sql = ( f"COPY INTO {table_name_upper} FROM (SELECT {cast_select} FROM '{source_uri}') FILEFORMAT = PARQUET" + f"{copy_options}" ) elif copy_dialect.has_header: copy_sql = ( f"COPY INTO {table_name_upper} FROM (SELECT {cast_select} FROM '{source_uri}') " - f"FILEFORMAT = CSV FORMAT_OPTIONS({format_options})" + f"FILEFORMAT = CSV FORMAT_OPTIONS({format_options}){copy_options}" ) else: copy_sql = ( f"COPY INTO {table_name_upper}{column_list} FROM '{source_uri}' " - f"FILEFORMAT = CSV FORMAT_OPTIONS({format_options})" + f"FILEFORMAT = CSV FORMAT_OPTIONS({format_options}){copy_options}" ) copy_start = mono_time() cursor.execute(copy_sql) @@ -2430,7 +2582,7 @@ def create_external_tables( cursor.execute(f"CREATE TABLE {table_name_upper} USING PARQUET LOCATION '{location}'") cursor.execute(f"SELECT COUNT(*) FROM {table_name_upper}") result = cursor.fetchone() - table_stats[table_name_upper] = int(result[0]) if result else 0 + table_stats[table_name_lower] = int(result[0]) if result else 0 finally: cursor.close() @@ -2443,20 +2595,30 @@ def configure_for_benchmark(self, connection: Any, benchmark_type: str) -> None: Applies result cache control first, then any user-provided custom Spark configurations. """ + from benchbox.core.exceptions import ConfigurationError + from benchbox.platforms.cloud_shared import empty_cache_control_receipt + + configs = getattr(self, "spark_configs", {}) or {} + if self.disable_result_cache and any( + str(key).strip().lower() == "use_cached_result" and str(value).lower() != "false" + for key, value in configs.items() + ): + receipt = empty_cache_control_receipt() + receipt["errors"].append("Custom Spark configuration conflicts with required result cache disable") + self._cache_disable_failed = True + self._cache_control_receipt = receipt + raise ConfigurationError(receipt["errors"][0], details=receipt) + cursor = connection.cursor() try: - # Apply result cache control - disable by default for accurate benchmarking - if self.disable_result_cache: - try: - cursor.execute("SET use_cached_result = false") - self.logger.debug("Disabled result cache (use_cached_result = false)") - except Exception as e: - self.logger.warning(f"Failed to disable result cache: {e}") + self._ensure_session_cache_disabled(cursor, session=connection) # Apply user-provided configurations if specified if hasattr(self, "spark_configs") and self.spark_configs: for config_key, config_value in self.spark_configs.items(): + if self.disable_result_cache and str(config_key).strip().lower() == "use_cached_result": + continue # The validated session helper owns this setting. try: cursor.execute(f"SET {config_key} = {config_value}") self.logger.debug(f"Set {config_key} = {config_value}") @@ -2468,6 +2630,105 @@ def configure_for_benchmark(self, connection: Any, benchmark_type: str) -> None: finally: cursor.close() + def _ensure_session_cache_disabled(self, cursor: Any, *, session: Any | None = None) -> None: + """Disable and read back cache state once per connection, or refuse execution.""" + import inspect + import weakref + + from benchbox.core.exceptions import ConfigurationError + from benchbox.core.tuning.applied_ledger import ( + PHASE_SESSION, + RecordingConnection, + _RecordingCursor, + recording_connection, + ) + from benchbox.platforms.cloud_shared import empty_cache_control_receipt, explicit_cache_enabled_receipt + + if not self.disable_result_cache: + self._cache_control_receipt = explicit_cache_enabled_receipt("use_cached_result", "true") + return + + # Real attributes avoid connections invented by dynamic proxies. + # Unwrap known recording proxies to keep one capture owner per SET. + while isinstance(cursor, _RecordingCursor): + cursor = cursor._cur + while isinstance(session, RecordingConnection): + session = session.raw_connection + if session is None: + for attr in ("connection", "_connection"): + if inspect.getattr_static(cursor, attr, None) is not None: + session = getattr(cursor, attr) + break + key = session if session is not None else cursor + if self._cache_disabled_sessions is None: + self._cache_disabled_sessions = weakref.WeakSet() + receipt = empty_cache_control_receipt() + try: + weakref.ref(key) + hash(key) + if key in self._cache_disabled_sessions: + return + except TypeError as exc: + receipt["errors"].append("Unsupported session identity: cache initialization cannot be tracked") + self._cache_disable_failed = True + self._cache_control_receipt = receipt + raise ConfigurationError(receipt["errors"][0], details=receipt) from exc + + capture = recording_connection(cursor, getattr(self, "_applied_tuning_ledger", None), PHASE_SESSION) + try: + capture.execute("SET use_cached_result = false") + cursor.fetchall() + cursor.execute("SET use_cached_result") + row = cursor.fetchone() + if row is None or len(row) != 2 or str(row[0]).lower() != "use_cached_result": + raise ValueError("Unsupported cache readback: expected (use_cached_result, value)") + value = str(row[1]).lower() + receipt["settings"]["use_cached_result"] = value + if value != "false": + raise ValueError(f"Expected use_cached_result=false, observed {value}") + receipt["validated"] = True + receipt["cache_disabled"] = True + except Exception as exc: + receipt["errors"].append(str(exc)) + self._cache_disable_failed = True + self._cache_control_receipt = receipt + raise ConfigurationError("Databricks session cache control failed", details=receipt) from exc + + # A later successful session cannot erase an earlier failed receipt. + if not self._cache_disable_failed: + self._cache_control_receipt = receipt + self._cache_disabled_sessions.add(key) + + def _initialize_query_session(self, connection: Any) -> None: + """Prepare a session before a power or throughput harness starts timing.""" + if hasattr(connection, "cursor"): + cursor = connection.cursor() + try: + self._ensure_session_cache_disabled(cursor, session=connection) + finally: + cursor.close() + else: + self._ensure_session_cache_disabled(connection) + + def _make_direct_power_connection_adapter(self, connection: Any, benchmark_id: str, scale_factor: float) -> Any: + self._initialize_query_session(connection) + return super()._make_direct_power_connection_adapter(connection, benchmark_id, scale_factor) + + def _make_power_connection_adapter(self, connection: Any, benchmark_id: str, scale_factor: float) -> Any: + self._initialize_query_session(connection) + return super()._make_power_connection_adapter(connection, benchmark_id, scale_factor) + + def _execute_tpch_throughput_test(self, benchmark: Any, connection: Any, run_config: dict) -> list[dict[str, Any]]: + # Databricks currently uses shared cursors. Initialize the actual + # connection before the parent harness starts its throughput window. + self._initialize_query_session(connection) + return super()._execute_tpch_throughput_test(benchmark, connection, run_config) + + def new_stream_connection(self, connection: Any, *, benchmark_type: str | None = None) -> Any: + stream = super().new_stream_connection(connection, benchmark_type=benchmark_type) + self._initialize_query_session(stream) + return stream + def execute_query( self, connection: Any, @@ -2484,10 +2745,6 @@ def execute_query( power harness passes a per-stream cursor through the facade, which has no ``cursor()`` method of its own. """ - start_time = mono_time() - self.log_verbose(f"Executing query {query_id}") - self.log_very_verbose(f"Query SQL (first 200 chars): {query[:200]}{'...' if len(query) > 200 else ''}") - own_cursor = False if hasattr(connection, "cursor"): cursor = connection.cursor() @@ -2495,6 +2752,26 @@ def execute_query( else: cursor = connection + # Fallback for direct callers. Harness factories prepare this session + # before their outer timer, even when no warmup is requested. + try: + self._ensure_session_cache_disabled(cursor, session=connection if own_cursor else None) + except Exception as exc: + if own_cursor: + cursor.close() + return { + "query_id": query_id, + "status": "FAILED", + "execution_time_seconds": 0.0, + "rows_returned": 0, + "error": str(exc), + "error_type": type(exc).__name__, + } + + start_time = mono_time() + self.log_verbose(f"Executing query {query_id}") + self.log_very_verbose(f"Query SQL (first 200 chars): {query[:200]}{'...' if len(query) > 200 else ''}") + try: # Schema context is already set in create_connection() and persists for the session # No need to set USE . before every query - it adds unnecessary overhead @@ -2509,8 +2786,18 @@ def execute_query( if re.sub(r"[^a-z0-9]", "", (benchmark_type or "").lower()).startswith("tpcdi"): query = self._apply_tpcdi_databricks_rewrites(query) query = self._normalize_databricks_query(query) - cursor.execute(query) - result = cursor.fetchall() + # The SQL execution API accepts one statement per execute: run + # operation batches (DELETE+INSERT pairs, the 3-statement SCD2 + # stage batch, DDL sequences) statement-by-statement in session + # order and report the last statement's rows. A single statement + # keeps the identical single-execute path as before. + from benchbox.platforms.base.mysql_wire import split_sql_statements + + statements = split_sql_statements(query) + result = [] + for statement in statements or [query]: + cursor.execute(statement) + result = cursor.fetchall() execution_time = elapsed_seconds(start_time) actual_row_count = len(result) if result else 0 @@ -2787,48 +3074,81 @@ def _convert_to_delta_table(self, statement: str) -> str: before, while table_format="hudi" renders USING HUDI with record-key TBLPROPERTIES instead. """ - if not statement.upper().startswith("CREATE TABLE"): + from benchbox.platforms.cloud_shared import split_leading_sql_comments + + # Schema chunks can carry decorative "--" header lines before the real + # CREATE (naive ";" splitting preserves them). Gate and rewrite on the + # remainder so those chunks still get the idempotent OR REPLACE form. + prefix, body = split_leading_sql_comments(statement) + if not body.upper().startswith("CREATE TABLE"): return statement # Ensure idempotency with OR REPLACE, unless the statement already has # IF NOT EXISTS (CREATE OR REPLACE ... IF NOT EXISTS is a syntax error). - if "CREATE TABLE" in statement.upper() and "OR REPLACE" not in statement.upper(): - if "IF NOT EXISTS" not in statement.upper(): - statement = statement.replace("CREATE TABLE", "CREATE OR REPLACE TABLE", 1) + # Rewrite the body only, so a comment mentioning CREATE TABLE is safe. + if "CREATE TABLE" in body.upper() and "OR REPLACE" not in body.upper(): + if "IF NOT EXISTS" not in body.upper(): + body = body.replace("CREATE TABLE", "CREATE OR REPLACE TABLE", 1) + elif getattr(self, "_schema_reset_in_place", False): + # After an in-place reset the table still exists with its old + # definition; replace it so the current DDL applies. + body = re.sub( + r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS", "CREATE OR REPLACE TABLE", body, count=1, flags=re.IGNORECASE + ) + statement = prefix + body if self.table_format == "hudi": - return self._convert_to_hudi_table(statement) - - # Default to DELTA format when unspecified - if "USING" not in statement.upper(): - # Find the closing parenthesis of column definitions - paren_count = 0 - using_pos = len(statement) - - for i, char in enumerate(statement): - if char == "(": - paren_count += 1 - elif char == ")": - paren_count -= 1 - if paren_count == 0: - using_pos = i + 1 - break - - # Insert USING DELTA clause - statement = statement[:using_pos] + " USING DELTA" + statement[using_pos:] + return self._convert_to_hudi_table(prefix + body) + + # Default to DELTA format when unspecified. Scan the body only: parens + # in the comment prefix must not displace the USING DELTA insertion. + # On CTAS every table clause (USING, TBLPROPERTIES) precedes + # AS SELECT: there is no column list to attach to, and anything + # appended after the query is a syntax error. Only scan for the + # column-list close paren when the statement actually defines + # columns. + as_select = re.search(r"\bAS\s+(?:SELECT\b|WITH\b)", body, flags=re.IGNORECASE) + has_using_clause = re.search( + r"\bUSING\s+(?:DELTA|HUDI|PARQUET|CSV|JSON|TEXT|ORC|AVRO)\b", body, flags=re.IGNORECASE + ) + if has_using_clause is None: + if as_select is not None: + body = body[: as_select.start()] + "USING DELTA " + body[as_select.start() :] + else: + # Find the closing parenthesis of column definitions + paren_count = 0 + using_pos = len(body) + + for i, char in enumerate(body): + if char == "(": + paren_count += 1 + elif char == ")": + paren_count -= 1 + if paren_count == 0: + using_pos = i + 1 + break + + # Insert USING DELTA clause + body = body[:using_pos] + " USING DELTA" + body[using_pos:] # Include Delta Lake optimization properties - if "TBLPROPERTIES" not in statement.upper(): - statement += " TBLPROPERTIES (" + if "TBLPROPERTIES" not in body.upper(): properties = [] if self.delta_auto_optimize: properties.append("'delta.autoOptimize.optimizeWrite' = 'true'") properties.append("'delta.autoOptimize.autoCompact' = 'true'") - statement += ", ".join(properties) + ")" + clause = "TBLPROPERTIES (" + ", ".join(properties) + ")" + if as_select is not None: + # Re-find AS SELECT: the USING insertion above shifted it. + anchor_match = re.search(r"\bAS\s+(?:SELECT\b|WITH\b)", body, flags=re.IGNORECASE) + anchor = anchor_match.start() if anchor_match else len(body) + body = body[:anchor].rstrip() + " " + clause + " " + body[anchor:] + else: + body += " " + clause - return statement + return prefix + body def _convert_to_hudi_table(self, statement: str) -> str: """Convert CREATE TABLE statement to Apache Hudi format. @@ -2844,7 +3164,12 @@ def _convert_to_hudi_table(self, statement: str) -> str: Record-key values are validated as SQL identifiers at init, so the f-string interpolation below cannot break quoting. """ - if "USING" not in statement.upper(): + import re + + has_using_clause = re.search( + r"\bUSING\s+(?:DELTA|HUDI|PARQUET|CSV|JSON|TEXT|ORC|AVRO)\b", statement, flags=re.IGNORECASE + ) + if has_using_clause is None: paren_count = 0 using_pos = len(statement) diff --git a/benchbox/platforms/dataframe/benchmark_mixin.py b/benchbox/platforms/dataframe/benchmark_mixin.py index 9e36d21ca5..36619b0f8d 100644 --- a/benchbox/platforms/dataframe/benchmark_mixin.py +++ b/benchbox/platforms/dataframe/benchmark_mixin.py @@ -13,6 +13,7 @@ import inspect import logging from abc import abstractmethod +from contextlib import AbstractContextManager, nullcontext from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -229,6 +230,24 @@ def __init__( self.per_table_stats = per_table_stats or {} +def _describe_query_parameters(benchmark_config: Any) -> str | None: + benchmark_id = normalize_benchmark_id(benchmark_config.name) + if benchmark_id == "tpch": + from benchbox.core.tpch.benchmark import describe_query_parameters + + seed = (getattr(benchmark_config, "options", {}) or {}).get("seed") + return describe_query_parameters(None if seed is None else int(seed)) + if benchmark_id != "tpcds": + return None + from benchbox.core.tpcds.dataframe_queries.production_binding import ( + describe_query_parameters, + run_seed, + selected_query_numbers, + ) + + return describe_query_parameters(run_seed(benchmark_config), selected_query_numbers(benchmark_config)) + + def _client_host_profile(system_profile: Any) -> dict[str, Any]: """Return the client-host profile dict for a DataFrame run. @@ -443,6 +462,7 @@ def run_benchmark( query_subset=getattr(benchmark_config, "queries", None), tuning_mode=options_map.get("tuning_mode"), tuning_config=options_map.get("df_tuning_config"), + query_parameters=_describe_query_parameters(benchmark_config), ) ) @@ -797,7 +817,12 @@ def _resolve_column_names_and_delimiter( if benchmark_id == "tpch": column_names_map.update(get_tpch_column_names()) + # Only a real string attribute counts: getattr on a Mock benchmark + # fabricates a child mock for any name, which would otherwise ride + # through as the delimiter override and corrupt every CSV load. csv_delimiter = getattr(benchmark_instance, "csv_delimiter", None) + if not isinstance(csv_delimiter, str): + csv_delimiter = None return column_names_map, csv_delimiter @@ -1216,29 +1241,41 @@ def _run_warmup_iterations( console.print(f"\n[yellow]Running {warmup_iterations} warmup iteration(s)...[/yellow]") for warmup_iter in range(warmup_iterations): + warmup_stream_id = warmup_iter warmup_queries = self._filter_queries( - self._get_queries_for_benchmark(benchmark_config, benchmark_instance, stream_id=0), + self._get_queries_for_benchmark(benchmark_config, benchmark_instance, stream_id=warmup_stream_id), skip_query_ids, query_filter, ) - console.print(f"[dim]Warmup {warmup_iter + 1}/{warmup_iterations} (stream 0)[/dim]") - for query in warmup_queries: - try: - result = self.execute_query(ctx, query) - result = dict(result) - except Exception as e: - logger.warning(f"Warmup query {query.query_id} failed: {e}") - result = { - "query_id": query.query_id, - "status": "FAILED", - "error": str(e), - "execution_time_seconds": 0.0, - } - result["iteration"] = 0 - result["stream_id"] = 0 - result["run_type"] = "warmup" - query_results.append(result) + console.print(f"[dim]Warmup {warmup_iter + 1}/{warmup_iterations} (stream {warmup_stream_id})[/dim]") + with self._stream_parameter_scope(benchmark_config, warmup_stream_id): + for query in warmup_queries: + try: + result = self.execute_query(ctx, query) + result = dict(result) + except Exception as e: + logger.warning(f"Warmup query {query.query_id} failed: {e}") + result = { + "query_id": query.query_id, + "status": "FAILED", + "error": str(e), + "execution_time_seconds": 0.0, + } + result["iteration"] = 0 + result["stream_id"] = warmup_stream_id + result["run_type"] = "warmup" + query_results.append(result) + + @staticmethod + def _stream_parameter_scope(benchmark_config: BenchmarkConfig, stream_id: int) -> AbstractContextManager[None]: + if normalize_benchmark_id(benchmark_config.name) != "tpch": + return nullcontext() + from benchbox.core.tpch.dataframe_queries import seeded_parameter_overrides + + seed = (getattr(benchmark_config, "options", {}) or {}).get("seed") + scale_factor = getattr(benchmark_config, "scale_factor", 1.0) + return seeded_parameter_overrides(None if seed is None else int(seed), scale_factor, stream_id) def _run_measurement_iterations( self, @@ -1283,24 +1320,25 @@ def _run_measurement_iterations( ) iteration_results: list[dict[str, Any]] = [] - for i, query in enumerate(measurement_queries, 1): - if run_options and (run_options.verbose or run_options.very_verbose): - console.print(f"[blue]Executing query {i}/{total_queries}: {query.query_id}[/blue]") + with self._stream_parameter_scope(benchmark_config, measurement_stream_id): + for i, query in enumerate(measurement_queries, 1): + if run_options and (run_options.verbose or run_options.very_verbose): + console.print(f"[blue]Executing query {i}/{total_queries}: {query.query_id}[/blue]") - result = self._execute_single_measurement_query( - ctx=ctx, - query=query, - capture_plans=capture_plans, - supports_profiled=supports_profiled, - supports_capture_plan_kw=supports_capture_plan_kw, - profiled_runner=profiled_runner, - iteration=iteration + 1, - stream_id=measurement_stream_id, - monitor=monitor, - ) - query_results.append(result) - executed_query_ids.append(str(query.query_id).strip().upper()) - iteration_results.append(result) + result = self._execute_single_measurement_query( + ctx=ctx, + query=query, + capture_plans=capture_plans, + supports_profiled=supports_profiled, + supports_capture_plan_kw=supports_capture_plan_kw, + profiled_runner=profiled_runner, + iteration=iteration + 1, + stream_id=measurement_stream_id, + monitor=monitor, + ) + query_results.append(result) + executed_query_ids.append(str(query.query_id).strip().upper()) + iteration_results.append(result) self._print_iteration_summary( iteration + 1, diff --git a/benchbox/platforms/dataframe/datafusion_df.py b/benchbox/platforms/dataframe/datafusion_df.py index cdb5d4001a..c5358435eb 100644 --- a/benchbox/platforms/dataframe/datafusion_df.py +++ b/benchbox/platforms/dataframe/datafusion_df.py @@ -812,7 +812,7 @@ def scalar_to_df(self, data: dict[str, Any]) -> DataFusionLazyDF: # Convert to PyArrow table table = pa.table({k: [v] for k, v in data.items()}) # Create DataFusion DataFrame from the table - return self._session_context.from_arrow(table) + return self.session_ctx.from_arrow(table) # ========================================================================= # Window Functions diff --git a/benchbox/platforms/dataframe/expression_family.py b/benchbox/platforms/dataframe/expression_family.py index 373249ceaa..68ffd053fe 100644 --- a/benchbox/platforms/dataframe/expression_family.py +++ b/benchbox/platforms/dataframe/expression_family.py @@ -50,7 +50,12 @@ resolve_dataframe_csv_dialect, ) from benchbox.platforms.dataframe.tuning_mixin import TuningConfigurableMixin -from benchbox.platforms.dataframe.unified_frame import UnifiedExpr, UnifiedLazyFrame, UnifiedWhen +from benchbox.platforms.dataframe.unified_frame import ( + UnifiedExpr, + UnifiedLazyFrame, + UnifiedWhen, + _defer_datafusion_operation, +) from benchbox.utils.clock import elapsed_seconds, mono_time from benchbox.utils.file_format import detect_data_format @@ -308,6 +313,7 @@ def rename_columns(self, df: LazyDF, mapping: dict[str, str]) -> LazyDF: """Delegate rename_columns to the adapter.""" return cast(LazyDF, self._adapter.rename_columns(df, mapping)) + @_defer_datafusion_operation def when(self, condition: Any) -> UnifiedWhen: """Create a WHEN expression for conditional logic (CASE WHEN). @@ -332,7 +338,7 @@ def when(self, condition: Any) -> UnifiedWhen: # Check platform type from adapter platform = self._adapter.platform_name - if platform == "PySpark": + if platform in {"PySpark", "LakeSail"}: # For PySpark, we just store the condition - F.when is called in then() return UnifiedWhen(cond, platform="PySpark") @@ -365,6 +371,7 @@ def concat(self, dataframes: list[Any]) -> UnifiedLazyFrame: result = self._adapter.concat_dataframes(native_dfs) return UnifiedLazyFrame(result, self._adapter) + @_defer_datafusion_operation def struct(self, *columns: Any) -> UnifiedExpr: """Create a struct expression from multiple columns. @@ -413,6 +420,7 @@ def struct(self, *columns: Any) -> UnifiedExpr: return UnifiedExpr(pl.struct(*native_cols)) + @_defer_datafusion_operation def map_from_entries(self, column: Any) -> UnifiedExpr: """Create a map from a list of key-value struct entries. @@ -670,6 +678,7 @@ def max_(self, column: str) -> UnifiedExpr: return UnifiedExpr(pl.max(column)) + @_defer_datafusion_operation def coalesce(self, *exprs: Any) -> UnifiedExpr: """Create a COALESCE expression. diff --git a/benchbox/platforms/dataframe/unified_frame.py b/benchbox/platforms/dataframe/unified_frame.py index 8e1999e81b..e8c78fd2d8 100644 --- a/benchbox/platforms/dataframe/unified_frame.py +++ b/benchbox/platforms/dataframe/unified_frame.py @@ -15,16 +15,6 @@ - sort(descending=): Translates to orderBy() with asc()/desc() on PySpark - col().sum()/mean()/count(): Works on all platforms via UnifiedExpr -DataFusion Compatibility Notes: - The DataFusion support includes experimental AST parsing for handling - aggregate arithmetic expressions. This was originally tested with - DataFusion 43.0.0 and re-validated against 53.0.0. If a future DataFusion - release changes the underlying error-message format this relies on, - `_get_datafusion_ast_string()` now raises `DataFusionASTFormatError` - (naming the installed DataFusion version and the unrecognized error - text) instead of silently falling back to unchanged-expression behavior. - See _get_datafusion_ast_string() for details. - Copyright 2026 Joe Harris / BenchBox Project Licensed under the MIT License. See LICENSE file in the project root for details. @@ -33,7 +23,8 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar if TYPE_CHECKING: import polars as pl @@ -129,7 +120,108 @@ def _unwrap_unified_expr(value: Any) -> Any: return value -class UnifiedStrExpr: +def _resolve_datafusion_value(value: Any, frame: Any) -> Any: + while ( + frame is not None + and isinstance(value, _DataFusionDeferredOperations) + and value._datafusion_resolver is not None + ): + value = value._datafusion_resolver(frame) + if isinstance(value, tuple): + return tuple(_resolve_datafusion_value(item, frame) for item in value) + if isinstance(value, list): + return [_resolve_datafusion_value(item, frame) for item in value] + if isinstance(value, dict): + return {key: _resolve_datafusion_value(item, frame) for key, item in value.items()} + return value + + +def _has_datafusion_resolver(value: Any) -> bool: + if isinstance(value, _DataFusionDeferredOperations): + return value._datafusion_resolver is not None + if isinstance(value, (tuple, list)): + return any(_has_datafusion_resolver(item) for item in value) + if isinstance(value, dict): + return any(_has_datafusion_resolver(item) for item in value.values()) + return False + + +def _defer_datafusion_operation(operation: Callable[..., Any]) -> Callable[..., Any]: + @wraps(operation) + def apply(*args: Any, **kwargs: Any) -> Any: + result = operation(*args, **kwargs) + if ( + isinstance(result, _DataFusionDeferredOperations) + and all(result is not argument for argument in args) + and (_has_datafusion_resolver(args) or _has_datafusion_resolver(kwargs)) + ): + captured_args = _resolve_datafusion_value(args, None) + captured_kwargs = _resolve_datafusion_value(kwargs, None) + result._datafusion_resolver = lambda frame: operation( + *_resolve_datafusion_value(captured_args, frame), **_resolve_datafusion_value(captured_kwargs, frame) + ) + return result + + return apply + + +class _DataFusionDeferredOperations: + _datafusion_resolver: Callable[[Any], Any] | None = None + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + for name, member in tuple(vars(cls).items()): + if name in {"__init__", "__repr__", "native"} or (name.startswith("_") and not name.endswith("__")): + continue + if isinstance(member, property) and member.fget is not None: + setattr( + cls, + name, + property(_defer_datafusion_operation(member.fget), member.fset, member.fdel, member.__doc__), + ) + elif callable(member): + setattr(cls, name, _defer_datafusion_operation(member)) + + +def _bind_datafusion_arguments(operation: Callable[..., Any]) -> Callable[..., Any]: + @wraps(operation) + def apply(self: Any, *args: Any, **kwargs: Any) -> Any: + frame = self._source_df if isinstance(self, UnifiedGroupBy) else self._df + if _is_datafusion_df(frame): + args = _resolve_datafusion_value(args, frame) + kwargs = _resolve_datafusion_value(kwargs, frame) + return operation(self, *args, **kwargs) + + return apply + + +def _datafusion_division(numerator: Any, denominator: Any) -> UnifiedExpr: + from datafusion import lit as df_lit + + def native(value: Any) -> Any: + value = value.native if isinstance(value, UnifiedExpr) else value + return value if _is_datafusion_expr(value) else df_lit(value) + + def build(frame: Any = None) -> UnifiedExpr: + import pyarrow as pa + from datafusion import functions as df_f + + left = native(numerator if frame is None else _resolve_datafusion_value(numerator, frame)) + right = native(denominator if frame is None else _resolve_datafusion_value(denominator, frame)) + divisor = df_f.nullif(right, df_f.cast_to_type(df_lit(0), right)) + quotient = left / divisor + if frame is not None and pa.types.is_integer( + frame.select(quotient.alias("__division_type__")).schema()[0].type + ): + quotient = left.cast(pa.float64()) / divisor + return UnifiedExpr(quotient) + + result = build() + result._datafusion_resolver = build + return result + + +class UnifiedStrExpr(_DataFusionDeferredOperations): """Platform-agnostic string expression namespace. Provides Polars-style .str accessor methods that work across platforms: @@ -323,7 +415,7 @@ def len_chars(self) -> UnifiedExpr: return UnifiedExpr(self._expr.str.len_chars()) -class UnifiedListExpr: +class UnifiedListExpr(_DataFusionDeferredOperations): """Platform-agnostic list/array expression namespace. Provides Polars-style .list accessor methods that work across platforms: @@ -585,7 +677,7 @@ def alias(self, name: str) -> UnifiedExpr: return UnifiedExpr(self._expr.alias(name)) -class UnifiedMapExpr: +class UnifiedMapExpr(_DataFusionDeferredOperations): """Platform-agnostic map expression namespace. Provides map accessor methods across platforms. @@ -639,7 +731,7 @@ def values(self) -> UnifiedExpr: raise NotImplementedError("Map operations not supported on Polars (no native Map dtype)") -class UnifiedDtExpr: +class UnifiedDtExpr(_DataFusionDeferredOperations): """Platform-agnostic datetime expression namespace. Provides Polars-style .dt accessor methods that work across platforms: @@ -714,7 +806,9 @@ def weekday(self) -> UnifiedExpr: # DataFusion dow: 0=Sunday..6=Saturday; convert to ISO return UnifiedExpr((df_f.date_part("dow", self._expr) + 6) % 7) - return UnifiedExpr(self._expr.dt.weekday()) + # Polars dt.weekday(): 1=Monday..7=Sunday; convert to ISO 0=Monday..6=Sunday + # so every backend matches the documented convention. + return UnifiedExpr(self._expr.dt.weekday() - 1) def truncate(self, every: str) -> UnifiedExpr: """Truncate datetime to given interval. @@ -774,7 +868,7 @@ def total_days(self) -> UnifiedExpr: return UnifiedExpr(self._expr.dt.total_days()) -class UnifiedStructExpr: +class UnifiedStructExpr(_DataFusionDeferredOperations): """Platform-agnostic struct field accessor. Provides .struct.field(name) access pattern across platforms. @@ -794,7 +888,7 @@ def field(self, name: str) -> UnifiedExpr: return UnifiedExpr(self._expr.struct.field(name)) -class UnifiedExpr: +class UnifiedExpr(_DataFusionDeferredOperations): """Platform-agnostic expression wrapper. Wraps PySpark Columns and DataFusion Exprs to add Polars-compatible methods like @@ -1004,30 +1098,14 @@ def __rmul__(self, other: Any) -> UnifiedExpr: return UnifiedExpr(self._unwrap(other) * self._expr) def __truediv__(self, other: Any) -> UnifiedExpr: - other_expr = self._unwrap(other) - # DataFusion: use nullif to prevent DivideByZero errors - # dividend / nullif(divisor, 0) returns NULL when divisor is 0 if self._is_datafusion: - from datafusion import functions as df_f, lit as df_lit - - if isinstance(other_expr, (int, float)): - # Literal divisor - no need for nullif if non-zero - if other_expr == 0: - return UnifiedExpr(self._expr / df_f.nullif(df_lit(other_expr), df_lit(0))) - return UnifiedExpr(self._expr / other_expr) - # Column divisor - wrap in nullif for safety - return UnifiedExpr(self._expr / df_f.nullif(other_expr, df_lit(0))) - return UnifiedExpr(self._expr / other_expr) + return _datafusion_division(self, other) + return UnifiedExpr(self._expr / self._unwrap(other)) def __rtruediv__(self, other: Any) -> UnifiedExpr: - other_expr = self._unwrap(other) - # DataFusion: use nullif to prevent DivideByZero errors if self._is_datafusion: - from datafusion import functions as df_f, lit as df_lit - - # self._expr is the divisor here - return UnifiedExpr(other_expr / df_f.nullif(self._expr, df_lit(0))) - return UnifiedExpr(other_expr / self._expr) + return _datafusion_division(other, self) + return UnifiedExpr(self._unwrap(other) / self._expr) # ========================================================================= # Comparison Operations @@ -1657,30 +1735,12 @@ def is_between(self, low: Any, high: Any) -> UnifiedExpr: # ========================================================================= def rank(self, method: str = "min", descending: bool = False) -> UnifiedExpr: - """Compute rank within partition. - - Provides unified ranking: - - Polars: Uses .rank(method=method, descending=descending) - - PySpark: Requires Window specification - returns a deferred rank expression - - DataFusion: Requires Window specification - returns a deferred rank expression - - Note: For PySpark/DataFusion, this returns a deferred expression. The actual ranking - requires calling .over() with a window specification. - - Args: - method: Ranking method: "min", "max", "dense", "ordinal", "average" - descending: Whether to rank in descending order - - Returns: - UnifiedExpr with rank values (Polars) or deferred for window (PySpark/DataFusion) - """ if self._is_pyspark: # For PySpark, we need to store the rank parameters for later use with over() # Return a wrapper that tracks the ranking need # The actual rank will be computed when over() is called return _PySparkDeferredRank(self._expr, method, descending) if self._is_datafusion: - # For DataFusion, we also need deferred ranking with over() return _DataFusionDeferredRank(self._expr, method, descending) return UnifiedExpr(self._expr.rank(method=method, descending=descending)) @@ -1732,11 +1792,8 @@ def over( return UnifiedExpr(self._expr.over(window)) - # Polars: simple over() call - if order_by: - # Polars over() with order_by requires a different approach - # For now, just use partition_by (ordering within window is less common in Polars) - return UnifiedExpr(self._expr.over(partition_cols)) + if order_by is not None: + return UnifiedExpr(self._expr.over(partition_cols, order_by=order_by)) return UnifiedExpr(self._expr.over(partition_cols)) def cum_sum(self) -> UnifiedExpr: @@ -1758,10 +1815,7 @@ def cum_sum(self) -> UnifiedExpr: # Return sum expression - caller should use with over() return UnifiedExpr(F.sum(self._expr)) if self._is_datafusion: - from datafusion import functions as df_f - - # Return sum expression - caller should use with over() - return UnifiedExpr(df_f.sum(self._expr)) + return _DataFusionDeferredCumulative(self._expr, "sum") return UnifiedExpr(self._expr.cum_sum()) def cum_max(self) -> UnifiedExpr: @@ -1783,10 +1837,7 @@ def cum_max(self) -> UnifiedExpr: # Return max expression - caller should use with over() return UnifiedExpr(F.max(self._expr)) if self._is_datafusion: - from datafusion import functions as df_f - - # Return max expression - caller should use with over() - return UnifiedExpr(df_f.max(self._expr)) + return _DataFusionDeferredCumulative(self._expr, "max") return UnifiedExpr(self._expr.cum_max()) def cum_min(self) -> UnifiedExpr: @@ -2032,6 +2083,31 @@ def over( return UnifiedExpr(rank_expr) +_DATAFUSION_RANK_METHODS = frozenset({"min", "max", "dense", "ordinal", "average"}) + + +def _datafusion_whole_frame_rank(expr: DataFusionExpr, method: str, descending: bool) -> DataFusionExpr: + from datafusion import functions as df_f, lit as df_lit + from datafusion.expr import Window + + nulls_last_window = Window(order_by=[expr.sort(ascending=not descending, nulls_first=False)]) + rows_sharing_value = df_f.count(expr).over(Window(partition_by=[expr])) + min_rank = df_f.rank().over(nulls_last_window) + if method == "min": + ranked = min_rank + elif method == "dense": + ranked = df_f.dense_rank().over(nulls_last_window) + elif method == "ordinal": + ranked = df_f.row_number().over(nulls_last_window) + elif method == "max": + ranked = min_rank + rows_sharing_value - df_lit(1) + elif method == "average": + ranked = min_rank + (rows_sharing_value - df_lit(1)) / df_lit(2.0) + else: + raise ValueError(f"Unsupported rank method: {method!r}. Expected one of {sorted(_DATAFUSION_RANK_METHODS)}.") + return df_f.when(expr.is_null(), df_lit(None)).otherwise(ranked) + + class _DataFusionDeferredRank(UnifiedExpr): """Deferred rank expression for DataFusion. @@ -2047,7 +2123,8 @@ def __init__(self, expr: DataFusionExpr, method: str, descending: bool) -> None: method: Ranking method: "min", "max", "dense", "ordinal", "average" descending: Whether to rank in descending order """ - super().__init__(expr) + super().__init__(_datafusion_whole_frame_rank(expr, method, descending)) + self._rank_source = expr self._rank_method = method self._rank_descending = descending @@ -2056,15 +2133,6 @@ def over( partition_by: str | list[str], order_by: str | None = None, ) -> UnifiedExpr: - """Apply ranking over a window partition. - - Args: - partition_by: Column(s) to partition by - order_by: Optional column to order by (ignored, uses self._expr) - - Returns: - UnifiedExpr with rank values - """ from datafusion import col as df_col, functions as df_f from datafusion.expr import Window @@ -2073,7 +2141,7 @@ def over( partition_exprs = [df_col(c) if isinstance(c, str) else c for c in partition_cols] # Build ordering based on the expression - order_expr = self._expr.sort(ascending=not self._rank_descending) + order_expr = self._rank_source.sort(ascending=not self._rank_descending, nulls_first=self._rank_descending) # Build Window specification window = Window(partition_by=partition_exprs, order_by=[order_expr]) @@ -2096,6 +2164,27 @@ def over( return UnifiedExpr(rank_func.over(window)) +class _DataFusionDeferredCumulative(UnifiedExpr): + def __init__(self, expr: DataFusionExpr, operation: str) -> None: + super().__init__(expr) + self._operation = operation + + def over(self, partition_by: str | list[str], order_by: str | None = None) -> UnifiedExpr: + from datafusion import col as df_col, functions as df_f + from datafusion.expr import Window, WindowFrame + + if order_by is None: + raise ValueError("DataFusion cumulative windows require an explicit order_by column") + partition_cols = [partition_by] if isinstance(partition_by, str) else list(partition_by) + window = Window( + partition_by=[df_col(column) for column in partition_cols], + order_by=[df_col(order_by).sort(ascending=True, nulls_first=False)], + window_frame=WindowFrame("rows", None, 0), + ) + aggregate = df_f.sum(self._expr) if self._operation == "sum" else df_f.max(self._expr) + return UnifiedExpr(aggregate.over(window)) + + class _DataFusionDeferredFilter(UnifiedExpr): """Deferred filter expression for DataFusion. @@ -2107,18 +2196,13 @@ class _DataFusionDeferredFilter(UnifiedExpr): an aggregate method (sum, count, etc.) is called. """ - def __init__(self, expr: Any, condition: Any) -> None: + def __init__(self, expr: DataFusionExpr, condition: DataFusionExpr) -> None: """Initialize the deferred filter. Args: expr: The column expression to aggregate (DataFusion `Expr` at runtime) condition: The filter condition to apply (DataFusion `Expr` at runtime) - Both params stay `Any`: the only call site (`UnifiedExpr.filter` in - the DataFusion branch) unwraps `condition.native` which itself - returns `Any`. Tightening the annotation here would mis-state the - contract - runtime values are DataFusion expressions, but the - type-checker can't see the dynamic dispatch that guarantees it. """ super().__init__(expr) self._filter_condition = condition @@ -2174,7 +2258,7 @@ def max(self) -> UnifiedExpr: return self._apply_filtered_agg(df_f.max) -class UnifiedWhenThen: +class UnifiedWhenThen(_DataFusionDeferredOperations): """Intermediate result from when().then() for chaining. This allows the Polars-style when/then/otherwise pattern: @@ -2259,7 +2343,7 @@ def otherwise(self, value: Any) -> UnifiedExpr: return UnifiedExpr(self._when_builder.otherwise(val)) -class UnifiedWhen: +class UnifiedWhen(_DataFusionDeferredOperations): """Platform-agnostic WHEN expression builder. Provides Polars-style when/then/otherwise syntax that works across platforms: @@ -2377,453 +2461,6 @@ def _is_datafusion_expr(expr: Any) -> bool: return "datafusion" in type_name -# ========================================================================= -# DataFusion Aggregate Arithmetic Helpers (EXPERIMENTAL) -# ========================================================================= -# These functions help handle DataFusion's limitation that arithmetic -# on aggregates inside aggregate() is not supported. We extract the -# arithmetic and apply it after the aggregation. -# -# WARNING: This implementation uses error message parsing to extract AST -# information, which is inherently fragile. Originally tested with -# DataFusion 43.0.0; re-validated against 53.0.0 as of the -# unified-frame-error-parsing-hardening.yaml TODO (the pinned/installed -# version has drifted well past 43.0.0, but the error format below is -# unchanged as of 53.0.0). -# -# `_get_datafusion_ast_string()` distinguishes two failure modes instead of -# silently returning None for both: -# 1. The expression's AST doesn't contain an Alias/BinaryExpr/ -# AggregateFunction node (e.g. a plain Column, Literal, or unaliased -# aggregate) - this is a genuinely different expression shape that this -# module's aggregate-arithmetic extraction doesn't apply to. Falls back -# to passing the expression through unchanged, exactly as before. -# 2. `rex_call_operator()`'s error text no longer even matches the -# "Catch all triggered in get_operator_name: " wrapper format this -# module depends on - this means the underlying error-message mechanism -# itself changed (not just the AST it happens to embed), which is the -# actual fragile-dependency risk the module docstring warns about. -# Raises DataFusionASTFormatError loudly instead of silently degrading. - - -class DataFusionASTFormatError(RuntimeError): - """DataFusion's rex_call_operator() error text no longer matches the - "Catch all triggered in get_operator_name: " format this module - depends on to extract AST information for aggregate-arithmetic support. - - This means a DataFusion release changed the underlying error-message - mechanism this module relies on (not merely the specific AST node it - happens to embed - see _DATAFUSION_AST_SANITY_KEYWORDS for that case, - which is handled separately and does not raise). Fix by re-validating - the current error format (see w0 in - unified-frame-error-parsing-hardening.yaml) and updating - _DATAFUSION_AST_ERROR_PREFIX / the AST-extraction regexes below to match. - """ - - -# The stable wrapper text DataFusion's rex_call_operator() uses to embed a -# Rust Debug-formatted AST dump in its error message, regardless of the -# wrapped expression's own node type (Column, Literal, Alias, BinaryExpr, -# AggregateFunction, ...). Confirmed present for Column/Literal/ -# AggregateFunction/Alias(BinaryExpr(...)) shapes as of DataFusion 53.0.0. -_DATAFUSION_AST_ERROR_PREFIX = "Catch all triggered in get_operator_name" - -# Node-type keywords that indicate this expression is the -# Alias(BinaryExpr(...AggregateFunction...)) shape this module's arithmetic -# extraction cares about, as opposed to some other expression shape that -# legitimately doesn't need arithmetic extraction. -_DATAFUSION_AST_SANITY_KEYWORDS = ("Alias", "BinaryExpr", "AggregateFunction") - - -def _get_datafusion_ast_string(expr: DataFusionExpr) -> str | None: - """Get the internal AST representation from a DataFusion expression. - - EXPERIMENTAL: This function relies on parsing error messages from - DataFusion's rex_call_operator() method to extract AST information. - This is fragile and may break with DataFusion version changes. - - Tested with: DataFusion 43.0.0 and 53.0.0. - Expected error message format: "Catch all triggered in get_operator_name: Alias(BinaryExpr...)" - - Args: - expr: A DataFusion expression - - Returns: - The AST string representation, or None if this expression's AST - doesn't contain an Alias/BinaryExpr/AggregateFunction node (a - genuinely different, unrelated expression shape - not an error). - - Raises: - DataFusionASTFormatError: If DataFusion raised an error but its text - no longer matches the expected catch-all AST-dump wrapper format - at all, indicating the underlying mechanism this function - depends on has changed upstream. - """ - if not hasattr(expr, "rex_call_operator"): - # Not a real DataFusion Expr at all (e.g. a plain column-name string - # mixed into the same select()/agg() call as an aggregate - # expression) - this is a type mismatch in the caller's input, not a - # DataFusion error-format question, so it is definitely not the - # Alias/BinaryExpr/AggregateFunction pattern this caller looks for. - return None - - try: - # rex_call_operator throws an error for Alias expressions, - # but the error message contains the full AST - expr.rex_call_operator() - return None - except Exception as e: - # The error message contains the AST like: - # "Catch all triggered in get_operator_name: Alias(BinaryExpr...)" - error_str = str(e) - - if _DATAFUSION_AST_ERROR_PREFIX not in error_str: - # The catch-all AST-dump wrapper itself is gone/changed - the - # mechanism this module depends on has broken upstream. This is - # the actual format-drift risk flagged in the module docstring; - # fail loudly and attributably rather than silently falling back - # to unchanged-expression behavior. - import datafusion - - installed_version = getattr(datafusion, "__version__", "unknown") - raise DataFusionASTFormatError( - "DataFusion's rex_call_operator() error format has changed and no " - f"longer contains the expected '{_DATAFUSION_AST_ERROR_PREFIX}' " - "wrapper text that benchbox's aggregate-arithmetic AST extraction " - f"depends on. Installed DataFusion version: {installed_version}. " - f"Unrecognized error text: {error_str!r}" - ) from e - - # Verify we got the expected format (basic sanity check): does this - # expression's own AST contain the Alias/BinaryExpr/AggregateFunction - # shape this caller is looking for? - if any(keyword in error_str for keyword in _DATAFUSION_AST_SANITY_KEYWORDS): - return error_str - - # The catch-all wrapper is intact, but this expression is a - # genuinely different, unrelated shape (e.g. a plain Column, Literal, - # or unaliased aggregate) - not a format break. Return None so the - # caller falls back to passing the expression through unchanged. - return None - - -def _extract_datafusion_alias_name(expr_str: str) -> str | None: - """Extract alias name from expression string representation.""" - import re - - # Pattern: name: \"avg_qty\" in the Alias structure (escaped quotes) - # The error message escapes quotes, so we need to match \"...\", not "..." - # Look for the last name: occurrence which is the alias name - matches = list(re.finditer(r'name:\s*\\"([^\\]+)\\"', expr_str)) - if matches: - # Return the last match (alias name), not intermediate names - return matches[-1].group(1) - return None - - -def _extract_datafusion_multiplier(expr_str: str) -> tuple[float | None, str | None]: - """Extract multiplier and operation from BinaryExpr Literal. - - Returns: - Tuple of (multiplier, operation) where operation is 'multiply' or 'divide' - """ - import re - - # Pattern: Literal(Float64(0.2), None) or Literal(Int64(2), None) - match = re.search(r"Literal\((Float64|Int64)\(([0-9.]+)\)", expr_str) - if match: - multiplier = float(match.group(2)) - # Determine operation type - if "op: Multiply" in expr_str: - return multiplier, "multiply" - elif "op: Divide" in expr_str: - return multiplier, "divide" - return None, None - - -def _rebuild_datafusion_pure_aggregate(expr_str: str) -> DataFusionExpr | None: - """Rebuild a pure aggregate expression from a BinaryExpr AST string. - - Given an AST string containing the aggregate pattern, extract just - the aggregate function call. - """ - import re - - from datafusion import col as df_col, functions as df_f - - # Find aggregate function name - look for UDF inner name like Avg, Sum - # Pattern: AggregateUDF { inner: Avg { ... } } - func_match = re.search(r"inner:\s*(\w+)\s*\{", expr_str) - if not func_match: - return None - func_name = func_match.group(1).lower() # Avg -> avg, Sum -> sum - - # Find column name within the aggregate - # Pattern: Column { relation: ..., name: \"l_quantity\" } (escaped quotes) - col_match = re.search(r'Column \{[^}]*name:\s*\\"([^\\]+)\\"', expr_str) - if not col_match: - return None - col_name = col_match.group(1) - - # Rebuild the pure aggregate - col_expr = df_col(col_name) - if func_name == "avg": - return df_f.avg(col_expr) - elif func_name == "sum": - return df_f.sum(col_expr) - elif func_name == "count": - return df_f.count(col_expr) - elif func_name == "min": - return df_f.min(col_expr) - elif func_name == "max": - return df_f.max(col_expr) - - return None - - -def _extract_datafusion_agg_arithmetic( - exprs: list[DataFusionExpr], -) -> tuple[list[DataFusionExpr], list[tuple]]: - """Extract arithmetic operations from DataFusion aggregate expressions. - - DataFusion doesn't support arithmetic on aggregates inside aggregate(), - e.g., (col.mean() * lit(0.2)).alias("avg_qty") fails with: - "Invalid aggregate expression 'BinaryExpr(...)'" - - This function detects such patterns and separates: - 1. The pure aggregate expressions (to run in aggregate()) - 2. The arithmetic to apply after aggregation - - Handles two patterns: - 1. aggregate * literal (e.g., sum(x) * 0.5) - 2. aggregate * aggregate (e.g., sum(x) * avg(y)) - 3. aggregate + aggregate (e.g., sum(x) + sum(y) + sum(z)) - - Args: - exprs: List of native DataFusion expressions - - Returns: - Tuple of: - - List of cleaned expressions for aggregate() - - List of post-ops (various formats for different operations) - """ - processed = [] - post_ops: list[tuple] = [] - - for expr in exprs: - # Get the internal AST representation by triggering an error message - # that contains the full Rust AST structure - ast_str = _get_datafusion_ast_string(expr) - - # Check if this is an aliased BinaryExpr with aggregate - # Pattern: Alias(BinaryExpr { left: AggregateFunction(...), op: Multiply/Divide, right: Literal(...) }, ...) - if ast_str and "BinaryExpr" in ast_str and "AggregateFunction" in ast_str: - # Try to extract the alias name - alias_name = _extract_datafusion_alias_name(ast_str) - if alias_name: - # Count how many aggregates are in this expression - agg_count = ast_str.count("AggregateFunction(") - - if agg_count >= 2: - # Multiple aggregates combined with arithmetic (sum * avg, sum + sum + sum) - multi_result = _extract_multi_agg_arithmetic(ast_str, alias_name) - if multi_result is not None: - temp_exprs, post_op = multi_result - processed.extend(temp_exprs) - post_ops.append(post_op) - continue - else: - # Single aggregate with literal multiplier (existing pattern) - value, operation = _extract_datafusion_multiplier(ast_str) - if value is not None and operation is not None: - # Extract the pure aggregate by rebuilding - pure_agg = _rebuild_datafusion_pure_aggregate(ast_str) - if pure_agg is not None: - temp_alias = f"__temp_{alias_name}__" - processed.append(pure_agg.alias(temp_alias)) - post_ops.append(("literal", temp_alias, alias_name, value, operation)) - continue - - # No transformation needed - use as-is - processed.append(expr) - - return processed, post_ops - - -_AGG_FUNCS = ("sum", "avg", "mean", "count", "min", "max") -_OP_MAP = {"Multiply": "multiply", "Plus": "add", "Divide": "divide", "Minus": "subtract"} - - -def _parse_multi_agg_pairs(ast_str: str, agg_funcs: list[str], col_matches: list[str], has_nvl: bool): - import re - - if not has_nvl: - return [(f.lower(), col_matches[i]) for i, f in enumerate(agg_funcs) if i < len(col_matches)] - - # NVL-wrapped aggregates: match (func, col) pairs inside each AggregateFunction block - agg_pattern = ( - r"AggregateFunction\s*\{[^}]*inner:\s*(\w+)[^}]*args:\s*\[ScalarFunction[^]]+name:\s*\\?\"([^\"\\]+)\\?\"" - ) - nvl_matches = re.findall(agg_pattern, ast_str, re.DOTALL) - aggregates = [(f.lower(), c) for f, c in nvl_matches if f.lower() in _AGG_FUNCS] - if aggregates: - return aggregates - # Fall back to positional pairing if the NVL-specific regex didn't match - return [(f.lower(), col_matches[i]) for i, f in enumerate(agg_funcs) if i < len(col_matches)] - - -def _pick_primary_op(ops: list[str]) -> str: - for op in ops: - if op in ("Multiply", "Plus", "Divide", "Minus"): - return op - return ops[0] - - -def _build_pure_agg_expr(func_name: str, col_name: str): - from datafusion import col as df_col, functions as df_f - - dispatch = { - "avg": df_f.avg, - "mean": df_f.avg, - "sum": df_f.sum, - "count": df_f.count, - "min": df_f.min, - "max": df_f.max, - } - builder = dispatch.get(func_name) - if builder is None: - return None - return builder(df_col(col_name)) - - -def _extract_multi_agg_arithmetic(ast_str: str, alias_name: str) -> tuple[list[Any], tuple] | None: - """Extract multiple aggregates from a BinaryExpr. - - Handles patterns like: - - sum(x) * avg(y) -> two temp columns, multiply after - - sum(a) + sum(b) + sum(c) -> three temp columns, add after - - sum(nvl(x, 0)) / sum(nvl(y, 0)) -> preserve nvl wrapper - - Returns: - Tuple of (list of temp aggregate expressions, post_op tuple) or None - """ - import re - - func_matches = re.findall(r"inner:\s*(\w+)\s*\{", ast_str) - has_nvl = "NVLFunc" in ast_str or "nvl" in ast_str.lower() - has_cast = "Cast(" in ast_str and "Float64" in ast_str - - col_matches = re.findall(r'name:\s*\\?"([^"\\]+)\\?"', ast_str) - col_matches = [c for c in col_matches if c and c[0].isalpha() and c not in ("?table?",)] - agg_funcs = [f for f in func_matches if f.lower() in _AGG_FUNCS] - - if len(agg_funcs) < 2: - return None - - aggregates = _parse_multi_agg_pairs(ast_str, agg_funcs, col_matches, has_nvl) - if len(aggregates) < 2: - return None - - ops = re.findall(r"op:\s*(\w+)", ast_str) - if not ops: - return None - primary_op = _pick_primary_op(ops) - - # IMPORTANT: DataFusion's aggregate() only accepts pure aggregate expressions. - # Any wrapping (Cast, coalesce, arithmetic) must be done AFTER aggregation. - temp_exprs = [] - temp_aliases = [] - for i, (func_name, col_name) in enumerate(aggregates): - agg_expr = _build_pure_agg_expr(func_name, col_name) - if agg_expr is None: - return None - temp_alias = f"__temp_{alias_name}_{i}__" - temp_aliases.append(temp_alias) - temp_exprs.append(agg_expr.alias(temp_alias)) - - operation = _OP_MAP.get(primary_op, "multiply") - return temp_exprs, ("multi", temp_aliases, alias_name, operation, has_nvl, has_cast) - - -def _apply_datafusion_post_ops(result: DataFusionDataFrame, post_ops: list[tuple]) -> DataFusionDataFrame: - """Apply post-aggregation arithmetic operations to a DataFusion DataFrame. - - Args: - result: DataFusion DataFrame with aggregation results - post_ops: List of post-op tuples in one of these formats: - - ("literal", temp_alias, final_alias, value, operation) for single agg * literal - - ("multi", temp_aliases, final_alias, operation) for multi agg arithmetic - - Returns: - DataFrame with arithmetic applied and columns renamed - """ - from datafusion import col as df_col - - for post_op in post_ops: - op_type = post_op[0] - if op_type == "literal": - result = _apply_literal_post_op(result, post_op, df_col) - elif op_type == "multi": - result = _apply_multi_post_op(result, post_op, df_col) - else: - raise ValueError(f"Unsupported DataFusion post-op format: {post_op}") - - return result - - -def _apply_literal_post_op(result: DataFusionDataFrame, post_op: tuple, df_col) -> DataFusionDataFrame: - """Apply ("literal", temp_alias, final_alias, value, operation) post-op.""" - _, temp_alias, final_alias, value, operation = post_op - if operation == "multiply": - result = result.with_column(final_alias, df_col(temp_alias) * value) - elif operation == "divide": - result = result.with_column(final_alias, df_col(temp_alias) / value) - if temp_alias != final_alias: - result = result.drop(temp_alias) - return result - - -def _apply_multi_post_op(result: DataFusionDataFrame, post_op: tuple, df_col) -> DataFusionDataFrame: - """Apply ("multi", temp_aliases, final_alias, operation[, has_nvl, has_cast]) post-op.""" - if len(post_op) == 6: - _, temp_aliases, final_alias, operation, has_nvl, has_cast = post_op - else: - _, temp_aliases, final_alias, operation = post_op - has_nvl = False - has_cast = False - - if len(temp_aliases) < 2: - return result - - import pyarrow as pa - from datafusion import functions as df_f, lit as df_lit - - def _prep(alias: str): - expr = df_col(alias) - if has_nvl: - expr = df_f.coalesce(expr, df_lit(0)) - if has_cast: - expr = expr.cast(pa.float64()) - return expr - - combined = _prep(temp_aliases[0]) - for temp_alias in temp_aliases[1:]: - next_col = _prep(temp_alias) - if operation == "multiply": - combined = combined * next_col - elif operation == "add": - combined = combined + next_col - elif operation == "divide": - combined = combined / df_f.nullif(next_col, df_lit(0.0)) - elif operation == "subtract": - combined = combined - next_col - - result = result.with_column(final_alias, combined) - for temp_alias in temp_aliases: - result = result.drop(temp_alias) - return result - - class UnifiedGroupBy(Generic[DF, Expr]): """Platform-agnostic GroupBy wrapper. @@ -2851,6 +2488,7 @@ def __init__( self._adapter = adapter self._source_df = source_df + @_bind_datafusion_arguments def agg(self, *exprs: Expr) -> UnifiedLazyFrame: """Aggregate the grouped data. @@ -2876,20 +2514,12 @@ def agg(self, *exprs: Expr) -> UnifiedLazyFrame: # Polars: Use group_by().agg() result = self._grouped.agg(*unwrapped) elif _is_datafusion_df(self._source_df): - # DataFusion: Use aggregate([group_cols], [agg_exprs]) - # grouped is None, we use source_df.aggregate() directly - # - # DataFusion doesn't support arithmetic on aggregates inside agg(), - # e.g., (col.mean() * lit(0.2)).alias("x") fails. - # We need to detect these patterns and handle them in post-processing: - # 1. Extract the pure aggregates - # 2. Apply arithmetic after aggregation - processed_exprs, post_ops = _extract_datafusion_agg_arithmetic(unwrapped) - result = self._source_df.aggregate(self._columns, processed_exprs) - - # Apply any post-aggregation arithmetic - if post_ops: - result = _apply_datafusion_post_ops(result, post_ops) + # DataFusion: Use aggregate([group_cols], [agg_exprs]); grouped is + # None, so call source_df.aggregate() directly. DataFusion plans + # arithmetic, CASE, casts and scalar functions over aggregates + # (e.g. sum(x) / sum(y), or a CASE whose condition and branches are + # aggregates) natively, so the expressions are passed through as-is. + result = self._source_df.aggregate(self._columns, unwrapped) else: # Fallback: try group_by().agg() pattern result = self._grouped.agg(*unwrapped) @@ -3086,6 +2716,7 @@ def _prepare_join_items(df, items: list, side: str) -> tuple[Any, list[str], lis join_cols: list[str] = [] temp_cols: list[str] = [] for i, item in enumerate(items): + item = _resolve_datafusion_value(item, df) if isinstance(item, UnifiedExpr): temp_col = f"__{side}_join_key_{i}__" df = df.with_column(temp_col, item.native) @@ -3766,6 +3397,7 @@ def _datafusion_join_with_exprs( # Grouping Operations # ========================================================================= + @_bind_datafusion_arguments def group_by(self, *columns: str | Expr | list) -> UnifiedGroupBy: """Group by one or more columns. @@ -3813,6 +3445,7 @@ def group_by(self, *columns: str | Expr | list) -> UnifiedGroupBy: # Filter/Select Operations # ========================================================================= + @_bind_datafusion_arguments def filter(self, condition: Expr) -> UnifiedLazyFrame: """Filter rows by condition. @@ -3827,6 +3460,7 @@ def filter(self, condition: Expr) -> UnifiedLazyFrame: result = self._df.filter(native_condition) return UnifiedLazyFrame(result, self._adapter) + @_bind_datafusion_arguments def select(self, *columns: str | Expr | list) -> UnifiedLazyFrame: """Select columns. @@ -3850,14 +3484,9 @@ def select(self, *columns: str | Expr | list) -> UnifiedLazyFrame: # If so, use aggregate() instead of select() for proper execution has_aggregate = self._has_aggregate_expr(unwrapped) if has_aggregate: - # DataFusion doesn't support arithmetic on aggregates inside aggregate(), - # so we need to extract and apply arithmetic post-aggregation - processed_exprs, post_ops = _extract_datafusion_agg_arithmetic(unwrapped) - # Use aggregate with empty group-by for DataFrame-level aggregation - result = self._df.aggregate([], processed_exprs) - # Apply any post-aggregation arithmetic - if post_ops: - result = _apply_datafusion_post_ops(result, post_ops) + # Use aggregate with empty group-by for DataFrame-level + # aggregation; expressions over aggregates are planned natively. + result = self._df.aggregate([], unwrapped) else: result = self._df.select(*unwrapped) else: @@ -3905,6 +3534,7 @@ def _has_aggregate_expr(self, exprs: list) -> bool: return True return False + @_bind_datafusion_arguments def with_columns(self, *exprs: Expr | list) -> UnifiedLazyFrame: """Add or replace columns. @@ -4109,6 +3739,7 @@ def distinct(self) -> UnifiedLazyFrame: # Sorting Operations # ========================================================================= + @_bind_datafusion_arguments def sort( self, by: str | list[str] | list[tuple[str, str]], diff --git a/benchbox/platforms/datafusion.py b/benchbox/platforms/datafusion.py index 9336de0a79..7bdf064133 100644 --- a/benchbox/platforms/datafusion.py +++ b/benchbox/platforms/datafusion.py @@ -298,7 +298,17 @@ def from_config(cls, config: dict[str, Any]): # Force recreate adapter_config["force_recreate"] = config.get("force", False) - # Pass through other relevant config + # Pass through other relevant config, plus the shared plan + # display/capture keys (skipping None so adapter defaults apply): + # __init__ only ever sees this rebuilt config -- nested options were + # already merged into config above, so these reads see both shapes -- + # and dropping them silently disables console plan display and + # capture/filtering even when requested. + from benchbox.platforms.base.config_utils import PLAN_FORWARD_KEYS + + for key in PLAN_FORWARD_KEYS: + if key in config and config[key] is not None: + adapter_config[key] = config[key] for key in [ "tuning_config", "tuning_enabled", @@ -761,6 +771,12 @@ def _create_empty_schema_tables(self, connection: Any, skip: set[str] | None = N # Schema-integrity validation will check that the tables exist in the catalog. return {} + def materialize_schema_only_tables(self, benchmark, connection: Any) -> dict[str, int]: + # create_schema() only records _table_schemas on DataFusion, so the + # SKIP_DATA_LOADING path must retain this materialization step or + # schema validation reports every expected table missing. + return self._create_empty_schema_tables(connection) + def load_data( self, benchmark, connection: Any, data_dir: Path ) -> tuple[dict[str, int], float, dict[str, Any] | None]: diff --git a/benchbox/platforms/doris.py b/benchbox/platforms/doris.py index b4c4d792aa..1f2dce0db8 100644 --- a/benchbox/platforms/doris.py +++ b/benchbox/platforms/doris.py @@ -989,7 +989,8 @@ def execute_query( Kept outside the execution path so a strict_plan_capture PlanCaptureError propagates rather than being mislabeled as a failed query. """ - result = super().execute_query( + return self.execute_query_with_plan_capture( + super().execute_query, connection, query, query_id, @@ -998,8 +999,6 @@ def execute_query( validate_row_count=validate_row_count, stream_id=stream_id, ) - self._merge_plan_capture_into_result(result, connection, query, query_id) - return result def get_platform_info(self, connection: Any = None) -> dict[str, Any]: """Get Doris platform information.""" diff --git a/benchbox/platforms/duckdb.py b/benchbox/platforms/duckdb.py index cb4de457c1..fa34cefe8e 100644 --- a/benchbox/platforms/duckdb.py +++ b/benchbox/platforms/duckdb.py @@ -608,10 +608,25 @@ def from_config(cls, config: dict[str, Any]): # `__init__` and applied as a DuckDB `SET threads` statement; omitting it # here silently discards the caller's request, because `__init__` only # ever sees this rebuilt config, never the original. + # Plan flags ride along too: `show_query_plans` (CLI --show-plans) + # controls console plan display, while the capture_* keys control + # result-bundle plan capture. Dropping them here silently disables + # both even when the caller requested them. Shared PLAN_FORWARD_KEYS + # (skipping None so adapter defaults, including int-coerced timeouts, + # apply) instead of a bespoke list. for key in [ "thread_limit", "max_temp_directory_size", "progress_bar", + ]: + if key in config: + adapter_config[key] = config[key] + from benchbox.platforms.base.config_utils import PLAN_FORWARD_KEYS + + for key in PLAN_FORWARD_KEYS: + if key in config and config[key] is not None: + adapter_config[key] = config[key] + for key in [ "tuning_config", "tuning_enabled", "unified_tuning_configuration", diff --git a/benchbox/platforms/ducklake.py b/benchbox/platforms/ducklake.py index 20b4084c3b..858cf823e5 100644 --- a/benchbox/platforms/ducklake.py +++ b/benchbox/platforms/ducklake.py @@ -11,8 +11,9 @@ Catalog metadata backend is selected via the ``catalog`` platform option (``duckdb`` default, ``sqlite``, or ``postgres``); DATA_PATH may be a local -directory or an ``s3://`` URI. See ``create_connection()`` for the per-backend -ATTACH string construction and the S3 secret setup. +directory or a cloud URI (``s3://``, ``gs://``/``gcs://``, +``az://``/``azure://``/``abfss://``). See ``create_connection()`` for the +per-backend ATTACH string construction and the cloud secret setup. For the ``postgres`` catalog backend, the target PostgreSQL DATABASE must already exist - this adapter does not run ``CREATE DATABASE``. @@ -50,13 +51,26 @@ # Stable, idempotent secret name so reconnects/CREATE OR REPLACE don't # accumulate anonymous secrets. _S3_SECRET_NAME = "benchbox_ducklake_s3" +_GCS_SECRET_NAME = "benchbox_ducklake_gcs" +_AZURE_SECRET_NAME = "benchbox_ducklake_azure" _RUN_IDENTITY_TABLE = "__benchbox_run_identity" # Connection credentials that must never reach exported result metadata. # get_platform_info() already omits these, but the normalized metadata is also # built from the unfiltered constructor config, where only pg_password happens # to match the shared secret-key matcher - pg_user does not. -_CREDENTIAL_CONFIG_KEYS = frozenset({"pg_user", "pg_password", "s3_key_id", "s3_secret"}) +_CREDENTIAL_CONFIG_KEYS = frozenset( + { + "pg_user", + "pg_password", + "s3_key_id", + "s3_secret", + "gcs_key_id", + "gcs_secret", + "azure_connection_string", + "azure_account_name", + } +) _REDACTED = "****" @@ -71,6 +85,12 @@ # Matches the `SECRET ''` clause of CREATE SECRET; the secret NAME is an # unquoted identifier, so only the quoted key material is caught here. _S3_SECRET_CLAUSE_RE = re.compile(r"(\bSECRET\s+)'(?:[^']|'')*'", flags=re.IGNORECASE) +# Same backstop for the other secret key clauses this adapter emits: +# `KEY_ID '...'` (S3/GCS explicit keys), `CONNECTION_STRING '...'` (Azure +# connection string), and `ACCOUNT_NAME '...'` (Azure credential_chain). +_SECRET_KEY_CLAUSE_RE = re.compile( + r"(\b(?:KEY_ID|CONNECTION_STRING|ACCOUNT_NAME)\s+)'(?:[^']|'')*'", flags=re.IGNORECASE +) def _redact_secrets(message: str, *secrets: str | None) -> str: @@ -81,7 +101,8 @@ def _redact_secrets(message: str, *secrets: str | None) -> str: 1. Exact replacement of every secret value we hold, in each encoding this adapter can emit it in - raw, libpq-quoted, and libpq-quoted-then-SQL- escaped (the form that reaches DuckDB inside the ATTACH literal). - 2. Pattern redaction of `password=...` / `SECRET '...'` for anything the + 2. Pattern redaction of `password=...` / `SECRET '...'` / `KEY_ID '...'` + / `CONNECTION_STRING '...'` / `ACCOUNT_NAME '...'` for anything the driver re-encoded on its way back out. """ redacted: str = message @@ -97,7 +118,8 @@ def _redact_secrets(message: str, *secrets: str | None) -> str: for encoding in longest_first: redacted = redacted.replace(encoding, _REDACTED) redacted = _PASSWORD_COMPONENT_RE.sub(rf"\1{_REDACTED}", redacted) - return _S3_SECRET_CLAUSE_RE.sub(rf"\1{_REDACTED}", redacted) + redacted = _S3_SECRET_CLAUSE_RE.sub(rf"\1{_REDACTED}", redacted) + return _SECRET_KEY_CLAUSE_RE.sub(rf"\1{_REDACTED}", redacted) # Deployment modes are a naming of the two independent axes (catalog backend x @@ -143,7 +165,7 @@ def _resolve_catalog_with_deployment_mode(deployment_mode: Any, catalog: Any) -> def _warn_if_deployment_mode_contradicts_storage(deployment_mode: Any, data_path: Any) -> None: """Warn when *deployment_mode* names a storage axis the data_path does not match. - Unlike the catalog axis this cannot be defaulted: an ``s3://`` mode needs a + Unlike the catalog axis this cannot be defaulted: a cloud mode needs a bucket, and inventing one would be worse than saying nothing. So the mismatch is reported and the supplied ``data_path`` is used as-is. """ @@ -155,7 +177,7 @@ def _warn_if_deployment_mode_contradicts_storage(deployment_mode: Any, data_path return actual_is_cloud = is_cloud_path(str(data_path)) if actual_is_cloud is not mode_is_cloud: - expected = "a cloud (s3://) data_path" if mode_is_cloud else "a local data_path" + expected = "a cloud data_path" if mode_is_cloud else "a local data_path" logger.warning( "DuckLake deployment mode %r implies %s, but data_path=%s was given; using it as-is. " "Pass a matching --platform-option data_path to run the deployment you selected.", @@ -317,7 +339,7 @@ def add_cli_arguments(parser) -> None: "--ducklake-data-path", dest="ducklake_data_path", type=str, - help="Path to the DuckLake Parquet data directory (local path or s3:// URI)", + help="Path to the DuckLake Parquet data directory (local path or cloud URI: s3://, gs://, az://)", ) ducklake_group.add_argument( "--ducklake-catalog", @@ -327,7 +349,8 @@ def add_cli_arguments(parser) -> None: help="DuckLake catalog backend: duckdb, sqlite, or postgres (default: duckdb). " "The --platform-option catalog=... form is the primary interface; PostgreSQL " "catalog connection params (pg_host/pg_port/pg_database/pg_user/pg_password) and " - "S3 credentials (s3_key_id/s3_secret/s3_region) are only available via " + "cloud credentials (s3_key_id/s3_secret/s3_region, gcs_key_id/gcs_secret, " + "azure_connection_string/azure_account_name) are only available via " "--platform-option.", ) except argparse.ArgumentError as exc: @@ -472,7 +495,15 @@ def _resolve_option(key: str, default: Any = None) -> Any: if value is not None: adapter_config[key] = value - for key in ("s3_key_id", "s3_secret", "s3_region"): + for key in ( + "s3_key_id", + "s3_secret", + "s3_region", + "gcs_key_id", + "gcs_secret", + "azure_connection_string", + "azure_account_name", + ): value = _resolve_option(key) if value is not None: adapter_config[key] = value @@ -564,6 +595,18 @@ def __init__(self, **config): self.s3_secret = config.get("s3_secret") self.s3_region = config.get("s3_region") + # GCS DATA_PATH credentials: HMAC key pair for gs:// URIs. Unlike S3 + # there is no ambient credential chain in DuckDB's gcs secret type, + # so both are required before a GCS secret is created. + self.gcs_key_id = config.get("gcs_key_id") + self.gcs_secret = config.get("gcs_secret") + + # Azure DATA_PATH credentials for az://, azure://, and abfss:// URIs: + # either a full connection string, or an account name for ambient + # credential_chain auth. A connection string wins when both are set. + self.azure_connection_string = config.get("azure_connection_string") + self.azure_account_name = config.get("azure_account_name") + @staticmethod def _validate_catalog(catalog: Any) -> str: """Validate and normalize the ``catalog`` platform option. @@ -642,6 +685,72 @@ def _build_s3_secret_sql(self) -> str: return f"CREATE OR REPLACE SECRET {_S3_SECRET_NAME} (TYPE s3, {', '.join(parts)})" return f"CREATE OR REPLACE SECRET {_S3_SECRET_NAME} (TYPE s3, PROVIDER credential_chain)" + def _build_gcs_secret_sql(self) -> str: + """Build the ``CREATE OR REPLACE SECRET`` statement for GCS DATA_PATH access. + + DuckDB reads GCS through the S3 API using HMAC interoperability keys, + so both ``gcs_key_id`` and ``gcs_secret`` are required: unlike S3 + there is no ambient credential chain for the gcs secret type, and a + half-formed secret would fail confusingly at ATTACH time instead of + here with an actionable message. ``CREATE OR REPLACE`` keeps this + idempotent across reconnects. + """ + if not (self.gcs_key_id and self.gcs_secret): + raise ValueError( + "DuckLake GCS DATA_PATH requires HMAC credentials: pass both " + "--platform-option gcs_key_id= and " + "--platform-option gcs_secret= (Google Cloud " + "Storage interoperability keys, not service-account keys)." + ) + return ( + f"CREATE OR REPLACE SECRET {_GCS_SECRET_NAME} (TYPE gcs, " + f"KEY_ID '{escape_sql_string_literal(self.gcs_key_id)}', " + f"SECRET '{escape_sql_string_literal(self.gcs_secret)}')" + ) + + def _build_azure_secret_sql(self) -> str: + """Build the ``CREATE OR REPLACE SECRET`` statement for Azure DATA_PATH access. + + A connection string is self-contained and wins when both forms are + set; otherwise an account name drives ambient ``credential_chain`` + auth (Azure CLI, managed identity, environment). With neither, fail + here with an actionable message instead of at ATTACH time. + ``CREATE OR REPLACE`` keeps this idempotent across reconnects. + """ + if self.azure_connection_string: + return ( + f"CREATE OR REPLACE SECRET {_AZURE_SECRET_NAME} (TYPE azure, " + f"CONNECTION_STRING '{escape_sql_string_literal(self.azure_connection_string)}')" + ) + if self.azure_account_name: + return ( + f"CREATE OR REPLACE SECRET {_AZURE_SECRET_NAME} (TYPE azure, " + f"PROVIDER credential_chain, " + f"ACCOUNT_NAME '{escape_sql_string_literal(self.azure_account_name)}')" + ) + raise ValueError( + "DuckLake Azure DATA_PATH requires credentials: pass either " + "--platform-option azure_connection_string= or " + "--platform-option azure_account_name= (ambient credential_chain auth)." + ) + + def _build_cloud_secret_sql(self) -> tuple[str, str]: + """Build the secret setup for this DATA_PATH's provider family. + + Returns the (extension, secret_sql) pair: S3 and GCS ride on + ``httpfs`` with their own secret types; Azure needs the ``azure`` + extension. Dispatches on the provider family so a new alias spelling + cannot be recognised by the classifier yet miss its secret. + """ + from benchbox.utils.cloud_storage import cloud_provider_family + + family = cloud_provider_family(str(self.data_path)) + if family == "gcp": + return "httpfs", self._build_gcs_secret_sql() + if family == "azure": + return "azure", self._build_azure_secret_sql() + return "httpfs", self._build_s3_secret_sql() + def get_target_dialect(self) -> str: """Get the SQL dialect for query translation. @@ -1004,9 +1113,7 @@ def create_connection(self, **connection_config) -> Any: if data_path_is_cloud: cloud_info = get_cloud_path_info(str(self.data_path)) self.log_verbose( - f"DuckLake DATA_PATH is a cloud path: {cloud_info['provider']} bucket " - f"'{cloud_info['bucket']}' (credential_chain provider unless s3_key_id/" - f"s3_secret were supplied)" + f"DuckLake DATA_PATH is a cloud path: {cloud_info['provider']} bucket '{cloud_info['bucket']}'" ) escaped_data_path = escape_sql_string_literal(str(self.data_path)) @@ -1029,31 +1136,38 @@ def create_connection(self, **connection_config) -> Any: "CREATE DATABASE)" ) - # Cloud DATA_PATH: httpfs + an S3 secret, created BEFORE ATTACH so - # the ATTACH's initial catalog/DATA_PATH validation can already - # reach the bucket. Local runs never touch this branch, so no S3 - # credentials are required for the default/local path. + # Cloud DATA_PATH: storage extension + a provider secret, created + # BEFORE ATTACH so the ATTACH's initial catalog/DATA_PATH + # validation can already reach the bucket. Local runs never touch + # this branch, so no credentials are required for the + # default/local path. if data_path_is_cloud: - setup_conn.execute("INSTALL httpfs") - setup_conn.execute("LOAD httpfs") + storage_extension, secret_sql = self._build_cloud_secret_sql() + setup_conn.execute(f"INSTALL {storage_extension}") + setup_conn.execute(f"LOAD {storage_extension}") try: - setup_conn.execute(self._build_s3_secret_sql()) + setup_conn.execute(secret_sql) except Exception as secret_exc: # Redact: never let explicit key/secret material reach an # exception message or log line. - using_explicit_creds = bool(self.s3_key_id and self.s3_secret) + using_explicit_creds = bool( + (self.s3_key_id and self.s3_secret) + or (self.gcs_key_id and self.gcs_secret) + or self.azure_connection_string + or self.azure_account_name + ) raise RuntimeError( - "Failed to create the DuckDB S3 secret for DuckLake DATA_PATH " - f"(provider={'explicit key/secret' if using_explicit_creds else 'credential_chain'}). " + "Failed to create the DuckDB cloud secret for DuckLake DATA_PATH " + f"(provider={'explicit key/secret' if using_explicit_creds else 'ambient credentials'}). " f"Underlying error type: {type(secret_exc).__name__}." + ( "" if using_explicit_creds - # credential_chain mode holds no explicit key material, + # Ambient-credential mode holds no explicit key material, # but the error text still passes through the redactor - # so ambient credentials echoed by httpfs cannot ride + # so ambient credentials echoed by the extension cannot ride # out on this branch either. - else f" Underlying error: {_redact_secrets(str(secret_exc))}" + else f" Underlying error: {_redact_secrets(str(secret_exc), self.azure_account_name)}" ) ) from None @@ -1082,8 +1196,25 @@ def create_connection(self, **connection_config) -> Any: # so it is suppressed whenever this adapter holds secret material; # runs without credentials (duckdb/sqlite catalogs, credential_chain # S3) keep the full chain for debuggability. - holds_secret_material = bool(self.pg_password or self.s3_secret or self.s3_key_id) - underlying = _redact_secrets(str(e), self.pg_password, self.s3_secret, self.s3_key_id) + holds_secret_material = bool( + self.pg_password + or self.s3_secret + or self.s3_key_id + or self.gcs_secret + or self.gcs_key_id + or self.azure_connection_string + or self.azure_account_name + ) + underlying = _redact_secrets( + str(e), + self.pg_password, + self.s3_secret, + self.s3_key_id, + self.gcs_secret, + self.gcs_key_id, + self.azure_connection_string, + self.azure_account_name, + ) raise RuntimeError( "Failed to initialize the DuckLake catalog (INSTALL/LOAD/ATTACH " f"'ducklake' extension, catalog={self.catalog}). DuckLake requires " diff --git a/benchbox/platforms/firebolt.py b/benchbox/platforms/firebolt.py index 1ff278ea11..81bc1029b3 100644 --- a/benchbox/platforms/firebolt.py +++ b/benchbox/platforms/firebolt.py @@ -1332,7 +1332,8 @@ def execute_query( override delegates to it and then merges plan fields into the result (a no-op unless ``capture_plans`` is on and the query succeeded). """ - result = super().execute_query( + return self.execute_query_with_plan_capture( + super().execute_query, connection, query, query_id, @@ -1341,8 +1342,6 @@ def execute_query( validate_row_count=validate_row_count, stream_id=stream_id, ) - self._merge_plan_capture_into_result(result, connection, query, query_id) - return result def close_connection(self, connection: Any) -> None: """Close Firebolt connection.""" diff --git a/benchbox/platforms/lakesail.py b/benchbox/platforms/lakesail.py index a053c95951..e597af94c4 100644 --- a/benchbox/platforms/lakesail.py +++ b/benchbox/platforms/lakesail.py @@ -527,7 +527,14 @@ def configure_for_benchmark(self, connection: Any, benchmark_type: str) -> None: """Apply Sail-specific optimizations based on benchmark type.""" self.apply_olap_runtime_conf(connection, benchmark_type, "LakeSail Sail") - def _get_dialect_queries(self, benchmark: Any, benchmark_slug: str, connection: Any | None = None) -> dict: + def _get_dialect_queries( + self, + benchmark: Any, + benchmark_slug: str, + connection: Any | None = None, + *, + strict_translation: bool = False, + ) -> dict: """Use LakeSail-specific query rules where Spark syntax compatibility diverges.""" if benchmark_slug == "vector_search" and hasattr(benchmark, "get_queries"): try: @@ -537,7 +544,12 @@ def _get_dialect_queries(self, benchmark: Any, benchmark_slug: str, connection: ) except TypeError: return benchmark.get_queries(dialect="lakesail") - return super()._get_dialect_queries(benchmark, benchmark_slug, connection) + return super()._get_dialect_queries( + benchmark, + benchmark_slug, + connection, + strict_translation=strict_translation, + ) def get_query_plan(self, connection: Any, query: str) -> str | None: """Get query execution plan from Sail server.""" diff --git a/benchbox/platforms/motherduck.py b/benchbox/platforms/motherduck.py index ee892b195e..6a006cac7b 100644 --- a/benchbox/platforms/motherduck.py +++ b/benchbox/platforms/motherduck.py @@ -167,7 +167,15 @@ def from_config(cls, config: dict[str, Any]): if config.get("memory_limit"): adapter_config["memory_limit"] = config["memory_limit"] - # Pass through tuning provenance/config + # Pass through tuning provenance/config plus the shared plan + # display/capture keys (skipping None so adapter defaults apply): + # __init__ only ever sees this rebuilt config, so dropping them + # silently disables console plan display and capture/filtering. + from benchbox.platforms.base.config_utils import PLAN_FORWARD_KEYS + + for key in PLAN_FORWARD_KEYS: + if key in config and config[key] is not None: + adapter_config[key] = config[key] for key in [ "tuning_config", "tuning_enabled", diff --git a/benchbox/platforms/paradedb.py b/benchbox/platforms/paradedb.py new file mode 100644 index 0000000000..cf397e8d25 --- /dev/null +++ b/benchbox/platforms/paradedb.py @@ -0,0 +1,171 @@ +"""ParadeDB (pg_analytics) platform adapter for BenchBox benchmarking. + +Extends PostgreSQL adapter with ParadeDB-specific functionality: +- pg_analytics extension verification (Elasticsearch-compatible BM25 search + plus analytics over PostgreSQL heap tables) + +ParadeDB is a PostgreSQL extension for hybrid search and analytics workloads. +Benchmark tables stay ordinary heap tables loaded through the inherited COPY +path; no storage promotion or session GUCs are required. + +Copyright 2026 Joe Harris / BenchBox Project + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from .base.config_utils import ( + POSTGRES_FAMILY_BASE_OPTIONS, + POSTGRES_FAMILY_PLATFORM_FIELDS, + make_platform_config_builder, +) +from .postgresql import ( + POSTGRES_DIALECT, + PostgreSQLAdapter, + _build_postgres_connection_kwargs, + ensure_postgres_extension, +) + +logger = logging.getLogger(__name__) + +try: + import psycopg +except ImportError: + psycopg = None + +PARADEDB_EXTENSION = "pg_analytics" + + +class ParadeDBAdapter(PostgreSQLAdapter): + """ParadeDB platform adapter with pg_analytics extension verification. + + Extends PostgreSQLAdapter with ParadeDB-specific features: + - pg_analytics extension presence check with create-or-install guidance + - Version reporting in platform info for result provenance + + Requires PostgreSQL 14+ with pg_analytics installed on the server. + """ + + plan_capture_phase_eligible = True + + @property + def platform_name(self) -> str: + return "paradedb" + + def get_target_dialect(self) -> str: + """Return the target SQL dialect for ParadeDB (PostgreSQL-compatible).""" + return POSTGRES_DIALECT + + @staticmethod + def add_cli_arguments(parser) -> None: + """Add ParadeDB-specific CLI arguments.""" + if not hasattr(parser, "add_argument"): + return + try: + # Inherit PostgreSQL connection arguments + parser.add_argument( + "--paradedb-host", + dest="host", + default="localhost", + help="PostgreSQL server hostname (with pg_analytics installed)", + ) + parser.add_argument( + "--paradedb-port", + dest="port", + type=int, + default=5432, + help="PostgreSQL server port", + ) + parser.add_argument( + "--paradedb-database", + dest="database", + help="PostgreSQL database name (auto-generated if not specified)", + ) + parser.add_argument( + "--paradedb-username", + dest="username", + default="postgres", + help="PostgreSQL username", + ) + parser.add_argument( + "--paradedb-password", + dest="password", + help="PostgreSQL password", + ) + parser.add_argument( + "--paradedb-schema", + dest="schema", + default="public", + help="PostgreSQL schema name", + ) + except Exception: + pass + + @classmethod + def from_config(cls, config: dict[str, Any]) -> ParadeDBAdapter: + """Create ParadeDB adapter from unified configuration.""" + adapter_config = _build_postgres_connection_kwargs(config) + return cls(**adapter_config) + + def __init__(self, **config): + super().__init__(**config) + + def create_connection(self, **connection_config) -> Any: + """Create PostgreSQL connection and verify the pg_analytics extension.""" + conn = super().create_connection(**connection_config) + ensure_postgres_extension(conn, self.logger, PARADEDB_EXTENSION, "https://github.com/paradedb/paradedb") + return conn + + def _apply_stream_session_state(self, connection: Any) -> None: + """Delegate per-stream session state to the PostgreSQL parent path. + + ``create_connection`` adds only extension verification/creation above + the parent implementation - one-time database setup that must NOT be + repeated per stream - and no additional session-scoped GUCs, so there + is no ParadeDB-specific state to reapply. The explicit delegation + (rather than an inherited silent no-op) records that equivalence was + checked for this subclass. + """ + super()._apply_stream_session_state(connection) + + def get_platform_info(self, connection: Any = None) -> dict[str, Any]: + """Get ParadeDB platform information.""" + platform_info = super().get_platform_info(connection) + + # Override platform type and name + platform_info["platform_type"] = "paradedb" + platform_info["platform_name"] = "paradedb" + + if connection: + try: + cursor = connection.cursor() + + # Get pg_analytics version + cursor.execute(f"SELECT extversion FROM pg_extension WHERE extname = '{PARADEDB_EXTENSION}'") + result = cursor.fetchone() + if result: + platform_info["paradedb_version"] = result[0] + + cursor.close() + except Exception as e: + self.logger.debug(f"Error getting pg_analytics info: {e}") + + return platform_info + + _supported_tuning_type_names = ("PARTITIONING", "CLUSTERING", "PRIMARY_KEYS", "FOREIGN_KEYS") + + +_build_paradedb_config = make_platform_config_builder( + "paradedb", + __name__, + "paradedb", + "psycopg", + POSTGRES_FAMILY_PLATFORM_FIELDS, + base_options={ + **POSTGRES_FAMILY_BASE_OPTIONS, + }, +) diff --git a/benchbox/platforms/postgresql.py b/benchbox/platforms/postgresql.py index cc79a33ecf..408a2d8778 100644 --- a/benchbox/platforms/postgresql.py +++ b/benchbox/platforms/postgresql.py @@ -82,15 +82,19 @@ def close(self) -> None: self.closed = True -def _write_parquet_to_copy(data_file: Path, copy: Any) -> None: - """Stream a Parquet file as CSV directly into PostgreSQL COPY.""" +def _write_parquet_to_copy(data_file: Path, copy: Any, *, include_header: bool = True) -> None: + """Stream a Parquet file as CSV directly into PostgreSQL COPY. + + ``include_header`` is False when the file continues an already-open + multi-file COPY session whose header row was written by the first file. + """ try: import pyarrow.csv as arrow_csv import pyarrow.parquet as pq except ImportError as exc: # pragma: no cover - pyarrow is a project dependency raise RuntimeError("pyarrow is required to load Parquet files into PostgreSQL-family adapters") from exc - write_options = arrow_csv.WriteOptions(include_header=True, quoting_style="all_valid") + write_options = arrow_csv.WriteOptions(include_header=include_header, quoting_style="all_valid") parquet_file = pq.ParquetFile(data_file) with arrow_csv.CSVWriter(_PostgresCopySink(copy), parquet_file.schema_arrow, write_options=write_options) as writer: for batch in parquet_file.iter_batches(): @@ -259,6 +263,68 @@ def _build_postgres_connection_kwargs(config: dict[str, Any], *, default_port: i return result +def ensure_postgres_extension( + conn: Any, + logger: Any, + extension: str, + install_url: str, + *, + cascade: bool = False, +) -> str: + """Verify a PostgreSQL extension is installed, creating it when absent. + + Shared verify-or-create dance for PG-extension adapters (ParadeDB, + Citus): check ``pg_extension``, attempt ``CREATE EXTENSION IF NOT + EXISTS``, then re-check. Raises ``RuntimeError`` with install guidance + when the extension is still missing, so every adapter reports the same + actionable error instead of drifting copies. + + Args: + conn: Open database connection. + logger: Adapter logger for version reporting. + extension: Extension name as registered in ``pg_extension``. + install_url: Project URL for the install guidance in the error. + cascade: Pass ``CASCADE`` to CREATE EXTENSION (needed by citus). + + Returns: + Installed extension version string. + """ + cursor = conn.cursor() + try: + cursor.execute(f"SELECT extversion FROM pg_extension WHERE extname = '{extension}'") + result = cursor.fetchone() + if result: + logger.info(f"{extension} extension version: {result[0]}") + conn.commit() + return str(result[0]) + + logger.info(f"{extension} extension not found, attempting to create...") + cascade_sql = " CASCADE" if cascade else "" + cursor.execute(f"CREATE EXTENSION IF NOT EXISTS {extension}{cascade_sql}") + conn.commit() + cursor.execute(f"SELECT extversion FROM pg_extension WHERE extname = '{extension}'") + result = cursor.fetchone() + if result: + logger.info(f"Created {extension} extension version: {result[0]}") + conn.commit() + return str(result[0]) + + raise RuntimeError( + f"{extension} extension is not available on this PostgreSQL server. " + f"Install it ({install_url}) or use the 'postgresql' platform instead." + ) + except RuntimeError: + cursor.close() + raise + except Exception as e: + logger.error(f"Failed to configure {extension} extension: {e}") + cursor.close() + raise RuntimeError(f"{extension} configuration failed: {e}") from e + finally: + if not cursor.closed: + cursor.close() + + class PostgreSQLAdapter(PsycopgConnectionMixin, PlatformAdapter): """PostgreSQL platform adapter with COPY-based data loading. @@ -684,6 +750,56 @@ def _transform_create_statement(self, stmt: str) -> str: """ return stmt + @staticmethod + def _group_files_into_copy_sessions( + data_source: Any, + table_name: str, + data_files: list[Path], + benchmark: Any, + ) -> list[tuple[CsvDialect, bool, list[tuple[Path, bool]]]]: + """Partition one table's files into single-session COPY groups. + + ``COPY ... FROM STDIN`` accepts a continuous byte stream, so every + file sharing a dialect can stream through one COPY session instead of + paying per-file statement setup. Files group by (parquet-vs-text, + delimiter, null marker, header, quote, force_csv); each group yields + its dialect, force_csv flag, and (file, strip_trailing_delim) pairs + in original order. Header rows after the first file in a group are + skipped by the streamer so concatenated headers never become data. + """ + groups: dict[tuple[Any, ...], tuple[CsvDialect, bool, list[tuple[Path, bool]]]] = {} + order: list[tuple[Any, ...]] = [] + for data_file in data_files: + extension = get_data_extension(data_file) + if extension == ".parquet": + dialect = CsvDialect( + delimiter=",", + has_header=True, + null_marker="", + normalize_booleans=False, + quote='"', + ) + key: tuple[Any, ...] = ("parquet",) + force_csv = True + strip_trailing = False + else: + dialect = resolve_csv_dialect(data_source, table_name, data_file, benchmark) + force_csv = extension == ".csv" and dialect.has_header + key = ( + "text", + dialect.delimiter, + dialect.null_marker, + dialect.has_header, + dialect.quote, + force_csv, + ) + strip_trailing = extension == ".tbl" + if key not in groups: + groups[key] = (dialect, force_csv, []) + order.append(key) + groups[key][2].append((data_file, strip_trailing)) + return [groups[key] for key in order] + def load_data( self, benchmark, @@ -731,38 +847,42 @@ def load_data( ) load_failed = False - for data_file in data_files: - extension = get_data_extension(data_file) - if extension == ".parquet": - dialect = CsvDialect( - delimiter=",", - has_header=True, - null_marker="", - normalize_booleans=False, - quote='"', - ) - force_csv = True - else: - dialect = resolve_csv_dialect(data_source, table_name, data_file, benchmark) - force_csv = extension == ".csv" and dialect.has_header - + sessions = self._group_files_into_copy_sessions(data_source, table_name, data_files, benchmark) + for dialect, force_csv, session_files in sessions: + is_parquet = bool(session_files) and get_data_extension(session_files[0][0]) == ".parquet" try: copy_sql = _postgres_copy_sql(qualified_table, dialect, force_csv=force_csv) with cursor.copy(copy_sql) as copy: - if extension == ".parquet": - _write_parquet_to_copy(data_file, copy) - else: - with prepare_local_load_file( - data_file, - dialect=dialect, - strip_trailing_delim=extension == ".tbl", - ) as load_path: - with open(load_path, encoding="utf-8") as f: - while chunk := f.read(65536): - copy.write(chunk) + last_file_index = len(session_files) - 1 + for index, (data_file, strip_trailing) in enumerate(session_files): + skip_header = dialect.has_header and index > 0 + if is_parquet: + _write_parquet_to_copy(data_file, copy, include_header=not skip_header) + else: + with prepare_local_load_file( + data_file, + dialect=dialect, + strip_trailing_delim=strip_trailing, + ) as load_path: + with open(load_path, encoding="utf-8") as f: + if skip_header: + f.readline() + wrote_any = False + ends_with_newline = True + while chunk := f.read(65536): + copy.write(chunk) + wrote_any = True + ends_with_newline = chunk.endswith("\n") + if wrote_any and index < last_file_index and not ends_with_newline: + # A chunk without a trailing newline would otherwise + # merge its last record with the next file's first + # row in the continuous COPY stream; EOF only acts + # as a record boundary at the end of the session. + copy.write("\n") connection.commit() except Exception as e: - self.logger.error(f"Failed to load {table_name_lower} chunk {data_file.name}: {e}") + failed_names = ", ".join(data_file.name for data_file, _ in session_files) + self.logger.error(f"Failed to load {table_name_lower} chunk {failed_names}: {e}") connection.rollback() load_failed = True break @@ -912,7 +1032,8 @@ def execute_query( stream_id: int | None = None, ) -> dict[str, Any]: """Execute a query and optionally capture the structured query plan.""" - result = super().execute_query( + return self.execute_query_with_plan_capture( + super().execute_query, connection=connection, query=query, query_id=query_id, @@ -921,8 +1042,6 @@ def execute_query( validate_row_count=validate_row_count, stream_id=stream_id, ) - self._merge_plan_capture_into_result(result, connection, query, query_id) - return result def analyze_table(self, connection: Any, table_name: str) -> None: """Run ANALYZE on a table to update statistics. diff --git a/benchbox/platforms/presto_trino_adapter_base.py b/benchbox/platforms/presto_trino_adapter_base.py index ffe312b901..cfadb3b091 100644 --- a/benchbox/platforms/presto_trino_adapter_base.py +++ b/benchbox/platforms/presto_trino_adapter_base.py @@ -513,8 +513,12 @@ def _load_table_data( file_paths: Any, target_catalog: str, target_schema: str, + benchmark: Any | None = None, + data_source: Any | None = None, ) -> tuple[int, int] | None: """Load one table and return (rows_loaded, valid_file_count).""" + from benchbox.platforms.base.data_loading import resolve_csv_dialect + valid_files = normalize_existing_files(file_paths) table_name_lower = table_name.lower() @@ -529,10 +533,18 @@ def _load_table_data( chunk_info = f" from {len(valid_files)} file(s)" if len(valid_files) > 1 else "" self.log_verbose(f"Loading data for table: {table_name}{chunk_info}") + # Resolve the CSV dialect through the shared pipeline (manifest + # metadata, then benchmark attributes, then extension fallback) so a + # converted dataset whose suffix contradicts its declared dialect + # still loads with the right delimiter. + dialect = None + if data_source is not None and benchmark is not None: + dialect = resolve_csv_dialect(data_source, table_name, valid_files[0], benchmark).delimiter + qualified_table = f"{target_catalog}.{target_schema}.{table_name_lower}" rows_loaded = 0 for file_path in valid_files: - rows_loaded += load_file_batches(cursor, file_path, qualified_table) + rows_loaded += load_file_batches(cursor, file_path, qualified_table, delimiter=dialect) return rows_loaded, len(valid_files) @@ -550,10 +562,19 @@ def load_data( raise ValueError(f"Invalid catalog or schema for load: {target_catalog}.{target_schema}") try: - for table_name, file_paths in self._resolve_data_files(benchmark, data_dir).items(): + data_source = resolve_adapter_data_source(self, benchmark, data_dir) + for table_name, file_paths in data_source.tables.items(): try: load_start = mono_time() - table_result = self._load_table_data(cursor, table_name, file_paths, target_catalog, target_schema) + table_result = self._load_table_data( + cursor, + table_name, + file_paths, + target_catalog, + target_schema, + benchmark=benchmark, + data_source=data_source, + ) table_name_lower = table_name.lower() if table_result is None: table_stats[table_name_lower] = 0 @@ -695,7 +716,12 @@ def execute_query( stream_id: int | None = None, ) -> dict[str, Any]: """Execute the query and capture its plan on success when enabled.""" - result = super().execute_query( + # Plan capture routes through the shared chokepoint: for phase-eligible + # engines (the default) it records the executed query for the isolated + # post-measurement phase instead of running EXPLAIN inline; otherwise it + # captures inline. SUCCESS-guarded inside the chokepoint. + return self.execute_query_with_plan_capture( + super().execute_query, connection=connection, query=query, query_id=query_id, @@ -704,12 +730,6 @@ def execute_query( validate_row_count=validate_row_count, stream_id=stream_id, ) - # Plan capture routes through the shared chokepoint: for phase-eligible - # engines (the default) it records the executed query for the isolated - # post-measurement phase instead of running EXPLAIN inline; otherwise it - # captures inline. SUCCESS-guarded inside the chokepoint. - self._merge_plan_capture_into_result(result, connection, query, query_id) - return result def close_connection(self, connection: Any) -> None: """Close connection.""" diff --git a/benchbox/platforms/presto_trino_utils.py b/benchbox/platforms/presto_trino_utils.py index e50d361bcc..9e86ebfbe0 100644 --- a/benchbox/platforms/presto_trino_utils.py +++ b/benchbox/platforms/presto_trino_utils.py @@ -36,12 +36,19 @@ def escape_insert_value(value: str) -> str: return "'" + str(value).replace("'", "''") + "'" -def load_file_batches(cursor: Any, file_path: Path, qualified_table: str) -> int: - """Load one file into a Presto/Trino table using batched INSERT VALUES.""" +def load_file_batches(cursor: Any, file_path: Path, qualified_table: str, *, delimiter: str | None = None) -> int: + """Load one file into a Presto/Trino table using batched INSERT VALUES. + + ``delimiter`` carries the resolver-derived dialect when the caller has + DataSource context (manifest metadata, then benchmark attributes); when + absent, the historical file-extension heuristic applies so standalone + callers keep working. + """ from benchbox.platforms.base.data_loading import FileFormatRegistry from benchbox.utils.file_format import get_delimiter_for_file - delimiter = get_delimiter_for_file(file_path) + if delimiter is None: + delimiter = get_delimiter_for_file(file_path) compression_handler = FileFormatRegistry.get_compression_handler(file_path) rows_loaded = 0 @@ -188,6 +195,8 @@ def execute_schema_statements( log_notice(f"Dropping existing table before recreate: {table_name}") cursor.execute(f"DROP TABLE IF EXISTS {table_name}") cursor.execute(statement) + else: + raise else: raise diff --git a/benchbox/platforms/questdb_rewriter.py b/benchbox/platforms/questdb_rewriter.py index 1f09a63dc0..b5af15baed 100644 --- a/benchbox/platforms/questdb_rewriter.py +++ b/benchbox/platforms/questdb_rewriter.py @@ -100,6 +100,12 @@ Output: dateadd('', [+-]N, ) Units: DAY → 'd', MONTH → 'M', YEAR → 'y' +The comma-JOIN stage runs first through a SQLGlot AST round trip, which +normalizes the interval literal to the combined spelling +``INTERVAL ' '`` (e.g. ``INTERVAL '90 DAY'``). The regex below +matches both spellings so a single rewrite() pass eliminates the +INTERVAL before the query reaches QuestDB. + Affected TPC-H queries: Q1, Q4, Q6, Q17 (at minimum). ────────────────────────────────────────────────────────────────────────────── @@ -398,13 +404,21 @@ def _has_comma_join(query: str) -> bool: # Rule 2 - INTERVAL arithmetic → dateadd() # ────────────────────────────────────────────────────────────────────────────── -# Matches: +/- INTERVAL '' DAY|MONTH|YEAR -# Capturing groups: (1) date expression, (2) sign, (3) number, (4) unit +# Matches: +/- INTERVAL '' DAY|MONTH|YEAR, plus the SQLGlot- +# normalized combined spelling +/- INTERVAL ' DAY|MONTH|YEAR' +# produced by the comma-JOIN AST round trip. Exactly one unit spelling +# is required; a bare INTERVAL '' with no unit never matches. +# The date expression accepts CAST(...), ANSI typed literals +# (DATE '1998-12-01', which single-table queries carry verbatim since no +# AST round trip normalizes them), or a bare identifier/column. +# Capturing groups: (1) date expression, (2) sign, (3) number (split +# spelling), (4) unit (split spelling), (5) number (combined spelling), +# (6) unit (combined spelling) _INTERVAL_RE = re.compile( - # group 1: date expr - either CAST(... AS DATE) or a bare identifier/column - r"(CAST\s*\([^)]+\)|[\w.]+)" + # group 1: date expr - CAST(...), ANSI DATE 'literal', or bare identifier/column + r"(CAST\s*\([^)]+\)|DATE\s*'\d{4}-\d{2}-\d{2}'|[\w.]+)" r"\s*([+-])\s*" - r"INTERVAL\s+'(\d+)'\s+(DAY|MONTH|YEAR)", + r"INTERVAL\s+(?:'(\d+)'\s*(DAY|MONTH|YEAR)|'(\d+)\s+(DAY|MONTH|YEAR)')", re.IGNORECASE, ) @@ -417,8 +431,9 @@ def _rewrite_interval_arithmetic(query: str) -> str: def _replace(m: re.Match) -> str: date_expr = m.group(1).strip() sign = m.group(2) - n = int(m.group(3)) - unit = _INTERVAL_UNIT_MAP[m.group(4).upper()] + n = int(m.group(3) or m.group(5)) + unit_word = (m.group(4) or m.group(6) or "").upper() + unit = _INTERVAL_UNIT_MAP[unit_word] offset = -n if sign == "-" else n return f"dateadd('{unit}', {offset}, {date_expr})" diff --git a/benchbox/platforms/redshift.py b/benchbox/platforms/redshift.py index 8c264adc20..b8a0b41aea 100644 --- a/benchbox/platforms/redshift.py +++ b/benchbox/platforms/redshift.py @@ -44,6 +44,7 @@ FileFormatRegistry, resolve_adapter_data_source, resolve_csv_dialect, + run_staged_table_loads, ) from .base.runtime_metadata import build_default_normalized_result_metadata @@ -1934,9 +1935,10 @@ def load_data( self, benchmark, connection: Any, data_dir: Path ) -> tuple[dict[str, int], float, dict[str, Any] | None]: """Load data using Redshift COPY command with S3 integration.""" - start_time = mono_time() - table_stats = {} - + # Phase clock starts at load_data entry so the returned duration + # covers cursor creation, file resolution, and S3 client creation, + # matching the pre-template behavior. + phase_start = mono_time() cursor = connection.cursor() try: @@ -1948,69 +1950,42 @@ def load_data( if self.s3_bucket and boto3: s3_client = self._create_s3_client() - for table_name, file_paths in data_source.tables.items(): - valid_files = self._filter_valid_files(file_paths) - - if not valid_files: - self.logger.warning(f"Skipping {table_name} - no valid data files") - table_stats[table_name.lower()] = 0 - continue - - chunk_info = f" from {len(valid_files)} file(s)" if len(valid_files) > 1 else "" - self.log_verbose(f"Loading data for table: {table_name}{chunk_info}") - - try: - load_start = mono_time() - row_count = self._load_table_via_s3( - cursor, s3_client, table_name, valid_files, connection, data_source, benchmark - ) - table_stats[table_name.lower()] = row_count - - load_time = elapsed_seconds(load_start) - self.logger.info( - f"✅ Loaded {row_count:,} rows into {table_name.lower()}{chunk_info} in {load_time:.2f}s" - ) + def load_via_s3(table_name: str, valid_files: list[Path]) -> int: + return self._load_table_via_s3( + cursor, s3_client, table_name, valid_files, connection, data_source, benchmark + ) - except Exception as e: - error_message = str(e) or repr(e) or type(e).__name__ - self.logger.error(f"Failed to load {table_name}: {error_message}") - table_stats[table_name.lower()] = 0 + table_stats, total_time, _ = run_staged_table_loads( + self, + tables=data_source.tables, + stat_key=str.lower, + filter_files=self._filter_valid_files, + load_one=load_via_s3, + record_timings=False, + fail_fast=False, + phase_start=phase_start, + ) else: # Direct loading without S3 (less efficient) self.logger.warning("No S3 bucket configured, using direct INSERT loading") - for table_name, file_paths in data_source.tables.items(): - valid_files = self._filter_valid_files(file_paths) - - if not valid_files: - self.logger.warning(f"Skipping {table_name} - no valid data files") - table_stats[table_name.lower()] = 0 - continue - - try: - self.log_verbose(f"Direct loading data for table: {table_name}") - load_start = mono_time() - - total_rows_loaded = self._load_table_via_insert( - cursor, table_name, valid_files, connection, data_source, benchmark - ) - table_stats[table_name.lower()] = total_rows_loaded - - load_time = elapsed_seconds(load_start) - chunk_info = f" from {len(valid_files)} file(s)" if len(valid_files) > 1 else "" - self.logger.info( - f"✅ Loaded {total_rows_loaded:,} rows into {table_name.lower()}{chunk_info} in {load_time:.2f}s" - ) - - except Exception as e: - error_message = str(e) or repr(e) or type(e).__name__ - self.logger.error(f"Failed to load {table_name}: {error_message}") - table_stats[table_name.lower()] = 0 + def load_via_insert(table_name: str, valid_files: list[Path]) -> int: + return self._load_table_via_insert( + cursor, table_name, valid_files, connection, data_source, benchmark + ) - total_time = elapsed_seconds(start_time) - total_rows = sum(table_stats.values()) - self.logger.info(f"✅ Loaded {total_rows:,} total rows in {total_time:.2f}s") + table_stats, total_time, _ = run_staged_table_loads( + self, + tables=data_source.tables, + stat_key=str.lower, + filter_files=self._filter_valid_files, + load_one=load_via_insert, + record_timings=False, + fail_fast=False, + describe_start=lambda table_name, chunk_info: f"Direct loading data for table: {table_name}", + phase_start=phase_start, + ) except Exception as e: self.logger.error(f"Data loading failed: {e}") diff --git a/benchbox/platforms/singlestore.py b/benchbox/platforms/singlestore.py index 43e21f467b..4a77ba74aa 100644 --- a/benchbox/platforms/singlestore.py +++ b/benchbox/platforms/singlestore.py @@ -415,7 +415,8 @@ def execute_query( Kept outside the execution path so a strict_plan_capture PlanCaptureError propagates rather than being mislabeled as a failed query. """ - result = super().execute_query( + return self.execute_query_with_plan_capture( + super().execute_query, connection, query, query_id, @@ -424,8 +425,6 @@ def execute_query( validate_row_count=validate_row_count, stream_id=stream_id, ) - self._merge_plan_capture_into_result(result, connection, query, query_id) - return result def get_platform_info(self, connection: Any = None) -> dict[str, Any]: """Get SingleStore platform information.""" diff --git a/benchbox/platforms/snowflake.py b/benchbox/platforms/snowflake.py index 0f1907b2c7..cfe4552981 100644 --- a/benchbox/platforms/snowflake.py +++ b/benchbox/platforms/snowflake.py @@ -29,7 +29,13 @@ from ..utils.dependencies import check_platform_dependencies, get_dependency_error_message from .base import DriverIsolationCapability, PlatformAdapter from .base.config_utils import make_registered_platform_config_builder -from .base.data_loading import NO_BENCHMARK, DataSource, resolve_adapter_data_source, resolve_csv_dialect +from .base.data_loading import ( + NO_BENCHMARK, + DataSource, + resolve_adapter_data_source, + resolve_csv_dialect, + run_staged_table_loads, +) from .base.runtime_metadata import build_default_normalized_result_metadata from .base.tuning import make_informational_constraint_applier from .presto_trino_utils import normalize_existing_files @@ -779,8 +785,16 @@ def create_schema(self, benchmark, connection: Any) -> float: self.log_very_verbose("Retrieving schema SQL from benchmark") schema_sql = self._create_schema_with_tuning(benchmark, source_dialect="standard") - # Split schema into individual statements and execute - statements = [stmt.strip() for stmt in schema_sql.split(";") if stmt.strip()] + # Split schema into individual statements and execute. Chunks that + # hold only decorative "--" comments (a ";" inside a comment + # splits one off) carry no DDL: skip the no-op round trip. + from benchbox.platforms.cloud_shared import split_leading_sql_comments + + statements = [ + stmt.strip() + for stmt in schema_sql.split(";") + if stmt.strip() and split_leading_sql_comments(stmt)[1].strip() + ] self.log_verbose(f"Executing {len(statements)} schema statements") @@ -811,10 +825,10 @@ def load_data( self.log_verbose(f"Starting data loading for benchmark: {benchmark.__class__.__name__}") self.log_very_verbose(f"Data directory: {data_dir}") - start_time = mono_time() - table_stats = {} - per_table_timings: dict[str, Any] = {} - total_time = 0.0 + # Phase clock starts at load_data entry so the returned duration + # covers cursor creation, query-tag/file-format setup, and file + # resolution, matching the pre-template behavior. + phase_start = mono_time() cursor = connection.cursor() @@ -826,50 +840,36 @@ def load_data( self._create_load_file_formats(cursor) data_source = self._resolve_data_files(benchmark, data_dir) - # Load data for each table (handle multi-chunk files) - for table_name, file_paths in data_source.tables.items(): - valid_files = self._normalize_existing_files(file_paths) - - if not valid_files: - self.logger.warning(f"Skipping {table_name} - no valid data files") - table_stats[table_name.upper()] = 0 - per_table_timings[table_name.upper()] = {"total_ms": 0} - continue - - chunk_info = f" from {len(valid_files)} file(s)" if len(valid_files) > 1 else "" - self.log_verbose(f"Loading data for table: {table_name}{chunk_info}") - - try: - load_start = mono_time() - table_name_upper = table_name.upper() - actual_count = self._load_table_from_stage( - cursor, table_name, table_name_upper, valid_files, data_source, benchmark - ) - table_stats[table_name_upper] = actual_count - - effective_tuning = self.get_effective_tuning_configuration() - if effective_tuning is not None: - self.apply_ctas_sort(table_name_upper, effective_tuning, connection) - - load_time = elapsed_seconds(load_start) - per_table_timings[table_name_upper] = {"total_ms": load_time * 1000} - self.log_verbose( - f"✅ Loaded {actual_count:,} rows into {table_name_upper}{chunk_info} in {load_time:.2f}s" - ) - - except Exception as e: - self.logger.error(f"Failed to load {table_name}: {str(e)[:100]}...") - table_stats[table_name.upper()] = 0 - per_table_timings[table_name.upper()] = {"total_ms": 0} - # Fail fast: loads are full refreshes, so a failed table - # must abort the run instead of benchmarking a wiped table. - raise + def load_one(table_name: str, valid_files: list[Path]) -> int: + return self._load_table_from_stage( + cursor, table_name, table_name.upper(), valid_files, data_source, benchmark + ) - total_time = elapsed_seconds(start_time) - total_rows = sum(table_stats.values()) - self.log_verbose(f"✅ Loaded {total_rows:,} total rows in {total_time:.2f}s") + def on_table_loaded(table_name: str, table_name_upper: str, row_count: int) -> None: + del row_count + effective_tuning = self.get_effective_tuning_configuration() + if effective_tuning is not None: + self.apply_ctas_sort(table_name_upper, effective_tuning, connection) + + # Fail fast: loads are full refreshes, so a failed table must + # abort the run instead of benchmarking a wiped table. + # Success and summary lines stay verbose-gated as before. + table_stats, total_time, per_table_timings = run_staged_table_loads( + self, + tables=data_source.tables, + stat_key=str.upper, + filter_files=self._normalize_existing_files, + load_one=load_one, + on_table_loaded=on_table_loaded, + record_timings=True, + fail_fast=True, + success_log=self.log_verbose, + summary_log=self.log_verbose, + phase_start=phase_start, + ) + assert per_table_timings is not None self.log_operation_complete( - "Snowflake data loading", details=f"Loaded {total_rows:,} rows in {total_time:.2f}s" + "Snowflake data loading", details=f"Loaded {sum(table_stats.values()):,} rows in {total_time:.2f}s" ) except Exception as e: @@ -1320,6 +1320,15 @@ def validate_session_cache_control(self, connection: Any) -> dict[str, Any]: value_column_index=1, ) + @staticmethod + def _first_statement_keyword(statement: str) -> str: + """Extract the first uppercase keyword from a SQL statement, ignoring comments.""" + import re + + cleaned = re.sub(r"^(?:\s*--(?:[^\n]*)\n|\s*/\*.*?\*/)+", "", statement, flags=re.DOTALL) + m = re.match(r"^\s*([A-Za-z]+)", cleaned) + return m.group(1).upper() if m else "" + def execute_query( self, connection: Any, @@ -1360,8 +1369,48 @@ def execute_query( # Zero-divisor guard is a no-op unless the query divides. query = self._safeguard_snowflake_division(query) self.log_verbose(f"Executing query {query_id} on Snowflake") - cursor.execute(query) - result = cursor.fetchall() + # Snowflake's driver rejects multi-statement strings ("Actual + # statement count N did not match the desired statement count + # 1"), which operation benchmarks emit routinely (DELETE+INSERT + # pairs, the 3-statement SCD2 stage batch, DDL sequences). + # Run each statement in session order and report the last + # statement's rows; a single statement takes the identical + # single-execute path as before. + # Multi-statement DML sequences (such as SCD2 close-then-insert) + # are executed in an explicit transaction (BEGIN ... COMMIT) so + # failure at a later statement triggers ROLLBACK rather than + # committing earlier statements under autocommit=True. + from benchbox.platforms.base.mysql_wire import split_sql_statements + + statements = split_sql_statements(query) + statements_to_run = statements or [query] + result = [] + + keywords = [self._first_statement_keyword(s) for s in statements_to_run] + has_tx_control = any(kw in {"BEGIN", "START", "COMMIT", "ROLLBACK"} for kw in keywords) + has_ddl = any( + kw in {"CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT", "GRANT", "REVOKE", "USE"} + for kw in keywords + ) + use_explicit_tx = len(statements_to_run) > 1 and not has_tx_control and not has_ddl + + if use_explicit_tx: + cursor.execute("BEGIN") + try: + for statement in statements_to_run: + cursor.execute(statement) + result = cursor.fetchall() + cursor.execute("COMMIT") + except Exception: + try: + cursor.execute("ROLLBACK") + except Exception as rollback_err: + self.log_very_verbose(f"Rollback failed: {rollback_err}") + raise + else: + for statement in statements_to_run: + cursor.execute(statement) + result = cursor.fetchall() execution_time = elapsed_seconds(start_time) actual_row_count = len(result) if result else 0 @@ -1490,15 +1539,24 @@ def _optimize_table_definition(self, statement: str) -> str: Uppercasing quoted identifiers keeps both spellings resolving to the same table. Single-quoted string literals are left untouched. """ - if not statement.upper().startswith("CREATE TABLE"): + from benchbox.platforms.cloud_shared import split_leading_sql_comments + + # Schema chunks can carry decorative "--" header lines before the real + # CREATE (naive ";" splitting preserves them). Gate and rewrite on the + # remainder so those chunks still get the idempotent OR REPLACE form; + # the prefix is re-attached unchanged at the end. + prefix, body = split_leading_sql_comments(statement) + if not body.upper().startswith("CREATE TABLE"): return statement # Ensure idempotency with OR REPLACE (defense-in-depth), unless the # statement already has IF NOT EXISTS (OR REPLACE + IF NOT EXISTS - # is a Snowflake syntax error). - if "CREATE TABLE" in statement and "OR REPLACE" not in statement.upper(): - if "IF NOT EXISTS" not in statement.upper(): - statement = statement.replace("CREATE TABLE", "CREATE OR REPLACE TABLE", 1) + # is a Snowflake syntax error). Rewrite the body only, so a comment + # that happens to mention CREATE TABLE is never corrupted. + if "CREATE TABLE" in body and "OR REPLACE" not in body.upper(): + if "IF NOT EXISTS" not in body.upper(): + body = body.replace("CREATE TABLE", "CREATE OR REPLACE TABLE", 1) + statement = prefix + body import re @@ -1632,18 +1690,18 @@ def _validate_data_integrity( return "FAILED", validation_details def _get_query_statistics( - self, connection: Any, query_id: str, max_retries: int = 3, initial_delay: float = 0.5 + self, connection: Any, query_id: str, max_retries: int = 0, initial_delay: float = 0.5 ) -> dict[str, Any]: """Get detailed query statistics from Snowflake query history. Snowflake query history may not be immediately available after query execution. - This method implements retry logic with exponential backoff to handle delayed - statistics availability. + The timed query result remains valid when optional statistics are absent, so + the normal path makes one lookup. Explicit callers may retry a missing row. Args: connection: Snowflake connection query_id: Query identifier to look up in history - max_retries: Maximum number of retry attempts (default: 3) + max_retries: Maximum number of retries for a missing row (default: 0) initial_delay: Initial delay in seconds between retries (default: 0.5s) Returns: @@ -1652,24 +1710,17 @@ def _get_query_statistics( import time as time_module cursor = connection.cursor() - last_error = None - for attempt in range(max_retries + 1): try: - # Query the QUERY_HISTORY view for performance metrics + # Use the Information Schema table function's column set. cursor.execute(f""" SELECT QUERY_ID, - QUERY_TEXT, TOTAL_ELAPSED_TIME, EXECUTION_TIME, COMPILATION_TIME, BYTES_SCANNED, - BYTES_WRITTEN, - BYTES_SPILLED_TO_LOCAL_STORAGE, - BYTES_SPILLED_TO_REMOTE_STORAGE, ROWS_PRODUCED, - ROWS_EXAMINED, CREDITS_USED_CLOUD_SERVICES, WAREHOUSE_SIZE, CLUSTER_NUMBER @@ -1677,7 +1728,7 @@ def _get_query_statistics( END_TIME_RANGE_START => DATEADD('MINUTE', -5, CURRENT_TIMESTAMP()), END_TIME_RANGE_END => CURRENT_TIMESTAMP() )) - WHERE QUERY_TAG LIKE '%{query_id}%' + WHERE QUERY_TAG = '{self.query_tag}_{query_id}' ORDER BY START_TIME DESC LIMIT 1 """) @@ -1689,22 +1740,18 @@ def _get_query_statistics( cursor.close() return { "snowflake_query_id": result[0], - "total_elapsed_time_ms": result[2], - "execution_time_ms": result[3], - "compilation_time_ms": result[4], - "bytes_scanned": result[5], - "bytes_written": result[6], - "bytes_spilled_local": result[7], - "bytes_spilled_remote": result[8], - "rows_produced": result[9], - "rows_examined": result[10], + "total_elapsed_time_ms": result[1], + "execution_time_ms": result[2], + "compilation_time_ms": result[3], + "bytes_scanned": result[4], + "rows_produced": result[5], # CREDITS_USED_CLOUD_SERVICES covers the cloud-services # layer only, not warehouse compute. It is reported # under a truthful key and never priced; warehouse cost # is estimated from execution time and warehouse size. - "credits_used_cloud_services": result[11], - "warehouse_size": result[12], - "cluster_number": result[13], + "credits_used_cloud_services": result[6], + "warehouse_size": result[7], + "cluster_number": result[8], "retrieval_attempts": attempt + 1, } else: @@ -1726,21 +1773,11 @@ def _get_query_statistics( "retrieval_attempts": max_retries + 1, } - except Exception as e: - last_error = e - if attempt < max_retries: - delay = initial_delay * (2**attempt) - self.logger.debug( - f"Error retrieving query statistics for {query_id}: {e}, " - f"retrying in {delay:.1f}s (attempt {attempt + 1}/{max_retries})" - ) - time_module.sleep(delay) - else: - cursor.close() - return { - "statistics_error": str(last_error), - "retrieval_attempts": max_retries + 1, - } + except Exception as exc: + # A bad projection or permission error will not become valid + # after sleeping. Keep optional telemetry off the query path. + cursor.close() + return {"statistics_error": str(exc), "retrieval_attempts": attempt + 1} cursor.close() return {"note": "Query statistics not yet available", "retrieval_attempts": max_retries + 1} diff --git a/benchbox/platforms/snowpark_connect.py b/benchbox/platforms/snowpark_connect.py index 63c92bff28..21c36f8f25 100644 --- a/benchbox/platforms/snowpark_connect.py +++ b/benchbox/platforms/snowpark_connect.py @@ -692,13 +692,79 @@ def configure_for_benchmark(self, connection: Any, benchmark_type: str) -> None: self._benchmark_type = benchmark_type.lower() logger.info(f"Configuring Snowpark Connect for {benchmark_type} benchmark") - # Disable result cache for accurate benchmarking + # Disable result cache for accurate benchmarking. This is benchmarking + # hygiene applied to every run, not tuning-derived, so it must never + # enter the applied-tuning ledger (recording it would falsely report + # baseline runs as applied_unverified). It runs on the raw session + # handle, and RecordingConnection only intercepts execute/cursor + # paths anyway, so a .sql() call cannot be captured either way. if self._session: self._session.sql("ALTER SESSION SET USE_CACHED_RESULT = FALSE").collect() logger.debug("Disabled result cache for benchmarking") - # apply_primary_keys, apply_foreign_keys, apply_platform_optimizations, - # and apply_constraint_configuration are inherited from SparkTuningMixin + def apply_platform_optimizations(self, config: Any) -> list[str]: + """Snowpark applies no tuning-derived session settings, so nothing is recorded. + + Snowpark inherits the Spark mixin no-op signature (``(config) -> + list[str]``) rather than the ``PlatformAdapter`` 2-arg DDL hook: the + unified-tuning entrypoint Snowpark actually runs + (``TuningConfigMixin.apply_unified_tuning``) is itself a no-op and + never dispatches to ``apply_standard_unified_tuning``, so the 2-arg + call path the reviewer feared cannot reach this override at runtime. + + None of the unified platform-optimization fields map to Snowflake + ALTER SESSION parameters. Warehouse sizing (``warehouse_size``) is + persistent account-level infrastructure, not a session setting, so it + stays behind an explicit opt-in that does not exist yet (see the + Snowpark tuning-surface follow-up). Requested optimizations are + recorded as dropped intents so the ledger surfaces the request + honestly instead of silently vanishing; the run still reports noop + for applied statements. The USE_CACHED_RESULT hygiene ALTER in + configure_for_benchmark is benchmarking hygiene on every run, + not tuning-derived, and must never enter the ledger either. + """ + logger.debug("Snowpark tuning surface is empty; no session settings applied") + ledger = getattr(self, "_applied_tuning_ledger", None) + if ledger is not None and config is not None: + requested = self._requested_platform_optimization_names(config) + for name in requested: + ledger.record_dropped( + f"platform_optimization:{name}", + "Snowpark does not map platform optimizations to Snowflake session settings", + ) + return list[str]() + + @staticmethod + def _requested_platform_optimization_names(config: Any) -> list[str]: + """Return the names of enabled optimizations in a platform config. + + Reads the ``PlatformOptimizationConfiguration`` dataclass fields + without importing tuning internals: any truthy ``*_enabled`` flag or + non-empty layout field counts as requested. + """ + names: list[str] = [] + for key in ( + "z_ordering_enabled", + "liquid_clustering_enabled", + "auto_optimize_enabled", + "auto_compact_enabled", + "bloom_filters_enabled", + "materialized_views_enabled", + ): + if getattr(config, key, False): + names.append(key) + strategy = getattr(config, "databricks_clustering_strategy", "none") + if strategy and strategy != "none": + names.append(f"databricks_clustering_strategy:{strategy}") + for key in ("z_ordering_columns", "liquid_clustering_columns", "bloom_filter_columns"): + columns = getattr(config, key, None) + if columns: + names.append(f"{key}:{len(columns)}") + return names + + # apply_primary_keys, apply_foreign_keys, and apply_constraint_configuration + # are inherited from SparkTuningMixin. apply_platform_optimizations is + # overridden above: Snowpark has no Spark session config to apply. def get_target_dialect(self) -> str: """Return the target SQL dialect for Snowpark Connect. diff --git a/benchbox/platforms/spark_query_transformer.py b/benchbox/platforms/spark_query_transformer.py index b03cb10e95..1b14787d92 100644 --- a/benchbox/platforms/spark_query_transformer.py +++ b/benchbox/platforms/spark_query_transformer.py @@ -1,240 +1,12 @@ -"""Query transformation utilities for Spark SQL TPC-Havoc compatibility. +"""Compatibility import for the shared TPC-Havoc Spark SQL transformer.""" -Spark's analyzer rejects two SQL shapes used by TPC-Havoc variants: - -- Correlated scalar subqueries in the SELECT list of an aggregated query - (``SCALAR_SUBQUERY_IS_IN_GROUP_BY_OR_AGGREGATE_FUNCTION``). The scalar is - correlated on the GROUP BY keys, so it is constant per group; wrapping it in - ``first()`` preserves semantics while satisfying the analyzer. -- Empty grouping (``GROUP BY ()``), which the Spark parser rejects outright. - Dropping the empty grouping keeps the single-group aggregate semantics - (``HAVING`` without ``GROUP BY`` is valid on both Spark and DuckDB). -- The ``(SELECT 1) AS dual`` leg of the Q17 UNION ALL variant, which has no - explicit column alias. Adding one hardens the variant across Spark versions - and Spark-Connect engines. - -Import discipline: the Spark adapter imports ``SparkTPCHavocQueryTransformer`` -lazily inside ``execute_query``. Tests should patch this source-module name -(``benchbox.platforms.spark_query_transformer.SparkTPCHavocQueryTransformer``) -before calling ``execute_query``; there is no stable module-level binding on -``benchbox.platforms.spark`` to patch. - -Copyright 2026 Joe Harris / BenchBox Project -Licensed under the MIT License. See LICENSE file in the project root for details. -""" - -from __future__ import annotations - -import logging - -import sqlglot -from sqlglot import exp - -logger = logging.getLogger(__name__) - -# Variant IDs using a correlated scalar subquery in the SELECT list of a -# GROUP BY query. Verified against Spark 4.2 -# (``SCALAR_SUBQUERY_IS_IN_GROUP_BY_OR_AGGREGATE_FUNCTION``). -SCALAR_GROUP_BY_VARIANT_IDS: frozenset[str] = frozenset( - { - "1_v1", - "3_v1", - "7_v1", - "8_v1", - "9_v1", - "10_v1", - "12_v1", - "16_v1", - } +from benchbox.core.tpchavoc.spark_query_transformer import ( + DUAL_VARIANT_IDS, + GROUP_BY_EMPTY_VARIANT_IDS, + SCALAR_GROUP_BY_VARIANT_IDS, + SparkTPCHavocQueryTransformer, ) -# Variant IDs using empty grouping (``GROUP BY ()``), which the Spark parser -# rejects with ``PARSE_SYNTAX_ERROR``. -GROUP_BY_EMPTY_VARIANT_IDS: frozenset[str] = frozenset({"6_v2", "14_v2"}) - -# Variant IDs using the ``(SELECT 1) AS dual`` UNION ALL leg. -DUAL_VARIANT_IDS: frozenset[str] = frozenset({"17_v4"}) - - -class SparkTPCHavocQueryTransformer: - """Transform TPC-Havoc variant SQL for Spark compatibility. - - Uses variant-ID-based dispatch to apply targeted rewrites for the specific - TPC-Havoc variants Spark rejects. Follows the same pattern as - DataFusionQueryTransformer. All other queries pass through unchanged. - """ - - def __init__(self, verbose: bool = False): - """Initialize query transformer. - - Args: - verbose: Enable verbose logging of transformations - """ - self.verbose = verbose - self.transformations_applied: list[str] = [] - - def transform(self, query: str, query_id: str | None = None) -> str: - """Apply Spark-specific transformations to a TPC-Havoc variant query. - - Args: - query: Original SQL query - query_id: Optional variant identifier (e.g., "1_v1", "Q17_V4") - - Returns: - Transformed SQL query compatible with Spark - """ - self.transformations_applied = [] - - normalized_id = self.normalize_query_id(query_id) - - if normalized_id in SCALAR_GROUP_BY_VARIANT_IDS: - query = self._rewrite_scalar_group_by(query) - elif normalized_id in GROUP_BY_EMPTY_VARIANT_IDS: - query = self._rewrite_group_by_empty(query) - elif normalized_id in DUAL_VARIANT_IDS: - query = self._rewrite_dual(query) - - if self.verbose and self.transformations_applied: - logger.debug(f"Applied transformations: {', '.join(self.transformations_applied)}") - - return query - - def get_transformations_applied(self) -> list[str]: - """Return list of transformations applied to last query. - - Returns: - List of transformation names - """ - return self.transformations_applied - - @classmethod - def known_variant_ids(cls) -> frozenset[str]: - """All TPC-Havoc variant IDs this transformer rewrites.""" - return SCALAR_GROUP_BY_VARIANT_IDS | GROUP_BY_EMPTY_VARIANT_IDS | DUAL_VARIANT_IDS - - def normalize_query_id(self, query_id: str | None) -> str | None: - """Normalize a TPC-Havoc variant ID to "_v" form. - - Args: - query_id: Raw variant ID (e.g., "Q1_V1", "1-v1", "1_v1", "Q01_V1") - - Returns: - Normalized ID (e.g., "1_v1") or None - """ - if query_id is None: - return None - qid = str(query_id).strip().lower() - if qid.startswith("q"): - qid = qid[1:] - qid = qid.replace("-", "_") - if "_v" in qid: - q_num, _, v_num = qid.partition("_v") - qid = f"{q_num.lstrip('0') or '0'}_v{v_num}" - return qid - - # Backward-compatible alias. - _normalize_query_id = normalize_query_id - - def _rewrite_scalar_group_by(self, query: str) -> str: - """Wrap bare SELECT-list scalar subqueries in ``first()``. - - Spark requires a correlated scalar subquery in an aggregated query to - appear in GROUP BY or inside an aggregate function. Each rewritten - scalar is correlated on the GROUP BY keys, hence constant per group, - so ``first()`` preserves semantics. - """ - tree = sqlglot.parse_one(query, read="spark") - applied = False - for select in tree.find_all(exp.Select): - if not select.args.get("group"): - continue - new_projections = [] - select_changed = False - for projection in select.expressions: - new_projection = self._wrap_bare_scalars(projection) - new_projections.append(new_projection) - select_changed |= new_projection is not projection - if select_changed: - select.set("expressions", new_projections) - applied = True - if not applied: - return query - self.transformations_applied.append("scalar_group_by_first") - return tree.sql(dialect="spark") - - def _wrap_bare_scalars(self, node): # type: ignore[no-untyped-def] - """Wrap scalar-subquery nodes not already inside an aggregate. - - Stops at subquery, aggregate, membership-test, and nested-SELECT - boundaries so inner-query shapes are never rewritten. - """ - from sqlglot import exp - - if isinstance(node, exp.Subquery): - return exp.First(this=node) - if isinstance(node, (exp.AggFunc, exp.Exists, exp.In, exp.Any, exp.All, exp.Window)): - return node - if isinstance(node, exp.Select): - return node - args: dict = {} - changed = False - for key, value in node.args.items(): - if isinstance(value, exp.Expr): - new_value = self._wrap_bare_scalars(value) - args[key] = new_value - changed |= new_value is not value - elif isinstance(value, list): - new_list = [self._wrap_bare_scalars(item) if isinstance(item, exp.Expr) else item for item in value] - args[key] = new_list - changed |= any(new is not old for new, old in zip(new_list, value)) - else: - args[key] = value - if not changed: - return node - new_node = node.__class__(**args) - new_node.comments = node.comments - return new_node - - def _rewrite_group_by_empty(self, query: str) -> str: - """Drop empty grouping (``GROUP BY ()``) Spark cannot parse. - - With no GROUP BY the aggregate still computes a single group, so the - accompanying ``HAVING`` keeps its semantics on Spark and DuckDB. - """ - tree = sqlglot.parse_one(query, read="spark") - applied = False - for select in tree.find_all(exp.Select): - group = select.args.get("group") - if group and len(group.expressions) == 1: - only = group.expressions[0] - if isinstance(only, exp.Tuple) and not only.expressions: - select.set("group", None) - applied = True - if not applied: - return query - self.transformations_applied.append("group_by_empty_drop") - return tree.sql(dialect="spark") - - def _rewrite_dual(self, query: str) -> str: - """Give the ``(SELECT 1) AS dual`` leg an explicit column alias.""" - tree = sqlglot.parse_one(query, read="spark") - applied = False - for subquery in tree.find_all(exp.Subquery): - inner = subquery.this - if not isinstance(inner, exp.Select) or len(inner.expressions) != 1: - continue - only = inner.expressions[0] - if isinstance(only, exp.Alias): - continue - if isinstance(only, exp.Literal) and only.this == "1" and not only.args.get("is_string"): - only.replace(exp.alias_(only.copy(), "dual_col")) - applied = True - if not applied: - return query - self.transformations_applied.append("dual_column_alias") - return tree.sql(dialect="spark") - - __all__ = [ "SparkTPCHavocQueryTransformer", "SCALAR_GROUP_BY_VARIANT_IDS", diff --git a/benchbox/platforms/sqlite.py b/benchbox/platforms/sqlite.py index 9759bde45b..0cce2f7392 100644 --- a/benchbox/platforms/sqlite.py +++ b/benchbox/platforms/sqlite.py @@ -310,6 +310,15 @@ def from_config(cls, config: dict[str, Any]): # type: ignore[override] ) # Pass through other relevant config (verbose settings, tuning config, etc.) + # Plan display/capture keys ride along via shared PLAN_FORWARD_KEYS + # (skipping None so adapter defaults apply): __init__ only ever sees + # this rebuilt config, so dropping them silently disables console plan + # display and plan capture/filtering even when requested. + from benchbox.platforms.base.config_utils import PLAN_FORWARD_KEYS + + for key in PLAN_FORWARD_KEYS: + if key in config and config[key] is not None: + adapter_config[key] = config[key] for key in [ "tuning_config", "tuning_enabled", @@ -388,8 +397,21 @@ def create_connection(self, **connection_config) -> Any: # the adapted real value using its native numeric representation. sqlite3.register_adapter(Decimal, float) - # Create connection - conn = sqlite3.connect(db_path, timeout=self.timeout, check_same_thread=self.check_same_thread) + # Create connection. Throughput streams run on cursors of this one + # connection (SHARED_CURSOR). CPython's sqlite3 keeps a per-connection + # statement cache (sqlite3.connect cached_statements, default 128): + # concurrent cursors running identical SQL can share one prepared + # statement, so one stream's execute resets another's result set and it + # reads wrong or missing rows (sqlite 3.53.1, threadsafety 1). Zero + # disables reuse so each cursor prepares its own statement. The cost is + # one extra prepare per stream query; benchmark queries dominate it, + # and this adapter serves single-file testing, not a hot OLTP path. + conn = sqlite3.connect( + db_path, + timeout=self.timeout, + check_same_thread=self.check_same_thread, + cached_statements=0, + ) _register_sqlite_compatibility_functions(conn) diff --git a/benchbox/platforms/starrocks/workload.py b/benchbox/platforms/starrocks/workload.py index 53b7d53b75..e35ee04272 100644 --- a/benchbox/platforms/starrocks/workload.py +++ b/benchbox/platforms/starrocks/workload.py @@ -384,6 +384,13 @@ def _convert_types(self, statement: str) -> str: for pattern, replacement, case_sensitive in self._TYPE_MAPPINGS: flags = 0 if case_sensitive else re.IGNORECASE statement = re.sub(pattern, replacement, statement, flags=flags) + # TIMESTAMP/TIME stay case-sensitive above so a column literally named + # "timestamp" keeps its name. A lowercase occurrence in type position + # (right after another identifier, e.g. "ts timestamp") is a type + # written in non-conventional case; normalize it. A leading lowercase + # "timestamp" is a column name and is left alone. + statement = re.sub(r"(\w+\s+)timestamp\b", r"\1DATETIME", statement) + statement = re.sub(r"(\w+\s+)time\b", r"\1VARCHAR(10)", statement) return statement def _extract_first_column(self, statement: str) -> str | None: diff --git a/benchbox/read_primitives.py b/benchbox/read_primitives.py index 9781437c1d..fc3f6a05f2 100644 --- a/benchbox/read_primitives.py +++ b/benchbox/read_primitives.py @@ -59,14 +59,14 @@ def __init__( scale_factor=scale_factor, output_dir=output_dir, verbose=verbose, **kwargs ) - def generate_data(self, tables: Optional[list[str]] = None) -> dict[str, str]: + def generate_data(self, tables: Optional[list[str]] = None) -> dict[str, str | list[str]]: """Generate Read Primitives benchmark data. Args: tables: Optional list of table names to generate. If None, generates all. Returns: - A dictionary mapping table names to file paths + A dictionary mapping table names to a file path or an ordered list of shard paths. """ # Call the implementation to generate data self._impl.generate_data(tables) diff --git a/benchbox/sql_compat/rules/ddl_optimize/databricks_ddl_rewrites.py b/benchbox/sql_compat/rules/ddl_optimize/databricks_ddl_rewrites.py index 53a91efaa3..70d0b91458 100644 --- a/benchbox/sql_compat/rules/ddl_optimize/databricks_ddl_rewrites.py +++ b/benchbox/sql_compat/rules/ddl_optimize/databricks_ddl_rewrites.py @@ -12,6 +12,15 @@ from __future__ import annotations +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import ( + CompatibilityDecision, + FailureMode, + RewriteDDLPayload, + SupportLevel, +) +from benchbox.sql_compat.registry import REGISTRY from benchbox.sql_compat.rules._registration import register_ddl_rewrite register_ddl_rewrite( @@ -24,3 +33,33 @@ "for idempotency, tables must declare USING DELTA or USING HUDI, and auto-optimize TBLPROPERTIES " "improve write performance when delta_auto_optimize is enabled (Delta only).", ) + +# Benchmark-scoped rule for the Transaction Primitives staging DDL. Registered +# with REGISTRY.register directly (not the register_ddl_rewrite helper, which +# has no benchmark tier) so REGISTRY.resolve() keeps returning a single winner +# at the platform-wide tier instead of raising CompatibilityRegistryConflict +# (see singlestore_ddl_rewrites.py). The schema emitter +# (benchbox/core/transaction_primitives/schema.py) declares the feature only +# when this rule resolves; the generated compatibility docs render it from the +# registry. governance_only: the emitter applies the rewrite itself. +REGISTRY.register( + CompatibilityDecision( + rule_id="ddl_optimize.databricks.transaction_primitives.txn_staging_catalog_managed", + action=CompatAction.REWRITE_DDL, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=RewriteDDLPayload( + transformer_id="txn_staging_catalog_managed", + description="Transaction Primitives staging DDL declares the catalogManaged Delta table " + "feature: USING DELTA TBLPROPERTIES ('delta.feature.catalogManaged' = 'supported')", + governance_only=True, + ), + reason="Databricks multi-statement transactions only write Delta tables with the catalogManaged " + "feature (TRANSACTION_NOT_SUPPORTED.WRITE_NON_CATALOG_MANAGED_TABLE otherwise); the txn_* staging " + "tables are the only tables these transactions write, so the schema emitter declares the feature " + "when the registry resolves this benchmark-scoped rule.", + ), + Phase.DDL_OPTIMIZE, + "databricks", + benchmark="transaction_primitives", +) diff --git a/benchbox/sql_compat/rules/execution_filter/clickhouse_tpchavoc.py b/benchbox/sql_compat/rules/execution_filter/clickhouse_tpchavoc.py index b944eb6cc0..947545e0a6 100644 --- a/benchbox/sql_compat/rules/execution_filter/clickhouse_tpchavoc.py +++ b/benchbox/sql_compat/rules/execution_filter/clickhouse_tpchavoc.py @@ -37,7 +37,6 @@ from benchbox.sql_compat.registry import REGISTRY CLICKHOUSE_TPCHAVOC_SKIPS: dict[str, str] = { - "1_v6": "ClickHouse rejects the DuckDB FILTER clause on aggregate functions (Code 42, COUNT requires zero or one argument).", "1_v7": "ClickHouse has no DuckDB `LIST` aggregate / `list_transform`/`list_zip` lambda forms this variant uses (`Syntax error` at the `->` lambda).", "1_v10": "ClickHouse rejects a GROUP BY whose key is a CASE aliased to the same name as a column it references (alias shadowing, Code 215 NOT_AN_AGGREGATE).", "3_v1": "ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED).", @@ -53,7 +52,6 @@ "5_v10": "ClickHouse cannot SUM a `Variant(Decimal, Float64)` column produced by this variant's mixed-type CASE (Code 43 ILLEGAL_TYPE_OF_ARGUMENT).", "10_v1": "ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED).", "11_v4": "ClickHouse rejects an aggregate over a select-list-alias aggregate (nested aggregate, Code 184 ILLEGAL_AGGREGATION).", - "12_v7": "ClickHouse rejects DuckDB FILTER clauses on COUNT aggregates (Code 42, COUNT requires zero or one argument).", "13_v8": "ClickHouse cannot lower this variant's correlated subquery (CommonSubplan plan step, Code 48 NOT_IMPLEMENTED).", "14_v8": "ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED).", "16_v1": "ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED).", diff --git a/benchbox/sql_compat/rules/execution_filter/cloud_tpchavoc.py b/benchbox/sql_compat/rules/execution_filter/cloud_tpchavoc.py new file mode 100644 index 0000000000..3edf7881fe --- /dev/null +++ b/benchbox/sql_compat/rules/execution_filter/cloud_tpchavoc.py @@ -0,0 +1,50 @@ +"""Cloud TPC-Havoc variants with unsupported engine-specific constructs.""" + +from __future__ import annotations + +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import CompatibilityDecision, FailureMode, SkipQueryPayload, SupportLevel +from benchbox.sql_compat.registry import REGISTRY + +CLOUD_TPCHAVOC_SKIPS: dict[str, dict[str, str]] = { + "bigquery": { + # 1_v7 has a native-array rewrite (cloud_compat._rewrite_array_aggregation) + # awaiting a live compile check on BigQuery before the skip is lifted. + "1_v7": "BigQuery does not support DuckDB LIST aggregates and LIST lambda functions in this variant.", + "2_v2": "BigQuery rejects the correlated scalar subquery in this grouped variant.", + }, + # Snowflake 2_v2 fails live compilation (002031/42601: unsupported + # subquery type, verified 2026-09-26 on tpchavoc SF0.01 via EXPLAIN USING + # TEXT through the adapter query path); the sqlglot parse check cannot + # catch this engine-side limit, so the skip stays until a rewrite avoids + # the correlated scalar subquery and compiles live on Snowflake. + # 1_v7 likewise keeps its skip until _rewrite_array_aggregation compiles + # live on Snowflake. + "snowflake": { + "1_v7": "Snowflake does not support DuckDB LIST aggregates and LIST lambda functions in this variant.", + "2_v2": "Snowflake rejects the correlated scalar subquery in this grouped variant.", + }, + # Databricks 1_v7 keeps its skip until _rewrite_array_aggregation compiles + # live on Databricks. + "databricks": { + "1_v7": "Databricks SQL has array aggregation but not DuckDB's LIST_SUM and LIST_ZIP functions used here.", + }, +} + +for _platform, _skips in CLOUD_TPCHAVOC_SKIPS.items(): + for _query_id, _reason in _skips.items(): + REGISTRY.register( + CompatibilityDecision( + rule_id=f"execution_filter.{_platform}.tpchavoc.{_query_id}", + action=CompatAction.SKIP_QUERY, + support_level=SupportLevel.SKIPPED_QUERY, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=SkipQueryPayload(reason=_reason, query_id=_query_id), + reason=_reason, + ), + Phase.EXECUTION_FILTER, + _platform, + benchmark="tpchavoc", + query_id=_query_id, + ) diff --git a/benchbox/sql_compat/rules/execution_filter/cloud_transaction_primitives.py b/benchbox/sql_compat/rules/execution_filter/cloud_transaction_primitives.py new file mode 100644 index 0000000000..b50fd551bb --- /dev/null +++ b/benchbox/sql_compat/rules/execution_filter/cloud_transaction_primitives.py @@ -0,0 +1,57 @@ +"""Snowflake and BigQuery execution-filter rules for transaction_primitives operation gaps.""" + +from __future__ import annotations + +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import CompatibilityDecision, FailureMode, SkipQueryPayload, SupportLevel +from benchbox.sql_compat.registry import REGISTRY + +_SAVEPOINT = "{engine} has no SAVEPOINT statement." +_ISOLATION = "{engine} rejects SET TRANSACTION ISOLATION LEVEL." +_TEMP_TABLE = ( + "BigQuery runs each multi-statement script as chained single-statement jobs, " + "so a TEMP TABLE created in one statement is invisible to the next." +) + +_OPERATIONS = { + "transaction_savepoint_nested": _SAVEPOINT, + "transaction_savepoint_deep_nesting": _SAVEPOINT, + "transaction_isolation_read_committed": _ISOLATION, + "transaction_isolation_repeatable_read": _ISOLATION, + "transaction_isolation_serializable": _ISOLATION, +} + +_BIGQUERY_ONLY_OPERATIONS = { + "transaction_create_temp_table": _TEMP_TABLE, +} + +SNOWFLAKE_TRANSACTION_PRIMITIVES_OPERATION_SKIPS = { + query_id: reason.format(engine="Snowflake") for query_id, reason in _OPERATIONS.items() +} +BIGQUERY_TRANSACTION_PRIMITIVES_OPERATION_SKIPS = { + query_id: reason.format(engine="BigQuery") for query_id, reason in _OPERATIONS.items() +} | dict(_BIGQUERY_ONLY_OPERATIONS) + +for _platform, _skips in ( + ("snowflake", SNOWFLAKE_TRANSACTION_PRIMITIVES_OPERATION_SKIPS), + ("bigquery", BIGQUERY_TRANSACTION_PRIMITIVES_OPERATION_SKIPS), +): + for _query_id, _reason in _skips.items(): + REGISTRY.register( + CompatibilityDecision( + rule_id=f"execution_filter.{_platform}.transaction_primitives.{_query_id}", + action=CompatAction.SKIP_QUERY, + support_level=SupportLevel.SKIPPED_QUERY, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=SkipQueryPayload( + reason=f"{_reason} Evidence: live {_platform} probe on 2026-09-26.", + query_id=_query_id, + ), + reason=_reason, + ), + Phase.EXECUTION_FILTER, + _platform, + benchmark="transaction_primitives", + query_id=_query_id, + ) diff --git a/benchbox/sql_compat/rules/execution_filter/databricks_transaction_primitives.py b/benchbox/sql_compat/rules/execution_filter/databricks_transaction_primitives.py new file mode 100644 index 0000000000..b4bc145dc4 --- /dev/null +++ b/benchbox/sql_compat/rules/execution_filter/databricks_transaction_primitives.py @@ -0,0 +1,45 @@ +"""Databricks execution-filter rules for transaction_primitives operation gaps.""" + +from __future__ import annotations + +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import CompatibilityDecision, FailureMode, SkipQueryPayload, SupportLevel +from benchbox.sql_compat.registry import REGISTRY + +_SAVEPOINT = "Databricks SQL multi-statement transactions do not support SAVEPOINT." +_ISOLATION = "Databricks SQL rejects SET TRANSACTION ISOLATION LEVEL." +_TRUNCATE = "Databricks SQL does not allow TRUNCATE TABLE inside a transaction (TRANSACTION_NOT_SUPPORTED.COMMAND)." +_TEMP_TABLE = ( + "Databricks SQL rejects CREATE TEMPORARY TABLE ... IF NOT EXISTS, and temporary views are not allowed inside " + "a transaction (TRANSACTION_NOT_SUPPORTED.COMMAND)." +) + +DATABRICKS_TRANSACTION_PRIMITIVES_OPERATION_SKIPS = { + "transaction_savepoint_nested": _SAVEPOINT, + "transaction_savepoint_deep_nesting": _SAVEPOINT, + "transaction_isolation_read_committed": _ISOLATION, + "transaction_isolation_repeatable_read": _ISOLATION, + "transaction_isolation_serializable": _ISOLATION, + "transaction_truncate_in_transaction": _TRUNCATE, + "transaction_create_temp_table": _TEMP_TABLE, +} + +for _query_id, _reason in DATABRICKS_TRANSACTION_PRIMITIVES_OPERATION_SKIPS.items(): + REGISTRY.register( + CompatibilityDecision( + rule_id=f"execution_filter.databricks.transaction_primitives.{_query_id}", + action=CompatAction.SKIP_QUERY, + support_level=SupportLevel.SKIPPED_QUERY, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=SkipQueryPayload( + reason=f"{_reason} Evidence: live Databricks SQL warehouse probe on 2026-09-26.", + query_id=_query_id, + ), + reason=_reason, + ), + Phase.EXECUTION_FILTER, + "databricks", + benchmark="transaction_primitives", + query_id=_query_id, + ) diff --git a/benchbox/sql_compat/rules/execution_filter/datafusion_tpchavoc.py b/benchbox/sql_compat/rules/execution_filter/datafusion_tpchavoc.py index 2ab5e89229..9c20826ec0 100644 --- a/benchbox/sql_compat/rules/execution_filter/datafusion_tpchavoc.py +++ b/benchbox/sql_compat/rules/execution_filter/datafusion_tpchavoc.py @@ -34,12 +34,10 @@ "1_v7": "DataFusion has no `LIST` aggregate used by this variant (`Invalid function 'list'`).", "4_v7": "DataFusion physical planning does not support EXISTS in this variant shape.", "4_v10": "DataFusion physical planning does not support EXISTS in this variant shape.", - "6_v2": "DataFusion rejects the generated empty-tuple sub-query (`Empty tuple not supported yet`).", "7_v1": "DataFusion physical planning does not support the scalar sub-query in this variant shape.", "8_v1": "DataFusion `scalar_subquery_to_join` optimizer rule fails to resolve a sub-query field for this variant.", "9_v1": "DataFusion physical planning does not support the scalar sub-query in this variant shape.", "12_v1": "DataFusion `scalar_subquery_to_join` optimizer rule reports an ambiguous `count(*)` for this variant.", - "14_v2": "DataFusion rejects the generated empty-tuple sub-query (`Empty tuple not supported yet`).", "14_v8": "DataFusion physical planning does not support EXISTS in this variant shape.", "16_v7": "DataFusion physical planning does not support the IN sub-query in this variant shape.", "16_v10": "DataFusion physical planning does not support the IN sub-query in this variant shape.", diff --git a/benchbox/sql_compat/rules/execution_filter/postgres_tpchavoc.py b/benchbox/sql_compat/rules/execution_filter/postgres_tpchavoc.py index c250d96563..ec7bf3c788 100644 --- a/benchbox/sql_compat/rules/execution_filter/postgres_tpchavoc.py +++ b/benchbox/sql_compat/rules/execution_filter/postgres_tpchavoc.py @@ -9,22 +9,12 @@ POSTGRES_TPCHAVOC_SKIPS: dict[str, str] = { "1_v7": "PostgreSQL-family engines do not provide DuckDB's `LIST()` aggregate used by this variant.", - "2_v5": "PostgreSQL-family engines report ambiguous `ps_partkey` resolution for this variant shape.", - "3_v7": "PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING.", - "4_v7": "PostgreSQL-family engines do not allow the select-list alias `order_count` in HAVING.", - "5_v7": "PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING.", "5_v9": "PostgreSQL-family engines reject aggregate expressions inside window definitions.", - "7_v7": "PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING.", "7_v9": "PostgreSQL-family engines reject aggregate expressions inside window definitions.", - "9_v7": "PostgreSQL-family engines do not allow the select-list alias `sum_profit` in HAVING.", "9_v9": "PostgreSQL-family engines reject aggregate expressions inside window definitions.", - "10_v7": "PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING.", "10_v9": "PostgreSQL-family engines reject aggregate expressions inside window definitions.", - "11_v7": "PostgreSQL-family engines do not allow the select-list alias `value` in WHERE.", "11_v9": "PostgreSQL-family engines reject this window expression in WHERE.", "13_v9": "PostgreSQL-family engines reject aggregate expressions inside window definitions.", - "17_v2": "PostgreSQL-family engines report ambiguous `l_partkey` resolution for this variant shape.", - "17_v4": "PostgreSQL-family engines do not provide Oracle's `dual` table.", } _POSTGRES_TPCHAVOC_PLATFORMS = ("pg-duckdb", "pg-mooncake", "timescaledb") diff --git a/benchbox/sql_compat/rules/query_adapter/clickhouse_tpchavoc_rewrites.py b/benchbox/sql_compat/rules/query_adapter/clickhouse_tpchavoc_rewrites.py new file mode 100644 index 0000000000..0be774221c --- /dev/null +++ b/benchbox/sql_compat/rules/query_adapter/clickhouse_tpchavoc_rewrites.py @@ -0,0 +1,27 @@ +"""Document ClickHouse TPC-Havoc query rewrites.""" + +from __future__ import annotations + +from benchbox.core.tpchavoc.dialect_compat import CLICKHOUSE_FILTER_VARIANT_IDS +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import CompatibilityDecision, FailureMode, RewriteQueryPayload, SupportLevel +from benchbox.sql_compat.registry import REGISTRY + +for _platform in ("clickhouse-local", "clickhouse-server", "clickhouse-cloud"): + for _query_id in CLICKHOUSE_FILTER_VARIANT_IDS: + _reason = "Use ClickHouse countIf and null-preserving aggregate If combinators instead of FILTER" + REGISTRY.register( + CompatibilityDecision( + rule_id=f"query_adapter.{_platform}.tpchavoc.{_query_id}.filtered_aggregate", + action=CompatAction.REWRITE_QUERY, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=RewriteQueryPayload(transformer_id="tpchavoc_dialect_rewriter", description=_reason), + reason=_reason, + ), + Phase.QUERY_ADAPTER, + _platform, + benchmark="tpchavoc", + query_id=_query_id, + ) diff --git a/benchbox/sql_compat/rules/query_adapter/cloud_tpchavoc_rewrites.py b/benchbox/sql_compat/rules/query_adapter/cloud_tpchavoc_rewrites.py new file mode 100644 index 0000000000..78cfeca025 --- /dev/null +++ b/benchbox/sql_compat/rules/query_adapter/cloud_tpchavoc_rewrites.py @@ -0,0 +1,43 @@ +"""Document cloud TPC-Havoc query rewrites applied by the benchmark runtime.""" + +from __future__ import annotations + +from benchbox.core.tpchavoc.cloud_compat import BIGQUERY_FILTER_IDS +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import CompatibilityDecision, FailureMode, RewriteQueryPayload, SupportLevel +from benchbox.sql_compat.registry import REGISTRY + +_RULES = ( + *( + ("bigquery", query_id, "filtered_aggregate", "Use conditional aggregates instead of FILTER") + for query_id in BIGQUERY_FILTER_IDS + ), + *( + ("bigquery", query_id, "one_row_source", "Add a one-row source to subqueries without FROM") + for query_id in ("7_v8", "12_v8", "13_v8", "18_v4") + ), + *( + ("bigquery", query_id, "qualified_having", "Qualify the input column hidden by a SELECT alias") + for query_id in ("5_v4", "11_v4") + ), + ("bigquery", "3_v9", "window_null_order", "Remove explicit null ordering from the window order key"), + ("snowflake", "6_v2", "empty_group", "Drop the unsupported empty grouping set"), + ("snowflake", "14_v2", "empty_group", "Drop the unsupported empty grouping set"), +) + +for _platform, _query_id, _slug, _reason in _RULES: + REGISTRY.register( + CompatibilityDecision( + rule_id=f"query_adapter.{_platform}.tpchavoc.{_query_id}.{_slug}", + action=CompatAction.REWRITE_QUERY, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=RewriteQueryPayload(transformer_id="tpchavoc_cloud_query_rewriter", description=_reason), + reason=_reason, + ), + Phase.QUERY_ADAPTER, + _platform, + benchmark="tpchavoc", + query_id=_query_id, + ) diff --git a/benchbox/sql_compat/rules/query_adapter/datafusion_tpchavoc_rewrites.py b/benchbox/sql_compat/rules/query_adapter/datafusion_tpchavoc_rewrites.py new file mode 100644 index 0000000000..5590372906 --- /dev/null +++ b/benchbox/sql_compat/rules/query_adapter/datafusion_tpchavoc_rewrites.py @@ -0,0 +1,26 @@ +"""Document DataFusion TPC-Havoc query rewrites.""" + +from __future__ import annotations + +from benchbox.core.tpchavoc.dialect_compat import DATAFUSION_EMPTY_GROUP_VARIANT_IDS +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import CompatibilityDecision, FailureMode, RewriteQueryPayload, SupportLevel +from benchbox.sql_compat.registry import REGISTRY + +for _query_id in DATAFUSION_EMPTY_GROUP_VARIANT_IDS: + _reason = "Drop unsupported empty GROUP BY () while preserving the global aggregate and HAVING" + REGISTRY.register( + CompatibilityDecision( + rule_id=f"query_adapter.datafusion.tpchavoc.{_query_id}.empty_group", + action=CompatAction.REWRITE_QUERY, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=RewriteQueryPayload(transformer_id="tpchavoc_dialect_rewriter", description=_reason), + reason=_reason, + ), + Phase.QUERY_ADAPTER, + "datafusion", + benchmark="tpchavoc", + query_id=_query_id, + ) diff --git a/benchbox/sql_compat/rules/query_adapter/postgres_tpchavoc_rewrites.py b/benchbox/sql_compat/rules/query_adapter/postgres_tpchavoc_rewrites.py new file mode 100644 index 0000000000..d952b374ac --- /dev/null +++ b/benchbox/sql_compat/rules/query_adapter/postgres_tpchavoc_rewrites.py @@ -0,0 +1,63 @@ +"""Document PostgreSQL-family TPC-Havoc query rewrites.""" + +from __future__ import annotations + +from benchbox.core.tpchavoc.dialect_compat import ( + POSTGRES_ALIAS_VARIANT_IDS, + POSTGRES_DUAL_VARIANT_IDS, + POSTGRES_QUALIFIED_COLUMNS, +) +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import CompatibilityDecision, FailureMode, RewriteQueryPayload, SupportLevel +from benchbox.sql_compat.registry import REGISTRY + +for _platform in ("pg-duckdb", "pg-mooncake", "timescaledb"): + for _query_id in POSTGRES_ALIAS_VARIANT_IDS: + _reason = "Inline the aggregate expression where PostgreSQL rejects a SELECT alias in HAVING or WHERE" + REGISTRY.register( + CompatibilityDecision( + rule_id=f"query_adapter.{_platform}.tpchavoc.{_query_id}.inline_select_alias", + action=CompatAction.REWRITE_QUERY, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=RewriteQueryPayload(transformer_id="tpchavoc_dialect_rewriter", description=_reason), + reason=_reason, + ), + Phase.QUERY_ADAPTER, + _platform, + benchmark="tpchavoc", + query_id=_query_id, + ) + for _query_id in POSTGRES_QUALIFIED_COLUMNS: + _reason = "Qualify translated column references that are ambiguous to PostgreSQL" + REGISTRY.register( + CompatibilityDecision( + rule_id=f"query_adapter.{_platform}.tpchavoc.{_query_id}.qualify_columns", + action=CompatAction.REWRITE_QUERY, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=RewriteQueryPayload(transformer_id="tpchavoc_dialect_rewriter", description=_reason), + reason=_reason, + ), + Phase.QUERY_ADAPTER, + _platform, + benchmark="tpchavoc", + query_id=_query_id, + ) + for _query_id in POSTGRES_DUAL_VARIANT_IDS: + _reason = "Use an explicit VALUES relation instead of the generated dual-style source" + REGISTRY.register( + CompatibilityDecision( + rule_id=f"query_adapter.{_platform}.tpchavoc.{_query_id}.values_source", + action=CompatAction.REWRITE_QUERY, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=RewriteQueryPayload(transformer_id="tpchavoc_dialect_rewriter", description=_reason), + reason=_reason, + ), + Phase.QUERY_ADAPTER, + _platform, + benchmark="tpchavoc", + query_id=_query_id, + ) diff --git a/benchbox/sql_compat/rules/query_adapter/spark_tpchavoc_rewrites.py b/benchbox/sql_compat/rules/query_adapter/spark_tpchavoc_rewrites.py index 506157c1ef..8bb10bee6f 100644 --- a/benchbox/sql_compat/rules/query_adapter/spark_tpchavoc_rewrites.py +++ b/benchbox/sql_compat/rules/query_adapter/spark_tpchavoc_rewrites.py @@ -86,7 +86,7 @@ ), ] -for _platform in ("spark", "velox", "lakesail"): +for _platform in ("spark", "velox", "lakesail", "databricks"): for _qid, _slug, _reason in _REWRITES: REGISTRY.register( CompatibilityDecision( diff --git a/benchbox/sql_compat/rules/query_compile/__init__.py b/benchbox/sql_compat/rules/query_compile/__init__.py new file mode 100644 index 0000000000..ca9b1e8801 --- /dev/null +++ b/benchbox/sql_compat/rules/query_compile/__init__.py @@ -0,0 +1 @@ +"""Rules documenting semantic normalization in the query compilation hook.""" diff --git a/benchbox/sql_compat/rules/query_compile/sqlite_tpch_discount_bounds.py b/benchbox/sql_compat/rules/query_compile/sqlite_tpch_discount_bounds.py new file mode 100644 index 0000000000..7ceb4828d2 --- /dev/null +++ b/benchbox/sql_compat/rules/query_compile/sqlite_tpch_discount_bounds.py @@ -0,0 +1,28 @@ +"""SQLite TPC-H Q6 exact discount-bound normalization at QUERY_COMPILE. + +The existing SQLite translation hook applies this rewrite. SQLite REAL +arithmetic can move inclusive decimal endpoints inward and silently drop rows. +""" + +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import Phase +from benchbox.sql_compat.decision import CompatibilityDecision, FailureMode, RewriteQueryPayload, SupportLevel +from benchbox.sql_compat.registry import REGISTRY + +REGISTRY.register( + CompatibilityDecision( + rule_id="query_compile.sqlite.tpch.q6_exact_discount_bounds", + action=CompatAction.REWRITE_QUERY, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.SILENT_CORRUPTION, + payload=RewriteQueryPayload( + transformer_id="benchbox.utils.dialect_utils._fold_sqlite_discount_bounds", + description="Fold isolated numeric literal Add/Sub l_discount BETWEEN bounds to exact numeric literals", + ), + reason="SQLite REAL arithmetic can exclude inclusive TPC-H Q6 discount endpoints; normalize exact literal bounds", + ), + Phase.QUERY_COMPILE, + "sqlite", + benchmark="tpch", + query_id="6", +) diff --git a/benchbox/sql_compat/rules/query_source/nyctaxi_variants.py b/benchbox/sql_compat/rules/query_source/nyctaxi_variants.py index 94da3953fe..a7244fa603 100644 --- a/benchbox/sql_compat/rules/query_source/nyctaxi_variants.py +++ b/benchbox/sql_compat/rules/query_source/nyctaxi_variants.py @@ -92,6 +92,19 @@ GROUP BY FLOOR(TIMESTAMPDIFF(SECOND, pickup_datetime, dropoff_datetime) / 60 / 5) * 5 ORDER BY duration_bucket_min""" +STARROCKS_FHV_BASE_VOLUME_SQL = """\ + SELECT + dispatching_base_num, + COUNT(*) as trip_count, + AVG(TIMESTAMPDIFF(SECOND, pickup_datetime, dropoff_datetime) / 60) as avg_duration_min + FROM fhv_trips + WHERE pickup_datetime >= '{start_date}' + AND pickup_datetime < '{end_date}' + AND dropoff_datetime > pickup_datetime + GROUP BY dispatching_base_num + ORDER BY trip_count DESC + LIMIT 25""" + # --------------------------------------------------------------------------- # ClickHouse variants # EXTRACT(HOUR FROM ts) → toHour(ts) @@ -163,6 +176,19 @@ GROUP BY FLOOR(dateDiff('second', pickup_datetime, dropoff_datetime) / 60 / 5) * 5 ORDER BY duration_bucket_min""" +CLICKHOUSE_FHV_BASE_VOLUME_SQL = """\ + SELECT + dispatching_base_num, + COUNT(*) as trip_count, + AVG(dateDiff('second', pickup_datetime, dropoff_datetime) / 60) as avg_duration_min + FROM fhv_trips + WHERE pickup_datetime >= '{start_date}' + AND pickup_datetime < '{end_date}' + AND dropoff_datetime > pickup_datetime + GROUP BY dispatching_base_num + ORDER BY trip_count DESC + LIMIT 25""" + # --------------------------------------------------------------------------- # StarRocks rule registrations # --------------------------------------------------------------------------- @@ -257,6 +283,24 @@ query_id="trip-duration-analysis", ) +REGISTRY.register( + CompatibilityDecision( + rule_id="query_source.starrocks.nyctaxi.fhv_base_volume_timestampdiff_variant", + action=CompatAction.SELECT_VARIANT, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=SelectVariantPayload( + variant_key="starrocks", + variant_sql=STARROCKS_FHV_BASE_VOLUME_SQL, + ), + reason="StarRocks lacks EXTRACT(EPOCH FROM interval); replace with TIMESTAMPDIFF(SECOND, ...)", + ), + _P, + "starrocks", + benchmark=_B, + query_id="fhv-base-volume", +) + # --------------------------------------------------------------------------- # ClickHouse rule registrations # --------------------------------------------------------------------------- @@ -314,3 +358,21 @@ benchmark=_B, query_id="rush-hour-analysis", ) + +REGISTRY.register( + CompatibilityDecision( + rule_id="query_source.clickhouse.nyctaxi.fhv_base_volume_datediff_variant", + action=CompatAction.SELECT_VARIANT, + support_level=SupportLevel.REWRITTEN, + failure_mode=FailureMode.UNSUPPORTED_FEATURE, + payload=SelectVariantPayload( + variant_key="clickhouse", + variant_sql=CLICKHOUSE_FHV_BASE_VOLUME_SQL, + ), + reason="ClickHouse lacks EXTRACT(EPOCH FROM interval); replace with dateDiff('second', ...)", + ), + _P, + "clickhouse", + benchmark=_B, + query_id="fhv-base-volume", +) diff --git a/benchbox/tpcds_obt.py b/benchbox/tpcds_obt.py index ef15bfe088..bbf0ee4ebd 100644 --- a/benchbox/tpcds_obt.py +++ b/benchbox/tpcds_obt.py @@ -10,6 +10,11 @@ class TPCDSOBT(BaseBenchmark): """Public API wrapper for the TPC-DS OBT benchmark.""" + # Mirror the implementation's run-time contract so orchestration and the + # load guard see it on the wrapper too. + GENERATES_OWN_OUTPUT = TPCDSOBTBenchmark.GENERATES_OWN_OUTPUT + REQUIRED_LOADED_TABLES = TPCDSOBTBenchmark.REQUIRED_LOADED_TABLES + def __init__( self, scale_factor: float = 1.0, diff --git a/benchbox/tpchavoc.py b/benchbox/tpchavoc.py index 685eb36cca..00712a643f 100644 --- a/benchbox/tpchavoc.py +++ b/benchbox/tpchavoc.py @@ -89,6 +89,10 @@ def generate_data(self) -> list[Union[str, Path]]: """ return self._impl.generate_data() + def get_platform_skip_queries(self, platform_name: str) -> list[str]: + """Return query IDs that compatibility policy skips for ``platform_name`` (selector or display name).""" + return self._impl.get_platform_skip_queries(platform_name) + def get_queries(self, dialect: Optional[str] = None) -> dict[str, str]: """Get all TPC-Havoc benchmark queries (base TPC-H queries). @@ -108,6 +112,7 @@ def get_query( seed: Optional[int] = None, scale_factor: Optional[float] = None, dialect: Optional[str] = None, + base_dialect: Optional[str] = None, **kwargs, ) -> str: """Get a specific TPC-Havoc benchmark query. @@ -118,6 +123,7 @@ def get_query( seed: Random number generator seed for parameter generation scale_factor: Scale factor for parameter calculations dialect: Target SQL dialect + base_dialect: Source SQL dialect for translation **kwargs: Additional parameters Returns: @@ -153,6 +159,8 @@ def get_query( query_id, seed=seed, scale_factor=scale_factor, + dialect=dialect, + base_dialect=base_dialect, **kwargs, ) diff --git a/benchbox/tsbs_devops.py b/benchbox/tsbs_devops.py index 30620b2f8c..25fa6d7e2c 100644 --- a/benchbox/tsbs_devops.py +++ b/benchbox/tsbs_devops.py @@ -123,7 +123,8 @@ def get_queries(self, dialect: Optional[str] = None) -> dict[str, str]: """Get all benchmark queries. Args: - dialect: Target SQL dialect (not used - queries are standard SQL) + dialect: Target SQL dialect; cloud dialects are translated from the + DuckDB source Returns: Dictionary mapping query IDs to query strings diff --git a/benchbox/utils/VERSION_MANAGEMENT.md b/benchbox/utils/VERSION_MANAGEMENT.md index cf87688829..472f10da0a 100644 --- a/benchbox/utils/VERSION_MANAGEMENT.md +++ b/benchbox/utils/VERSION_MANAGEMENT.md @@ -4,7 +4,7 @@ This document outlines the version management strategy for BenchBox, ensuring consistency across all version references and providing clear guidelines for version updates. -Current release: `v0.4.1`. +Current release: `v0.4.2`. ## Version Sources diff --git a/benchbox/utils/binary_manifest.py b/benchbox/utils/binary_manifest.py new file mode 100644 index 0000000000..6046f4f7b6 --- /dev/null +++ b/benchbox/utils/binary_manifest.py @@ -0,0 +1,121 @@ +"""Verify the complete bundled-generator tree shipped inside BenchBox.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from collections.abc import Iterable, Mapping +from pathlib import Path, PurePosixPath + +MANIFEST_NAME = "SHA256MANIFEST.json" +DEFAULT_ROOT = Path(__file__).resolve().parents[1] / "_binaries" + + +def _unique_object(pairs: Iterable[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate manifest key: {key}") + result[key] = value + return result + + +def read_binary_manifest(payload: bytes) -> dict[str, str]: + """Validate the manifest schema, relative paths, and SHA-256 digests.""" + manifest = json.loads(payload, object_pairs_hook=_unique_object) + if ( + not isinstance(manifest, dict) + or set(manifest) != {"schema", "files"} + or type(manifest["schema"]) is not int + or manifest["schema"] != 1 + or not isinstance(manifest["files"], dict) + or not manifest["files"] + ): + raise ValueError("invalid bundled binary manifest schema") + for name, digest in manifest["files"].items(): + path = PurePosixPath(name) + if ( + not name + or name == MANIFEST_NAME + or path.is_absolute() + or path.as_posix() != name + or ".." in path.parts + or any(character in name for character in ("\\", ":", "\0")) + or not isinstance(digest, str) + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + ): + raise ValueError(f"invalid bundled binary manifest entry: {name}") + return manifest["files"] + + +def _tree_files(root: Path) -> dict[str, Path]: + if not root.is_dir() or root.is_symlink(): + raise ValueError(f"bundled binary root is not a regular directory: {root}") + files = {} + for path in sorted(root.rglob("*")): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + raise ValueError(f"non-regular bundled binary path: {path}") + if path.is_file() and path.relative_to(root).as_posix() != MANIFEST_NAME: + files[path.relative_to(root).as_posix()] = path + if not files: + raise ValueError("bundled binary tree is empty") + return files + + +def build_binary_manifest(root: Path) -> bytes: + """Generate deterministic hashes for every bundled file except the manifest.""" + hashes = {} + for name, path in _tree_files(root).items(): + with path.open("rb") as stream: + hashes[name] = hashlib.file_digest(stream, "sha256").hexdigest() + payload = json.dumps({"schema": 1, "files": hashes}, indent=2, sort_keys=True).encode() + b"\n" + read_binary_manifest(payload) + return payload + + +def verify_binary_payload(manifest: bytes, files: Mapping[str, bytes]) -> None: + """Verify exact file membership and bytes, including files read from archives.""" + expected = read_binary_manifest(manifest) + if set(expected) != set(files): + missing = sorted(set(expected) - set(files)) + extra = sorted(set(files) - set(expected)) + raise ValueError(f"bundled binary membership differs: missing={missing}, extra={extra}") + for name, digest in expected.items(): + if hashlib.sha256(files[name]).hexdigest() != digest: + raise ValueError(f"bundled binary hash differs: {name}") + + +def verify_binary_tree(root: Path = DEFAULT_ROOT) -> None: + """Verify an installed or source package against its bundled manifest.""" + files = _tree_files(root) + manifest_path = root / MANIFEST_NAME + if manifest_path.is_symlink() or not manifest_path.is_file(): + raise ValueError("bundled binary manifest is missing or not a regular file") + expected = read_binary_manifest(manifest_path.read_bytes()) + if set(expected) != set(files): + raise ValueError("bundled binary membership differs from the manifest") + for name, path in files.items(): + with path.open("rb") as stream: + actual = hashlib.file_digest(stream, "sha256").hexdigest() + if actual != expected[name]: + raise ValueError(f"bundled binary hash differs: {name}") + + +def main(argv: list[str] | None = None) -> int: + from benchbox.utils.printing import emit + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) + args = parser.parse_args(argv) + try: + verify_binary_tree(args.root) + except (OSError, ValueError) as exc: + parser.exit(1, f"Bundled binary verification failed: {exc}\n") + emit("Bundled binary membership and SHA-256 hashes verified.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchbox/utils/cloud_storage.py b/benchbox/utils/cloud_storage.py index 6311db066d..0f0aa97a87 100644 --- a/benchbox/utils/cloud_storage.py +++ b/benchbox/utils/cloud_storage.py @@ -775,6 +775,28 @@ def create_path_handler(path: Union[str, Path]) -> Union[Path, CloudPath, Databr raise ValueError(f"Invalid cloud path format '{path}': {e}") from e +def normalize_output_dir( + path: Union[str, Path, None], +) -> Union[Path, CloudPath, DatabricksPath, CloudStagingPath, None]: + """Coerce a benchmark output directory without dropping cloud staging wrappers. + + Several benchmarks historically stored ``output_dir`` via ``Path(...)``, + which stringifies a ``CloudStagingPath``/``DatabricksPath`` down to its + local cache directory and silently discards the cloud upload target the + orchestrator resolved at construction time. Route those assignments + through here (or :func:`create_path_handler` directly) so an already-built + handler passes through untouched while plain strings still become + :class:`Path` and ``None`` stays ``None``. + """ + if path is None: + return None + if isinstance(path, (DatabricksPath, CloudStagingPath)): + return path + if isinstance(path, Path): + return path + return create_path_handler(path) + + def get_remote_fs_adapter(remote_path: str) -> RemoteFileSystemAdapter: """Create a RemoteFileSystemAdapter for a remote path. diff --git a/benchbox/utils/dependencies.py b/benchbox/utils/dependencies.py index 80ea6383d8..241bf43b58 100644 --- a/benchbox/utils/dependencies.py +++ b/benchbox/utils/dependencies.py @@ -8,6 +8,7 @@ Licensed under the MIT License. See LICENSE file in the project root for details. """ +import os import sys from collections.abc import Sequence from functools import lru_cache @@ -289,12 +290,16 @@ def check_platform_dependencies(platform: str, packages: Optional[Sequence[str]] missing: list[str] = [] for package in platforms_packages: + # Optional native modules can change cwd before failing to load. + original_cwd = os.getcwd() try: # Use mapping if available, otherwise fall back to simple hyphen-to-underscore replacement import_name = PACKAGE_IMPORT_NAMES.get(package, package.replace("-", "_")) __import__(import_name) except ImportError: missing.append(package) + finally: + os.chdir(original_cwd) return len(missing) == 0, missing diff --git a/benchbox/utils/dependencies.yaml b/benchbox/utils/dependencies.yaml index 2f9d92e101..cf7aa3573d 100644 --- a/benchbox/utils/dependencies.yaml +++ b/benchbox/utils/dependencies.yaml @@ -39,6 +39,12 @@ platform_to_extra: singlestore: singlestore postgresql: postgresql postgres: postgresql + timescaledb: postgresql + pg-duckdb: postgresql + pg-mooncake: postgresql + paradedb: postgresql + citus: postgresql + cedardb: postgresql questdb: questdb dataframe_dependency_groups: pandas: diff --git a/benchbox/utils/dialect_utils.py b/benchbox/utils/dialect_utils.py index 083bfed5d7..7ebf255fbd 100644 --- a/benchbox/utils/dialect_utils.py +++ b/benchbox/utils/dialect_utils.py @@ -5,12 +5,14 @@ """ import hashlib +import math import re from collections import Counter from collections.abc import Iterator, Sequence from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass +from decimal import Context, Decimal, DecimalException, Inexact from typing import Callable #: Translation workload scopes. ``schema_ddl`` marks schema-creation DDL while @@ -18,6 +20,16 @@ SCHEMA_DDL_SCOPE = "schema_ddl" WORKLOAD_QUERY_SCOPE = "workload_query" +#: Normalized sqlglot targets translated with ``identify=False``. These engines +#: fold unquoted identifiers in a way that matches BenchBox's generated DDL, +#: while quoting would preserve a case the engine-side unquoted SQL cannot +#: find: ClickHouse is case-sensitive without folding, PostgreSQL/DataFusion +#: fold to lowercase, Snowflake folds to UPPERCASE, and Exasol folds to +#: UPPERCASE. Reserved words stay safe: each generator's RESERVED_KEYWORDS +#: still quotes them. Shared with ``benchbox/platforms/base/dialect_translation.py``; +#: keep both call sites on this constant instead of parallel literals. +NO_IDENTIFY_DIALECTS = frozenset({"clickhouse", "postgres", "snowflake", "exasol"}) + def _resolve_translation_scope(scope: str | None) -> str: """Return the effective workload scope, defaulting to benchmark queries.""" @@ -263,6 +275,83 @@ def _restore_group_order_by_all_keyword(query: str) -> str: ) +def _fold_sqlite_discount_bounds(query: str) -> str: + """Keep inclusive TPC-H discount boundaries exact before SQLite REAL arithmetic. + + A bound is folded only when it is a ``+``/``-`` expression of at most four plain decimal + literals (``0.06 - 0.01``), each below 100 with at most six decimals. SQLite evaluates + those as REAL. At that size double rounding error is about 1e-14, so the exact decimal + result is computed in a trapped-inexact context and the bound is replaced only when the + double result is finite and within 1e-12 of it. Integer or mixed bounds, other operators, + larger or longer literals, scientific notation and negative results keep SQLite's + semantics and are left alone. The rewrite is scoped to queries that read a table named ``lineitem``, the TPC-H table + whose ``l_discount`` is ``DECIMAL(15,2)``, so a query over any other table is left alone; + the TPC-Havoc Q6 variants read ``lineitem`` and are folded. + + The folded bound is the exact decimal endpoint, so the comparison follows exact decimal + semantics in both directions. SQLite's noisy endpoint can sit just below the exact value + (dropping an endpoint row, the Q6 defect) or just above it. A stored value inside that + noise band, about 1e-16 from the endpoint, is therefore decided by the exact endpoint. + TPC-H discounts are ``DECIMAL(15,2)``, so no stored value lies in that band. + """ + # Most SQLite queries need no extra parse. The AST, not this hint, selects rewrites. + if not re.search(r"\bl_discount\b", query, re.IGNORECASE) or not re.search(r"\bBETWEEN\b", query, re.IGNORECASE): + return query + + import sqlglot + from sqlglot import exp + + context = Context(prec=60, traps=[Inexact]) + max_leaves = 4 + noise = 1e-12 + leaves = 0 + + def evaluate(node: "exp.Expression") -> "tuple[Decimal, float] | None": + """Return the exact decimal value and the IEEE-double value of a decimal +/- tree.""" + nonlocal leaves + node = node.unnest() + if isinstance(node, exp.Literal): + leaves += 1 + if leaves > max_leaves or not node.is_number or not re.fullmatch(r"\d{1,2}\.\d{1,6}", node.name): + return None + return Decimal(node.name), float(node.name) + if isinstance(node, (exp.Add, exp.Sub)): + left, right = evaluate(node.this), evaluate(node.expression) + if left is None or right is None: + return None + try: + if isinstance(node, exp.Add): + return context.add(left[0], right[0]), left[1] + right[1] + return context.subtract(left[0], right[0]), left[1] - right[1] + except DecimalException: + return None + return None + + tree = sqlglot.parse_one(query, read="sqlite") + # A string or comment that merely mentions the table must not count: look at table references. + if not any(table.name.lower() == "lineitem" for table in tree.find_all(exp.Table)): + return query + changed = False + for between in tree.find_all(exp.Between): + if not isinstance(between.this, exp.Column) or between.this.name.lower() != "l_discount": + continue + folded: list[Decimal] = [] + for bound in (between.args["low"], between.args["high"]): + leaves = 0 + value = evaluate(bound) if isinstance(bound.unnest(), (exp.Add, exp.Sub)) else None + if value is None: + break + exact, real = value + if exact < 0 or not math.isfinite(real) or abs(real - float(exact)) > noise: + break + folded.append(exact) + else: + between.set("low", exp.Literal.number(format(folded[0], "f"))) + between.set("high", exp.Literal.number(format(folded[1], "f"))) + changed = True + return tree.sql(dialect="sqlite") if changed else query + + def _fix_sqlite_unsupported_syntax(query: str) -> str: """Rewrite SQLGlot output that SQLite cannot execute.""" @@ -317,13 +406,14 @@ def replace_date_trunc(match: re.Match[str]) -> str: expression = match.group("expression").strip() return f"STRFTIME('{trunc_formats[unit]}', {expression})" - return re.sub( + query = re.sub( r"\bDATE_TRUNC\s*\(\s*['\"](?PYEAR|MONTH|DAY|HOUR|MINUTE|SECOND)['\"]\s*,\s*" r"(?P[^(),]+?)\s*\)", replace_date_trunc, query, flags=re.IGNORECASE, ) + return _fold_sqlite_discount_bounds(query) def normalize_dialect_for_sqlglot(dialect: str) -> str: @@ -334,8 +424,8 @@ def normalize_dialect_for_sqlglot(dialect: str) -> str: dialects to their closest supported equivalent. SQLGlot supports these dialects: 'athena', 'bigquery', 'clickhouse', - 'databricks', 'doris', 'drill', 'druid', 'duckdb', 'dune', 'hive', - 'materialize', 'mysql', 'oracle', 'postgres', 'presto', 'prql', + 'databricks', 'doris', 'drill', 'druid', 'duckdb', 'dune', 'exasol', + 'hive', 'materialize', 'mysql', 'oracle', 'postgres', 'presto', 'prql', 'redshift', 'risingwave', 'snowflake', 'spark', 'spark2', 'sqlite', 'starrocks', 'tableau', 'teradata', 'trino', 'tsql'. @@ -458,13 +548,8 @@ def translate_sql_query( processed_query = pre_proc(processed_query) # Translate using SQLGlot - # Some databases don't need identifier quoting: - # - ClickHouse: case-sensitive without case-folding, lowercase schema matches unquoted lowercase - # - PostgreSQL/DataFusion: unquoted identifiers are folded to lowercase by the engine - # - Snowflake: unquoted identifiers are folded to UPPERCASE by the engine, - # matching unquoted UPPER DDL; quoted lowercase would not resolve. - # Quoting preserves case which causes mismatches with folded schemas. - should_identify = identify and (tgt not in ("clickhouse", "postgres", "snowflake")) + # Targets in NO_IDENTIFY_DIALECTS skip identifier quoting (see constant). + should_identify = identify and (tgt not in NO_IDENTIFY_DIALECTS) translated = sqlglot.transpile(processed_query, read=src, write=tgt, identify=should_identify)[0] # Built-in optional post-fix: diff --git a/benchbox/validation/bundle.py b/benchbox/validation/bundle.py index b1a6b7d679..96ad925a27 100644 --- a/benchbox/validation/bundle.py +++ b/benchbox/validation/bundle.py @@ -199,6 +199,132 @@ def _load_bundle_failed_query_count(): "clickbench": _CLICKBENCH_CANONICAL_IDS, } +# Keep this stdlib-only snapshot in sync with the TPC-Havoc variant registry and +# execution-filter skip dictionaries. The public results mirror does not ship +# those modules; the unit contract test checks both copies on the source branch. +TPCHAVOC_CANONICAL_VARIANTS = frozenset(f"{query}_v{variant}" for query in range(1, 23) for variant in range(1, 11)) +TPCHAVOC_DOCUMENTED_SKIPS: dict[str, frozenset[str]] = { + "datafusion": frozenset( + [ + "12_v1", + "14_v8", + "16_v10", + "16_v7", + "17_v10", + "17_v7", + "1_v7", + "4_v10", + "4_v7", + "7_v1", + "8_v1", + "9_v1", + ] + ), + "lakesail": frozenset( + [ + "10_v1", + "11_v4", + "11_v9", + "12_v1", + "14_v2", + "14_v8", + "16_v1", + "16_v10", + "16_v7", + "17_v10", + "17_v2", + "17_v4", + "17_v7", + "1_v7", + "1_v8", + "2_v5", + "2_v7", + "3_v1", + "4_v10", + "4_v7", + "5_v4", + "6_v2", + "7_v1", + "8_v1", + "9_v1", + ] + ), + "clickhouse": frozenset( + [ + "10_v1", + "11_v4", + "13_v8", + "14_v8", + "16_v1", + "16_v4", + "17_v10", + "17_v7", + "1_v10", + "1_v7", + "3_v1", + "3_v10", + "3_v9", + "4_v10", + "4_v7", + "5_v1", + "5_v10", + "5_v4", + "7_v1", + "8_v1", + "9_v1", + ] + ), + "postgres": frozenset( + [ + "10_v9", + "11_v9", + "13_v9", + "1_v7", + "5_v9", + "7_v9", + "9_v9", + ] + ), + "snowflake": frozenset( + [ + "1_v7", + "2_v2", + ] + ), + "databricks": frozenset( + [ + "1_v7", + ] + ), + "bigquery": frozenset( + [ + "1_v7", + "2_v2", + ] + ), +} + + +def _tpchavoc_documented_skips(platform_name: Any) -> frozenset[str]: + if not isinstance(platform_name, str): + return frozenset() + platform = re.sub(r"[^a-z0-9]+", "-", platform_name.lower()).strip("-") + if platform in {"clickhouse-local", "clickhouse-server", "clickhouse-cloud"}: + return TPCHAVOC_DOCUMENTED_SKIPS["clickhouse"] + if platform in {"pg-duckdb", "pg-mooncake", "timescaledb"}: + return TPCHAVOC_DOCUMENTED_SKIPS["postgres"] + return TPCHAVOC_DOCUMENTED_SKIPS.get(platform, frozenset()) + + +def _tpchavoc_variant_has_usable_timing(query: dict) -> bool: + # A SUCCESS row with no timing (null, non-numeric, non-finite, or zero + # ms) must not satisfy variant coverage: it carries no measurement. + try: + ms = float(query.get("ms")) + except (TypeError, ValueError): + return False + return math.isfinite(ms) and ms > 0 + def _normalize_coverage_query_id(raw_id: Any) -> str | None: """Normalize a bundle query ID for coverage membership, or None. @@ -1048,7 +1174,7 @@ def _warn_cross_bundle_timing(entries: list[tuple[dict[str, Any], ValidationResu #: ``platform.compute.cache_control``. Only these platforms can produce an #: absent receipt that contradicts a declared cache state; every other #: platform keeps the legacy absent-receipt exemption. -_CACHE_RECEIPT_PLATFORMS = frozenset({"snowflake", "redshift"}) +_CACHE_RECEIPT_PLATFORMS = frozenset({"snowflake", "redshift", "databricks"}) def _platform_records_cache_receipt(platform: dict) -> bool: @@ -1242,6 +1368,27 @@ def _validate_query_coverage( observed.discard(base_id) if {f"{base_id}a", f"{base_id}b"} <= observed: observed.add(base_id) + variant_hint = "" + if normalized_id == "tpchavoc": + platform = data.get("platform") + platform_name = platform.get("name") if isinstance(platform, dict) else None + documented_skips = _tpchavoc_documented_skips(platform_name) + successful = { + _normalize_coverage_query_id(q.get("id")) + for q in queries + if isinstance(q, dict) + and isinstance(q.get("status"), str) + and q["status"].upper() in {"SUCCESS", "PASS"} + and str(q.get("run_type") or "measurement").strip().lower() == "measurement" + and _tpchavoc_variant_has_usable_timing(q) + } + missing_variants = TPCHAVOC_CANONICAL_VARIANTS - documented_skips - successful + observed = canonical - {variant.split("_v")[0] for variant in missing_variants} + if missing_variants: + shown = ", ".join(sorted(missing_variants, key=lambda v: tuple(map(int, v.split("_v"))))[:12]) + if len(missing_variants) > 12: + shown += f", … (+{len(missing_variants) - 12} more)" + variant_hint = f" (missing successful or documented-skip variants: {shown})" missing = sorted( canonical - observed, key=lambda s: [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", s)], @@ -1254,7 +1401,7 @@ def _validate_query_coverage( hint = f" ({uncounted} queries carry a non-string or blank id)" if uncounted else "" vr.error( f"benchmark {bm_id!r} covers {covered} of {len(canonical)} canonical queries{hint} " - f"(missing: {shown}); partial runs remain local artifacts " + f"(missing: {shown}){variant_hint}; partial runs remain local artifacts " "unless validated through the trusted mirror path" ) @@ -1470,6 +1617,48 @@ def _validate_tables_block(data: dict, vr: ValidationResult) -> None: vr.error(f"'tables.{name}.load_ms' must be non-negative, got {load_ms!r}") +#: Tables a benchmark's queries read, which must be loaded with rows. Kept as +#: data rather than imported from the benchmark classes so the validator stays +#: self-contained. Mirrors ``REQUIRED_LOADED_TABLES`` on the benchmark class. +_REQUIRED_LOADED_TABLES: dict[str, tuple[str, ...]] = { + "tpcds_obt": ("tpcds_sales_returns_obt",), +} + + +def _validate_required_tables(data: dict, vr: ValidationResult) -> None: + """Reject a bundle that measured without the benchmark's query tables. + + A run that loads the wrong dataset can still finish: the schema creates + the query table empty and every query succeeds with no or trivial rows. + A bundle with measured queries must therefore show these tables in its + ``tables`` block; omitting the block is not evidence that they loaded. + """ + benchmark = data.get("benchmark") + benchmark_id = str(benchmark.get("id") or "").lower() if isinstance(benchmark, dict) else "" + required = _REQUIRED_LOADED_TABLES.get(benchmark_id) + if not required: + return + tables = data.get("tables") + if not isinstance(tables, dict): + if data.get("queries"): + vr.error( + f"benchmark '{benchmark_id}' bundle has measured queries but no 'tables' block, " + f"so it cannot show that {', '.join(required)} was loaded" + ) + return + loaded = {str(name).lower(): entry for name, entry in tables.items()} + for table in required: + entry = loaded.get(table) + rows = entry.get("rows") if isinstance(entry, dict) else None + if entry is None: + vr.error( + f"benchmark '{benchmark_id}' queries table '{table}', which is not in 'tables' " + f"(loaded: {sorted(loaded)}); the run measured a different dataset" + ) + elif isinstance(rows, (int, float)) and not isinstance(rows, bool) and rows <= 0: + vr.error(f"benchmark '{benchmark_id}' queries table '{table}', which was loaded with {rows} rows") + + def _validate_platform_config_clustering(data: dict, vr: ValidationResult) -> None: """Shape-check the optional ``platform.config`` clustering field when present. @@ -1923,6 +2112,7 @@ def _validate_bundle( _validate_translation_section(data, vr) _validate_environment_client_link(data, vr) _validate_tables_block(data, vr) + _validate_required_tables(data, vr) _validate_platform_config_clustering(data, vr) _warn_pre_cutoff_clustering_claim(data, vr) _validate_public_cost_section(data, vr) diff --git a/deploy/routes.yml b/deploy/routes.yml new file mode 100644 index 0000000000..4f4a78408e --- /dev/null +++ b/deploy/routes.yml @@ -0,0 +1,29 @@ +version: 1 +refs: + release: + kind: release-tag + trunk: + kind: trunk +routes: + - path: / + ref: release + builder: landing + - path: /docs/ + ref: release + builder: docs + - path: /docs/dev/ + ref: trunk + builder: docs + - path: /blog/ + ref: trunk + builder: blog + - path: /results/ + ref: trunk + builder: explorer + corpus: results-data +root_files: + ref: trunk + files: + - CNAME + - .nojekyll + - 404.html diff --git a/docker/benchbox-agent/Dockerfile b/docker/benchbox-agent/Dockerfile index 00da367eb4..b8b71afc9b 100644 --- a/docker/benchbox-agent/Dockerfile +++ b/docker/benchbox-agent/Dockerfile @@ -1,5 +1,5 @@ # benchbox-agent: Linux parity sandbox for reproducing the non-service -# pr.yml gate on an Apple-silicon Mac (see docs/operations/release-guide.md). +# ci.yml gate on an Apple-silicon Mac (see docs/operations/release-guide.md). # Not a service image and NOT part of any docker-compose stack -- it backs the # `container machine` used by `make ci-linux`. arm64 only: # container build --arch arm64 --tag local/benchbox-agent docker/benchbox-agent diff --git a/docs/README.md b/docs/README.md index 0a20752cf8..a04f5d2a0a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,7 @@ BenchBox adopts Semantic Versioning (`MAJOR.MINOR.PATCH`) to communicate compati - Increase **MINOR** for backward-compatible feature additions. - Increase **PATCH** for bug fixes or documentation-only updates. -Current release: `v0.4.1`. Use `benchbox --version` for a human-readable summary or `benchbox --version-json` when you need structured diagnostics in automation. Both commands confirm that the package, `pyproject.toml`, and documentation markers stay aligned. Platform runs now capture the requested versus resolved driver package versions, so comparisons across DuckDB or connector releases remain reproducible. +Current release: `v0.4.2`. Use `benchbox --version` for a human-readable summary or `benchbox --version-json` when you need structured diagnostics in automation. Both commands confirm that the package, `pyproject.toml`, and documentation markers stay aligned. Platform runs now capture the requested versus resolved driver package versions, so comparisons across DuckDB or connector releases remain reproducible. ## Usage Guides diff --git a/docs/advanced/customization.md b/docs/advanced/customization.md new file mode 100644 index 0000000000..59235f987b --- /dev/null +++ b/docs/advanced/customization.md @@ -0,0 +1,13 @@ +# Customization & Extensions + +```{tags} advanced, guide, configuration +``` + +Guides for customizing BenchBox and creating custom benchmarks. + +```{toctree} +:maxdepth: 1 + +custom-benchmarks +optimizer-tests +``` diff --git a/docs/advanced/customization.rst b/docs/advanced/customization.rst deleted file mode 100644 index 63b4fdf086..0000000000 --- a/docs/advanced/customization.rst +++ /dev/null @@ -1,12 +0,0 @@ -Customization & Extensions -========================== - -.. tags:: advanced, guide, configuration - -Guides for customizing BenchBox and creating custom benchmarks. - -.. toctree:: - :maxdepth: 1 - - custom-benchmarks - optimizer-tests diff --git a/docs/advanced/performance-optimization.md b/docs/advanced/performance-optimization.md index a52c66117c..855f8262f8 100644 --- a/docs/advanced/performance-optimization.md +++ b/docs/advanced/performance-optimization.md @@ -800,7 +800,7 @@ for outlier in outliers: ## See Also - [Performance Monitoring](performance.md) - Basic performance monitoring -- [Tuning Configuration API](../reference/python-api/tuning.rst) - Tuning API reference +- [Tuning Configuration API](../reference/python-api/tuning.md) - Tuning API reference - [Result Analysis API](../reference/python-api/result-analysis.rst) - Analysis utilities - [CI/CD Integration](ci-cd-integration.md) - Automated performance testing - [Platform Adapters](../reference/python-api/platforms/) - Platform-specific optimizations diff --git a/docs/advanced/performance-tuning.md b/docs/advanced/performance-tuning.md new file mode 100644 index 0000000000..84334036c0 --- /dev/null +++ b/docs/advanced/performance-tuning.md @@ -0,0 +1,14 @@ +# Performance & Tuning + +```{tags} advanced, guide, tuning +``` + +Guides for optimizing benchmark performance and tuning database configurations. + +```{toctree} +:maxdepth: 1 + +performance +performance-optimization +power-run-concurrent-queries +``` diff --git a/docs/advanced/performance-tuning.rst b/docs/advanced/performance-tuning.rst deleted file mode 100644 index f64da2def1..0000000000 --- a/docs/advanced/performance-tuning.rst +++ /dev/null @@ -1,13 +0,0 @@ -Performance & Tuning -==================== - -.. tags:: advanced, guide, tuning - -Guides for optimizing benchmark performance and tuning database configurations. - -.. toctree:: - :maxdepth: 1 - - performance - performance-optimization - power-run-concurrent-queries diff --git a/docs/benchmarks/academic-benchmarks.md b/docs/benchmarks/academic-benchmarks.md new file mode 100644 index 0000000000..87a594f2a7 --- /dev/null +++ b/docs/benchmarks/academic-benchmarks.md @@ -0,0 +1,85 @@ +--- +myst: + enable_extensions: + - deflist +--- + +# Academic Benchmarks + +```{tags} reference +``` + +Research benchmarks from academia that have shaped database technology. + +## The Role of Academic Research + +Academic research has been the driving force behind nearly every major advancement in database technology. From the relational model itself (Codd, 1970) to query optimization, indexing strategies, and distributed systems, universities and research institutions have pioneered the fundamental concepts that power modern databases. + +Academic benchmarks emerge from this research tradition. Unlike industry benchmarks designed to showcase specific products, academic benchmarks are created to: + +- **Test theoretical concepts** - Validate research hypotheses about query processing, optimization, and storage +- **Enable reproducible research** - Provide standardized workloads for comparing algorithmic improvements +- **Explore edge cases** - Stress-test systems in ways that reveal fundamental limitations +- **Advance the field** - Drive innovation by exposing weaknesses in current approaches + +## Why Academic Benchmarks Matter + +Academic benchmarks often become industry standards years after their initial publication. They matter for several reasons: + +**Theoretical foundation** +: Academic benchmarks are designed with deep understanding of database internals. They target specific subsystems (join algorithms, cardinality estimation, memory management) rather than just measuring end-to-end throughput. + +**Challenging workloads** +: Researchers deliberately design queries that expose optimizer weaknesses, test algorithmic complexity boundaries, and stress edge cases. These "hard" queries reveal performance cliffs that production workloads might never trigger. + +**Open methodology** +: Academic benchmarks are published with full specifications, data generators, and analysis. This transparency enables deep investigation of why systems perform as they do. + +**Predictive value** +: Performance on academic benchmarks often predicts behavior on future production workloads. Systems that handle join order optimization well on JOB will likely handle complex analytical queries well in practice. + +## Academic Benchmarks in BenchBox + +BenchBox includes three influential academic benchmarks: + +```{list-table} +:header-rows: 1 +:widths: 20 35 45 + +* - Benchmark + - Origin + - Focus +* - **Star Schema Benchmark (SSB)** + - O'Neil et al., UMass Boston + - Star schema OLAP queries, dimensional modeling performance +* - **AMPLab Big Data Benchmark** + - UC Berkeley AMPLab + - Big data analytics, comparing SQL engines at scale +* - **Join Order Benchmark (JOB)** + - Leis et al., TU Munich + - Query optimizer join ordering and cardinality estimation on canonical IMDb 2013 data +``` + +### From Research to Practice + +Many techniques first validated on academic benchmarks are now standard in production databases: + +- **Adaptive query execution** - Tested on complex join benchmarks, now in Spark and other engines +- **Columnar storage** - Validated on analytical benchmarks, now dominant in OLAP systems +- **Vectorized execution** - Demonstrated on microbenchmarks, now in DuckDB, ClickHouse, and others + +## Included Benchmarks + +```{toctree} +:maxdepth: 1 + +ssb +amplab +join-order +``` + +## See Also + +- [VLDB Conference](https://www.vldb.org/) - Premier database research venue +- [SIGMOD Conference](https://sigmod.org/) - ACM database systems conference +- {doc}`tpc-standards` - Industry-standard benchmarks derived from academic research diff --git a/docs/benchmarks/academic-benchmarks.rst b/docs/benchmarks/academic-benchmarks.rst deleted file mode 100644 index ad6c13b21d..0000000000 --- a/docs/benchmarks/academic-benchmarks.rst +++ /dev/null @@ -1,83 +0,0 @@ -Academic Benchmarks -=================== - -.. tags:: reference - -Research benchmarks from academia that have shaped database technology. - -The Role of Academic Research ------------------------------ - -Academic research has been the driving force behind nearly every major advancement in database technology. From the relational model itself (Codd, 1970) to query optimization, indexing strategies, and distributed systems, universities and research institutions have pioneered the fundamental concepts that power modern databases. - -Academic benchmarks emerge from this research tradition. Unlike industry benchmarks designed to showcase specific products, academic benchmarks are created to: - -- **Test theoretical concepts** - Validate research hypotheses about query processing, optimization, and storage -- **Enable reproducible research** - Provide standardized workloads for comparing algorithmic improvements -- **Explore edge cases** - Stress-test systems in ways that reveal fundamental limitations -- **Advance the field** - Drive innovation by exposing weaknesses in current approaches - -Why Academic Benchmarks Matter ------------------------------- - -Academic benchmarks often become industry standards years after their initial publication. They matter for several reasons: - -**Theoretical foundation** - Academic benchmarks are designed with deep understanding of database internals. They target specific subsystems (join algorithms, cardinality estimation, memory management) rather than just measuring end-to-end throughput. - -**Challenging workloads** - Researchers deliberately design queries that expose optimizer weaknesses, test algorithmic complexity boundaries, and stress edge cases. These "hard" queries reveal performance cliffs that production workloads might never trigger. - -**Open methodology** - Academic benchmarks are published with full specifications, data generators, and analysis. This transparency enables deep investigation of why systems perform as they do. - -**Predictive value** - Performance on academic benchmarks often predicts behavior on future production workloads. Systems that handle join order optimization well on JOB will likely handle complex analytical queries well in practice. - -Academic Benchmarks in BenchBox -------------------------------- - -BenchBox includes three influential academic benchmarks: - -.. list-table:: - :header-rows: 1 - :widths: 20 35 45 - - * - Benchmark - - Origin - - Focus - * - **Star Schema Benchmark (SSB)** - - O'Neil et al., UMass Boston - - Star schema OLAP queries, dimensional modeling performance - * - **AMPLab Big Data Benchmark** - - UC Berkeley AMPLab - - Big data analytics, comparing SQL engines at scale - * - **Join Order Benchmark (JOB)** - - Leis et al., TU Munich - - Query optimizer join ordering and cardinality estimation on canonical IMDb 2013 data - -From Research to Practice -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Many techniques first validated on academic benchmarks are now standard in production databases: - -- **Adaptive query execution** - Tested on complex join benchmarks, now in Spark and other engines -- **Columnar storage** - Validated on analytical benchmarks, now dominant in OLAP systems -- **Vectorized execution** - Demonstrated on microbenchmarks, now in DuckDB, ClickHouse, and others - -Included Benchmarks -------------------- - -.. toctree:: - :maxdepth: 1 - - ssb - amplab - join-order - -See Also --------- - -- `VLDB Conference `_ - Premier database research venue -- `SIGMOD Conference `_ - ACM database systems conference -- :doc:`tpc-standards` - Industry-standard benchmarks derived from academic research diff --git a/docs/benchmarks/ai-ml-benchmarks.rst b/docs/benchmarks/ai-ml-benchmarks.md similarity index 53% rename from docs/benchmarks/ai-ml-benchmarks.rst rename to docs/benchmarks/ai-ml-benchmarks.md index b5b97dd880..f6aa0c8222 100644 --- a/docs/benchmarks/ai-ml-benchmarks.rst +++ b/docs/benchmarks/ai-ml-benchmarks.md @@ -1,12 +1,11 @@ -AI & ML Benchmarks -================== +# AI & ML Benchmarks -.. tags:: advanced, reference, ai-ml +```{tags} advanced, reference, ai-ml +``` Benchmarks for AI / ML workloads that modern cloud databases increasingly handle natively - currently vector similarity search, with SQL-based AI functions covered under Primitives. -Why AI/ML Benchmarks? ---------------------- +## Why AI/ML Benchmarks? Analytical databases are absorbing workloads that used to require dedicated ML infrastructure. **Vector similarity search** - kNN / ANN over embedding vectors - is the most mature case: DuckDB, PostgreSQL (pgvector), ClickHouse, Snowflake, StarRocks, and Doris all ship array / vector types with distance functions. @@ -18,39 +17,38 @@ Vector workloads differ from OLAP: BenchBox ships repeatable vector-search queries with synthetic embeddings so you can compare engines without depending on an external embedding service. -AI/ML Benchmarks in BenchBox ----------------------------- +## AI/ML Benchmarks in BenchBox -.. list-table:: - :header-rows: 1 - :widths: 25 35 40 +```{list-table} +:header-rows: 1 +:widths: 25 35 40 - * - Benchmark - - Focus - - Target Platforms - * - **Vector Search** - - kNN / ANN similarity search over embedding vectors - - DuckDB, pgvector, ClickHouse, Snowflake, StarRocks, Doris +* - Benchmark + - Focus + - Target Platforms +* - **Vector Search** + - kNN / ANN similarity search over embedding vectors + - DuckDB, pgvector, ClickHouse, Snowflake, StarRocks, Doris +``` Synthetic embeddings are generated locally with a fixed seed, so there is no per-query API cost regardless of scale. -Included Benchmarks -------------------- +## Included Benchmarks -.. toctree:: - :maxdepth: 1 +```{toctree} +:maxdepth: 1 - vector-search +vector-search +``` -.. note:: +```{note} +**AI Primitives** - SQL-based AI functions (Snowflake Cortex, BigQuery ML, +Databricks AI) - is a related but distinct workload registered under +``Primitives`` in the benchmark registry. See +{doc}`benchbox-primitives` for coverage. +``` - **AI Primitives** - SQL-based AI functions (Snowflake Cortex, BigQuery ML, - Databricks AI) - is a related but distinct workload registered under - ``Primitives`` in the benchmark registry. See - :doc:`benchbox-primitives` for coverage. +## See Also -See Also --------- - -- :doc:`benchbox-primitives` - Core SQL operation primitives (includes AI Primitives) -- :doc:`benchbox-experimental` - Other emerging benchmarks +- {doc}`benchbox-primitives` - Core SQL operation primitives (includes AI Primitives) +- {doc}`benchbox-experimental` - Other emerging benchmarks diff --git a/docs/benchmarks/benchbox-experimental.md b/docs/benchmarks/benchbox-experimental.md new file mode 100644 index 0000000000..dccacbc30f --- /dev/null +++ b/docs/benchmarks/benchbox-experimental.md @@ -0,0 +1,108 @@ +--- +myst: + enable_extensions: + - deflist +--- + +# BenchBox Experimental + +```{tags} advanced, reference +``` + +Emerging benchmarks for specialized testing and novel workloads. + +## What Makes a Benchmark "Experimental"? + +Experimental benchmarks in BenchBox share one or more of these characteristics: + +**Newly developed** +: Recently created benchmarks that haven't yet been validated across many platforms or use cases. They may evolve as we learn from real-world usage. + +**Limited adoption** +: Benchmarks that address real needs but haven't achieved widespread industry acceptance. They may become standards or remain niche tools. + +**Specialized focus** +: Benchmarks targeting emerging workloads (AI/ML, time-series, metadata) that don't fit traditional OLAP categories. The methodology for testing these workloads is still evolving. + +**Research-oriented** +: Benchmarks designed to explore database behavior under unusual conditions (skewed data, adversarial queries) rather than measure typical performance. + +## Why Include Experimental Benchmarks? + +The database landscape evolves rapidly. Workloads that seemed exotic five years ago are now common: + +- **AI/ML integration** - Vector similarity, embedding storage, feature serving +- **Time-series analytics** - IoT data, observability, financial markets +- **Metadata-heavy workloads** - Data catalogs, schema evolution, lineage tracking +- **Adversarial conditions** - Skewed data, optimizer-hostile queries, chaos testing + +Experimental benchmarks let BenchBox stay ahead of these trends. Some will prove their worth and graduate to standard benchmarks. Others will inform the design of better benchmarks. All contribute to understanding database performance in emerging scenarios. + +## Using Experimental Benchmarks + +When working with experimental benchmarks, keep these considerations in mind: + +**Expect change** +: Schemas, queries, and methodologies may evolve. Pin to specific BenchBox versions for reproducible results. + +**Validate relevance** +: Check whether the benchmark's assumptions match your use case. A skew or +optimizer-stress benchmark may not tell you much about production dashboard +workloads. + +**Contribute feedback** +: Experimental benchmarks improve through usage. Report issues, suggest improvements, and share results to help refine them. + +**Interpret cautiously** +: Results may be less reliable than established benchmarks. Use them for directional guidance, not definitive platform selection. + +## Experimental Benchmarks in BenchBox + +```{list-table} +:header-rows: 1 +:widths: 25 35 40 + +* - Benchmark + - Focus + - Status +* - **TPC-HAVOC** + - Optimizer stress testing with 220 TPC-H syntax variants + - Research prototype +* - **TPC-H Skew** + - Data skew effects on query performance + - Methodology validation +* - **TPC-DS-OBT** + - TPC-DS on a single denormalized "One Big Table" schema + - Active development +* - **Data Vault** + - Data Vault 2.0 modeling patterns + - Schema finalization +``` + +### Graduation Criteria + +Experimental benchmarks may graduate to standard categories when they meet these criteria: + +1. **Stable specification** - No significant methodology changes for 6+ months +2. **Platform coverage** - Tested on 5+ platforms with consistent results +3. **Community validation** - External usage and feedback confirming utility +4. **Documentation complete** - Full specification, data generation, and analysis guides + +## Included Benchmarks + +```{toctree} +:maxdepth: 1 + +tpc-havoc +tpch-skew +tpc-ds-obt +datavault +``` + +## See Also + +- {doc}`benchbox-primitives` - Stable BenchBox-created benchmarks (Read/Write/Transaction/Metadata/AI) +- {doc}`real-world-benchmarks` - Public-dataset benchmarks (NYC Taxi, Flight Data) +- {doc}`time-series-benchmarks` - Time-series workloads (TSBS DevOps) +- {doc}`ai-ml-benchmarks` - AI/ML workloads (Vector Search) +- {doc}`academic-benchmarks` - Research benchmarks with established methodology diff --git a/docs/benchmarks/benchbox-experimental.rst b/docs/benchmarks/benchbox-experimental.rst deleted file mode 100644 index 091e12f232..0000000000 --- a/docs/benchmarks/benchbox-experimental.rst +++ /dev/null @@ -1,107 +0,0 @@ -BenchBox Experimental -===================== - -.. tags:: advanced, reference - -Emerging benchmarks for specialized testing and novel workloads. - -What Makes a Benchmark "Experimental"? --------------------------------------- - -Experimental benchmarks in BenchBox share one or more of these characteristics: - -**Newly developed** - Recently created benchmarks that haven't yet been validated across many platforms or use cases. They may evolve as we learn from real-world usage. - -**Limited adoption** - Benchmarks that address real needs but haven't achieved widespread industry acceptance. They may become standards or remain niche tools. - -**Specialized focus** - Benchmarks targeting emerging workloads (AI/ML, time-series, metadata) that don't fit traditional OLAP categories. The methodology for testing these workloads is still evolving. - -**Research-oriented** - Benchmarks designed to explore database behavior under unusual conditions (skewed data, adversarial queries) rather than measure typical performance. - -Why Include Experimental Benchmarks? ------------------------------------- - -The database landscape evolves rapidly. Workloads that seemed exotic five years ago are now common: - -- **AI/ML integration** - Vector similarity, embedding storage, feature serving -- **Time-series analytics** - IoT data, observability, financial markets -- **Metadata-heavy workloads** - Data catalogs, schema evolution, lineage tracking -- **Adversarial conditions** - Skewed data, optimizer-hostile queries, chaos testing - -Experimental benchmarks let BenchBox stay ahead of these trends. Some will prove their worth and graduate to standard benchmarks. Others will inform the design of better benchmarks. All contribute to understanding database performance in emerging scenarios. - -Using Experimental Benchmarks ------------------------------ - -When working with experimental benchmarks, keep these considerations in mind: - -**Expect change** - Schemas, queries, and methodologies may evolve. Pin to specific BenchBox versions for reproducible results. - -**Validate relevance** - Check whether the benchmark's assumptions match your use case. A skew or - optimizer-stress benchmark may not tell you much about production dashboard - workloads. - -**Contribute feedback** - Experimental benchmarks improve through usage. Report issues, suggest improvements, and share results to help refine them. - -**Interpret cautiously** - Results may be less reliable than established benchmarks. Use them for directional guidance, not definitive platform selection. - -Experimental Benchmarks in BenchBox ------------------------------------ - -.. list-table:: - :header-rows: 1 - :widths: 25 35 40 - - * - Benchmark - - Focus - - Status - * - **TPC-HAVOC** - - Optimizer stress testing with 220 TPC-H syntax variants - - Research prototype - * - **TPC-H Skew** - - Data skew effects on query performance - - Methodology validation - * - **TPC-DS-OBT** - - TPC-DS on a single denormalized "One Big Table" schema - - Active development - * - **Data Vault** - - Data Vault 2.0 modeling patterns - - Schema finalization - -Graduation Criteria -~~~~~~~~~~~~~~~~~~~ - -Experimental benchmarks may graduate to standard categories when they meet these criteria: - -1. **Stable specification** - No significant methodology changes for 6+ months -2. **Platform coverage** - Tested on 5+ platforms with consistent results -3. **Community validation** - External usage and feedback confirming utility -4. **Documentation complete** - Full specification, data generation, and analysis guides - -Included Benchmarks -------------------- - -.. toctree:: - :maxdepth: 1 - - tpc-havoc - tpch-skew - tpc-ds-obt - datavault - -See Also --------- - -- :doc:`benchbox-primitives` - Stable BenchBox-created benchmarks (Read/Write/Transaction/Metadata/AI) -- :doc:`real-world-benchmarks` - Public-dataset benchmarks (NYC Taxi, Flight Data) -- :doc:`time-series-benchmarks` - Time-series workloads (TSBS DevOps) -- :doc:`ai-ml-benchmarks` - AI/ML workloads (Vector Search) -- :doc:`academic-benchmarks` - Research benchmarks with established methodology diff --git a/docs/benchmarks/benchbox-primitives.md b/docs/benchmarks/benchbox-primitives.md new file mode 100644 index 0000000000..082d81533c --- /dev/null +++ b/docs/benchmarks/benchbox-primitives.md @@ -0,0 +1,107 @@ +--- +myst: + enable_extensions: + - deflist +--- + +# BenchBox Primitives + +```{tags} reference, custom-benchmark +``` + +Fundamental database operation testing designed for deep performance investigation. + +## Why Primitives? + +Standard benchmarks like TPC-H measure end-to-end query performance, but when a query runs slowly, they don't tell you *why*. Is it the join algorithm? Predicate pushdown? Aggregation strategy? Data loading? Primitive benchmarks isolate individual operations to answer these questions. + +BenchBox Primitives were created to fill a gap in the benchmarking landscape: + +- **TPC benchmarks** test complete queries but hide which operations are slow +- **Microbenchmarks** test operations but lack realistic data characteristics +- **Primitives** combine isolated operations with benchmark-scale data + +This approach enables systematic performance investigation that pinpoints bottlenecks. + +## Design Philosophy + +BenchBox Primitives follow several design principles: + +**Isolation** +: Each primitive tests one operation or a small family of related operations. A filter primitive tests filtering, not filtering-then-joining. This isolation makes performance differences attributable to specific capabilities. + +**Realistic data** +: Primitives use TPC-derived data with realistic distributions, cardinalities, and correlations. Testing a filter on uniformly distributed random data tells you little about production performance. + +**Coverage** +: The primitives cover the fundamental operations that compose all analytical queries: scans, filters, projections, aggregations, joins, sorts, and more. Understanding primitive performance predicts complex query performance. + +**Platform agnostic** +: Primitives work across SQL and DataFrame platforms, enabling comparison between Pandas and PostgreSQL, or Polars and DuckDB. The same logical operation is tested regardless of API. + +## Use Cases for Primitives + +**Performance debugging** +: When a TPC-H query runs slower than expected, run the corresponding primitives to identify which operation is the bottleneck. Is Q9's slowdown from the join strategy or the aggregation? + +**Platform evaluation** +: Before choosing a database, test the operations your workload uses most. If your queries are join-heavy, the join primitives predict real performance better than aggregate TPC scores. + +**Optimization validation** +: After tuning a database, run primitives to verify the optimization worked. Did the new index actually speed up the filter operation? Primitives provide focused measurement. + +**Regression detection** +: Track primitive performance across database versions to catch regressions early. A 20% slowdown in aggregation primitives predicts problems before they appear in complex queries. + +**DataFrame vs SQL comparison** +: Primitives enable apples-to-apples comparison between DataFrame libraries (Polars, Pandas) and SQL databases (DuckDB, PostgreSQL) on the same logical operations. + +## Primitive Categories + +BenchBox Primitives are organized into three categories: + +```{list-table} +:header-rows: 1 +:widths: 25 35 40 + +* - Category + - Operations + - Insights +* - **Read Primitives** + - Scans, filters, projections, aggregations, joins, sorts, window functions + - Query execution performance, optimizer effectiveness +* - **Write Primitives** + - Inserts, bulk loads, updates, deletes, upserts + - Data loading performance, transaction overhead +* - **Transaction Primitives** + - Isolation levels, concurrent access, conflict resolution + - ACID compliance, concurrency characteristics +* - **Metadata Primitives** + - INFORMATION_SCHEMA, SHOW, DESCRIBE, PRAGMA catalog queries + - Catalog introspection performance for data catalogs, BI tools, governance +* - **AI Primitives** + - SQL-based AI functions - generation, summarization, sentiment, embeddings + - Cloud AI SQL surface (Snowflake Cortex, BigQuery ML, Databricks AI) +``` + +### DataFrame Support + +Read and Write Primitives include full DataFrame implementations supporting both expression-based platforms (Polars, PySpark, DataFusion) and pandas-compatible platforms (Pandas, Dask, cuDF). This enables direct comparison between SQL and DataFrame performance on identical operations. + +## Included Benchmarks + +```{toctree} +:maxdepth: 1 + +read-primitives +write-primitives +transaction-primitives +metadata-primitives +ai-primitives +``` + +## See Also + +- {doc}`../tutorials/understanding-results` - Interpreting benchmark output +- {doc}`../guides/platform-comparison` - Comparing platforms systematically +- {doc}`tpc-standards` - End-to-end query benchmarks diff --git a/docs/benchmarks/benchbox-primitives.rst b/docs/benchmarks/benchbox-primitives.rst deleted file mode 100644 index 9a7bf0af1c..0000000000 --- a/docs/benchmarks/benchbox-primitives.rst +++ /dev/null @@ -1,106 +0,0 @@ -BenchBox Primitives -=================== - -.. tags:: reference, custom-benchmark - -Fundamental database operation testing designed for deep performance investigation. - -Why Primitives? ---------------- - -Standard benchmarks like TPC-H measure end-to-end query performance, but when a query runs slowly, they don't tell you *why*. Is it the join algorithm? Predicate pushdown? Aggregation strategy? Data loading? Primitive benchmarks isolate individual operations to answer these questions. - -BenchBox Primitives were created to fill a gap in the benchmarking landscape: - -- **TPC benchmarks** test complete queries but hide which operations are slow -- **Microbenchmarks** test operations but lack realistic data characteristics -- **Primitives** combine isolated operations with benchmark-scale data - -This approach enables systematic performance investigation that pinpoints bottlenecks. - -Design Philosophy ------------------ - -BenchBox Primitives follow several design principles: - -**Isolation** - Each primitive tests one operation or a small family of related operations. A filter primitive tests filtering, not filtering-then-joining. This isolation makes performance differences attributable to specific capabilities. - -**Realistic data** - Primitives use TPC-derived data with realistic distributions, cardinalities, and correlations. Testing a filter on uniformly distributed random data tells you little about production performance. - -**Coverage** - The primitives cover the fundamental operations that compose all analytical queries: scans, filters, projections, aggregations, joins, sorts, and more. Understanding primitive performance predicts complex query performance. - -**Platform agnostic** - Primitives work across SQL and DataFrame platforms, enabling comparison between Pandas and PostgreSQL, or Polars and DuckDB. The same logical operation is tested regardless of API. - -Use Cases for Primitives ------------------------- - -**Performance debugging** - When a TPC-H query runs slower than expected, run the corresponding primitives to identify which operation is the bottleneck. Is Q9's slowdown from the join strategy or the aggregation? - -**Platform evaluation** - Before choosing a database, test the operations your workload uses most. If your queries are join-heavy, the join primitives predict real performance better than aggregate TPC scores. - -**Optimization validation** - After tuning a database, run primitives to verify the optimization worked. Did the new index actually speed up the filter operation? Primitives provide focused measurement. - -**Regression detection** - Track primitive performance across database versions to catch regressions early. A 20% slowdown in aggregation primitives predicts problems before they appear in complex queries. - -**DataFrame vs SQL comparison** - Primitives enable apples-to-apples comparison between DataFrame libraries (Polars, Pandas) and SQL databases (DuckDB, PostgreSQL) on the same logical operations. - -Primitive Categories --------------------- - -BenchBox Primitives are organized into three categories: - -.. list-table:: - :header-rows: 1 - :widths: 25 35 40 - - * - Category - - Operations - - Insights - * - **Read Primitives** - - Scans, filters, projections, aggregations, joins, sorts, window functions - - Query execution performance, optimizer effectiveness - * - **Write Primitives** - - Inserts, bulk loads, updates, deletes, upserts - - Data loading performance, transaction overhead - * - **Transaction Primitives** - - Isolation levels, concurrent access, conflict resolution - - ACID compliance, concurrency characteristics - * - **Metadata Primitives** - - INFORMATION_SCHEMA, SHOW, DESCRIBE, PRAGMA catalog queries - - Catalog introspection performance for data catalogs, BI tools, governance - * - **AI Primitives** - - SQL-based AI functions - generation, summarization, sentiment, embeddings - - Cloud AI SQL surface (Snowflake Cortex, BigQuery ML, Databricks AI) - -DataFrame Support -~~~~~~~~~~~~~~~~~ - -Read and Write Primitives include full DataFrame implementations supporting both expression-based platforms (Polars, PySpark, DataFusion) and pandas-compatible platforms (Pandas, Dask, cuDF). This enables direct comparison between SQL and DataFrame performance on identical operations. - -Included Benchmarks -------------------- - -.. toctree:: - :maxdepth: 1 - - read-primitives - write-primitives - transaction-primitives - metadata-primitives - ai-primitives - -See Also --------- - -- :doc:`../tutorials/understanding-results` - Interpreting benchmark output -- :doc:`../guides/platform-comparison` - Comparing platforms systematically -- :doc:`tpc-standards` - End-to-end query benchmarks diff --git a/docs/benchmarks/flightdata.md b/docs/benchmarks/flightdata.md index ce17be4c6d..0b1b4b5ff1 100644 --- a/docs/benchmarks/flightdata.md +++ b/docs/benchmarks/flightdata.md @@ -39,13 +39,13 @@ routes, carriers, and temporal patterns - across 20 OLAP queries. | ----: | --------------: | -----------: | ------------------------------------ | | 0.01 | ~600 K | ~10 MB | 1-month BTS / dev sample | | 0.1 | ~2.4 M | ~100 MB | ~4 months of BTS data | -| 1.0 | ~24 M | ~1 GB | ~41 months (~3.4 years) | +| 1.0 | ~24 M | ~2.8 GB | ~41 months (~3.4 years), uncompressed CSV | | ≥10.0 | Full | ~10 GB+ | Approaches full historical corpus | ## Usage ```bash -# Default scale (SF=1.0, ~24M flights, ~1 GB) +# Default scale (SF=1.0, ~24M flights, ~2.8 GB uncompressed CSV) benchbox run --platform duckdb --benchmark flightdata --scale 1.0 # Quick dev sample diff --git a/docs/benchmarks/industry-benchmarks.md b/docs/benchmarks/industry-benchmarks.md new file mode 100644 index 0000000000..ce87fc93bb --- /dev/null +++ b/docs/benchmarks/industry-benchmarks.md @@ -0,0 +1,102 @@ +--- +myst: + enable_extensions: + - deflist +--- + +# Industry Benchmarks + +```{tags} reference +``` + +Real-world benchmarks created by companies to demonstrate database performance. + +## The Rise of Vendor Benchmarks + +As the database landscape has diversified, companies have created their own benchmarks to highlight specific strengths of their products. Unlike TPC benchmarks designed for broad comparability, industry benchmarks often target particular use cases where the sponsoring company excels. + +This isn't a criticism - these benchmarks fill important gaps. TPC-H and TPC-DS, while comprehensive, were designed decades ago and don't fully capture modern analytical workloads like: + +- **Real-time analytics** - Sub-second queries on streaming data +- **Wide tables** - Hundreds of columns common in log and event data +- **Machine learning integration** - Feature engineering and model serving +- **JSON and semi-structured data** - Modern application architectures + +Industry benchmarks address these gaps by reflecting actual production workloads. + +## Why Industry Benchmarks Matter + +**Practical relevance** +: Industry benchmarks model real use cases. ClickBench uses actual web analytics data. H2O.ai benchmarks reflect machine learning workflows. This practical grounding makes results more actionable for similar workloads. + +**Modern workload patterns** +: These benchmarks capture patterns that academic and TPC benchmarks miss: wide tables, high cardinality dimensions, time-series aggregations, and interactive query latencies. + +**Ecosystem validation** +: Companies create benchmarks not just for marketing, but to validate their design decisions. When ClickHouse publishes ClickBench results, they're demonstrating that their columnar architecture handles the workloads it was designed for. + +**Competitive pressure** +: Once published, these benchmarks become competitive arenas. Other vendors optimize for them, driving innovation. ClickBench results now span dozens of databases, providing valuable cross-platform comparisons. + +## Interpreting Vendor Benchmarks + +When evaluating vendor benchmark results, keep these factors in mind: + +**Home field advantage** +: The benchmark creator typically performs best. This isn't always manipulation - they designed their system for these exact workloads. But it means results may not generalize to your use case. + +**Configuration matters** +: Vendors optimize configurations for their benchmarks. Default settings may perform very differently. BenchBox runs with consistent, documented configurations to enable fair comparison. + +**Data characteristics** +: The specific data distributions, cardinalities, and access patterns in a benchmark may or may not match your data. A database that excels on ClickBench's web analytics data might struggle with your IoT sensor data. + +**What's not measured** +: Every benchmark has blind spots. ClickBench doesn't test joins. H2O.ai benchmarks don't test SQL. Consider what matters for your use case that the benchmark doesn't cover. + +## Industry Benchmarks in BenchBox + +BenchBox includes three industry benchmarks, each targeting different use cases: + +```{list-table} +:header-rows: 1 +:widths: 20 30 50 + +* - Benchmark + - Creator + - Focus +* - **ClickBench** + - ClickHouse / Alexey Milovidov + - Web analytics queries on wide tables, single-table aggregations +* - **H2O.ai Database Benchmark** + - H2O.ai (now maintained by DuckDB Labs) + - Data science workflows: groupby, join, and feature engineering +* - **CoffeeShop** + - Josue Bogran + - Point-of-sale analytics, time-series patterns, realistic business queries +``` + +### Complementing TPC Benchmarks + +Industry benchmarks work best alongside TPC benchmarks, not as replacements: + +- Use **TPC-H** for standardized OLAP comparison +- Use **ClickBench** if your workload involves wide tables and single-table analytics +- Use **H2O.ai** if you're evaluating databases for data science pipelines +- Use **CoffeeShop** for time-series analytics and business intelligence patterns + +## Included Benchmarks + +```{toctree} +:maxdepth: 1 + +clickbench +h2odb +coffeeshop +``` + +## See Also + +- {doc}`tpc-standards` - Industry-standard benchmarks from TPC +- {doc}`academic-benchmarks` - Research benchmarks from academia +- [ClickBench Official Results](https://benchmark.clickhouse.com/) - Cross-database ClickBench comparison diff --git a/docs/benchmarks/industry-benchmarks.rst b/docs/benchmarks/industry-benchmarks.rst deleted file mode 100644 index 4434d4ae44..0000000000 --- a/docs/benchmarks/industry-benchmarks.rst +++ /dev/null @@ -1,101 +0,0 @@ -Industry Benchmarks -=================== - -.. tags:: reference - -Real-world benchmarks created by companies to demonstrate database performance. - -The Rise of Vendor Benchmarks ------------------------------ - -As the database landscape has diversified, companies have created their own benchmarks to highlight specific strengths of their products. Unlike TPC benchmarks designed for broad comparability, industry benchmarks often target particular use cases where the sponsoring company excels. - -This isn't a criticism - these benchmarks fill important gaps. TPC-H and TPC-DS, while comprehensive, were designed decades ago and don't fully capture modern analytical workloads like: - -- **Real-time analytics** - Sub-second queries on streaming data -- **Wide tables** - Hundreds of columns common in log and event data -- **Machine learning integration** - Feature engineering and model serving -- **JSON and semi-structured data** - Modern application architectures - -Industry benchmarks address these gaps by reflecting actual production workloads. - -Why Industry Benchmarks Matter ------------------------------- - -**Practical relevance** - Industry benchmarks model real use cases. ClickBench uses actual web analytics data. H2O.ai benchmarks reflect machine learning workflows. This practical grounding makes results more actionable for similar workloads. - -**Modern workload patterns** - These benchmarks capture patterns that academic and TPC benchmarks miss: wide tables, high cardinality dimensions, time-series aggregations, and interactive query latencies. - -**Ecosystem validation** - Companies create benchmarks not just for marketing, but to validate their design decisions. When ClickHouse publishes ClickBench results, they're demonstrating that their columnar architecture handles the workloads it was designed for. - -**Competitive pressure** - Once published, these benchmarks become competitive arenas. Other vendors optimize for them, driving innovation. ClickBench results now span dozens of databases, providing valuable cross-platform comparisons. - -Interpreting Vendor Benchmarks ------------------------------- - -When evaluating vendor benchmark results, keep these factors in mind: - -**Home field advantage** - The benchmark creator typically performs best. This isn't always manipulation - they designed their system for these exact workloads. But it means results may not generalize to your use case. - -**Configuration matters** - Vendors optimize configurations for their benchmarks. Default settings may perform very differently. BenchBox runs with consistent, documented configurations to enable fair comparison. - -**Data characteristics** - The specific data distributions, cardinalities, and access patterns in a benchmark may or may not match your data. A database that excels on ClickBench's web analytics data might struggle with your IoT sensor data. - -**What's not measured** - Every benchmark has blind spots. ClickBench doesn't test joins. H2O.ai benchmarks don't test SQL. Consider what matters for your use case that the benchmark doesn't cover. - -Industry Benchmarks in BenchBox -------------------------------- - -BenchBox includes three industry benchmarks, each targeting different use cases: - -.. list-table:: - :header-rows: 1 - :widths: 20 30 50 - - * - Benchmark - - Creator - - Focus - * - **ClickBench** - - ClickHouse / Alexey Milovidov - - Web analytics queries on wide tables, single-table aggregations - * - **H2O.ai Database Benchmark** - - H2O.ai (now maintained by DuckDB Labs) - - Data science workflows: groupby, join, and feature engineering - * - **CoffeeShop** - - Josue Bogran - - Point-of-sale analytics, time-series patterns, realistic business queries - -Complementing TPC Benchmarks -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Industry benchmarks work best alongside TPC benchmarks, not as replacements: - -- Use **TPC-H** for standardized OLAP comparison -- Use **ClickBench** if your workload involves wide tables and single-table analytics -- Use **H2O.ai** if you're evaluating databases for data science pipelines -- Use **CoffeeShop** for time-series analytics and business intelligence patterns - -Included Benchmarks -------------------- - -.. toctree:: - :maxdepth: 1 - - clickbench - h2odb - coffeeshop - -See Also --------- - -- :doc:`tpc-standards` - Industry-standard benchmarks from TPC -- :doc:`academic-benchmarks` - Research benchmarks from academia -- `ClickBench Official Results `_ - Cross-database ClickBench comparison diff --git a/docs/benchmarks/read-primitives-approximate-functions.md b/docs/benchmarks/read-primitives-approximate-functions.md index c30f99db38..b28f84c13f 100644 --- a/docs/benchmarks/read-primitives-approximate-functions.md +++ b/docs/benchmarks/read-primitives-approximate-functions.md @@ -91,6 +91,41 @@ value+count pairs. The catalog's `result_contract.capability` is set to `approximate_top_k` so the cross-dialect comparator skips strict value equality and validates capability + array `type_class` instead. +## Live cloud survey (2026-09-25) + +One-shot approx aggregates ran green on Snowflake and Databricks at +SF=0.01 and SF=0.1 via `benchbox run --platform +--benchmark read_primitives --phases load,power` (measurement means, +4 iterations each): + +| Query | Snowflake SF=0.01 | Snowflake SF=0.1 | Databricks SF=0.01 | Databricks SF=0.1 | +|-------|-------------------|------------------|--------------------|-------------------| +| `approx_count_distinct_simple` | 458 ms | 242 ms | 441 ms | 456 ms | +| `approx_count_distinct_groupby` | 743 ms | 232 ms | 502 ms | 334 ms | +| `approx_quantile_groupby` | 399 ms | 277 ms | 288 ms | 324 ms | +| `approx_quantiles_array` | 422 ms | 340 ms | 307 ms | 299 ms | +| `approx_top_k_lineitem` | 564 ms | 257 ms | 327 ms | 378 ms | + +SF=0.01 latencies exceed SF=0.1 on both warehouses — warehouse +warm-up dominates at these data volumes, not the sketch evaluation. +Result bundles: `read_primitives_sf001__sql__.json` +(SF=0.01; `sf01` for SF=0.1) flat under `$BENCHBOX_OUTPUT_DIR/results/`. BenchBox SHA `1ba01a8`, +Snowflake driver 4.7.3, Databricks driver 2026.36. + +Not yet surveyed (no credentials in `~/.benchbox/credentials.yaml` +or no network path from the survey host): ClickHouse Cloud, Firebolt, +Starburst/Trino, MotherDuck. Redshift credentials exist but the +cluster timed out from the survey host. BigQuery TPC-H datasets exist +but are empty (0 rows), and the BigQuery loader rejects the shared +zstd cache, so BigQuery needs a gzip reload before its survey leg. + +Unsurveyed platforms and the reason: + +| Platform | Reason | +|----------|--------| +| Firebolt | no credentials on survey host | +| Starburst/Trino | no credentials on survey host | + ## DataFrame platform coverage Three of the five approximate-aggregate queries run on the BenchBox diff --git a/docs/benchmarks/real-world-benchmarks.rst b/docs/benchmarks/real-world-benchmarks.md similarity index 59% rename from docs/benchmarks/real-world-benchmarks.rst rename to docs/benchmarks/real-world-benchmarks.md index 62e9f30e6e..283a641725 100644 --- a/docs/benchmarks/real-world-benchmarks.rst +++ b/docs/benchmarks/real-world-benchmarks.md @@ -1,12 +1,11 @@ -Real-World Data Benchmarks -========================== +# Real-World Data Benchmarks -.. tags:: reference +```{tags} reference +``` Benchmarks built on public real-world datasets rather than synthetic TPC or vendor data. -Why Real-World Data? --------------------- +## Why Real-World Data? Synthetic benchmarks like TPC-H generate data that is statistically clean: uniform distributions, predictable cardinalities, well-behaved joins. Real-world data is messier - skewed, sparse, seasonal, and full of edge cases. BenchBox's real-world benchmarks capture these characteristics using authoritative public datasets. @@ -20,41 +19,39 @@ Synthetic benchmarks like TPC-H generate data that is statistically clean: unifo These characteristics drive optimizer behavior in ways TPC workloads do not - cardinality estimation, join ordering, partition pruning, and predicate pushdown all behave differently. -Real-World Benchmarks in BenchBox ---------------------------------- +## Real-World Benchmarks in BenchBox -.. list-table:: - :header-rows: 1 - :widths: 20 30 50 +```{list-table} +:header-rows: 1 +:widths: 20 30 50 - * - Benchmark - - Data Source - - Focus - * - **NYC Taxi** - - NYC TLC trip records - - Geospatial + temporal OLAP, taxi zones dimension, 25 queries - * - **Flight Data** - - US BTS On-Time Performance - - Aviation analytics - delays, routes, carriers, seasonality, 20 queries +* - Benchmark + - Data Source + - Focus +* - **NYC Taxi** + - NYC TLC trip records + - Geospatial + temporal OLAP, taxi zones dimension, 25 queries +* - **Flight Data** + - US BTS On-Time Performance + - Aviation analytics - delays, routes, carriers, seasonality, 20 queries +``` -When to Use ------------ +## When to Use - **NYC Taxi** - geospatial joins, pickup/dropoff zone rollups, fare analytics - **Flight Data** - test temporal and categorical aggregation on a 20+ year government dataset -Included Benchmarks -------------------- +## Included Benchmarks -.. toctree:: - :maxdepth: 1 +```{toctree} +:maxdepth: 1 - nyctaxi - flightdata +nyctaxi +flightdata +``` -See Also --------- +## See Also -- :doc:`time-series-benchmarks` - Time-series workloads (TSBS DevOps) -- :doc:`industry-benchmarks` - Vendor / practitioner benchmarks (ClickBench, H2O, CoffeeShop) -- :doc:`tpc-standards` - Synthetic standard benchmarks +- {doc}`time-series-benchmarks` - Time-series workloads (TSBS DevOps) +- {doc}`industry-benchmarks` - Vendor / practitioner benchmarks (ClickBench, H2O, CoffeeShop) +- {doc}`tpc-standards` - Synthetic standard benchmarks diff --git a/docs/benchmarks/sf1-calibrated-sizes.md b/docs/benchmarks/sf1-calibrated-sizes.md new file mode 100644 index 0000000000..ae0819fc73 --- /dev/null +++ b/docs/benchmarks/sf1-calibrated-sizes.md @@ -0,0 +1,84 @@ +# Calibrated SF=1 Uncompressed Source-Data Sizes + +Measured 2026-09-26 with `scripts/measure_sf1_sizes.py`: each benchmark's +generator ran at `scale_factor=1.0` with compression disabled, and the +generator's own outputs were summed (returned table paths plus auxiliary +corpora such as the primitives bulk-load files). Upstream staging caches +(the TPC-DS source behind `tpcds_obt`, the TPC-H source behind +`datavault`) are build-time scratch and are never counted. Compressed +variants (`.gz`, `.zst`, `.bz2`), columnar Parquet footprints, and +run-metadata files are excluded: Parquet-native benchmarks report +on-disk footprints, not uncompressed bytes. Any run whose downloader +substituted synthetic fallback months fails loudly instead of +calibrating. +Real-data benchmarks (nyctaxi, flightdata) were decompressed from retained +SF=1 caches; `ai_primitives` reuses TPC-H data; `metadata_primitives` +generates no data files; `tpchavoc` aliases the TPC-H generator output. +Byte counts carry POSIX line endings and the local toolchain's Parquet +footers: treat them as calibrated within a few percent, not as +bit-exact constants across platforms. + +These are uncompressed source bytes. They are larger than the retained +on-disk caches, which stay zstd- or gzip-compressed: `flightdata` measures +2.6 GiB uncompressed against a ~0.5 GiB compressed cache, and `nyctaxi` +measures 0.8 GiB against a smaller compressed cache. + +GiB figures use 1024^3 bytes. Row counts come from retained SF=1 manifests +where available; otherwise the generator baseline. + +| Benchmark | Measured bytes | Measured GiB | Approx GB | Method | +|-----------|----------------|--------------|-----------|--------| +| tpch | 1,092,043,700 | 1.017 | ~1.1 GB | generated | +| tpcds | 1,235,064,003 | 1.150 | ~1.2 GB | generated | +| tpcdi | 160,094,804 | 0.149 | ~0.2 GB | generated | +| ssb | 609,810,151 | 0.568 | ~0.6 GB | generated | +| tpch_skew | 1,072,122,706 | 0.998 | ~1.1 GB | generated | +| tpchavoc | 1,092,043,700 | 1.017 | ~1.1 GB | generated (TPC-H data) | +| ai_primitives | 1,092,043,700 | 1.017 | ~1.1 GB | generated (TPC-H data) | +| metadata_primitives | 0 | 0.000 | 0 | no data files | +| amplab | 679,784,321 | 0.633 | ~0.7 GB | generated | +| h2odb | 1,413,528,658 | 1.316 | ~1.4 GB | generated | +| coffeeshop | 900,141,206 | 0.838 | ~0.9 GB | generated | +| tsbs_devops | 919,491,614 | 0.856 | ~0.9 GB | generated | +| joinorder | 959,479,691 | 0.894 | ~1.0 GB | on-disk Parquet footprint (canonical IMDB; no uncompressed source) | +| joinorder_synthetic | 444,365,227 | 0.414 | ~0.4 GB | generated | +| read_primitives | 1,092,043,700 | 1.017 | ~1.1 GB | generated (TPC-H data) | +| write_primitives | 1,572,187,172 | 1.464 | ~1.6 GB | generated | +| transaction_primitives | 1,572,187,172 | 1.464 | ~1.6 GB | generated | +| vector_search | 1,342,083,621 | 1.250 | ~1.3 GB | generated | +| clickbench | 552,136,075 | 0.514 | ~0.6 GB | generated | +| nyctaxi | 863,567,123 | 0.804 | ~0.9 GB | decompressed SF=1 cache | +| flightdata | 2,750,362,238 | 2.561 | ~2.8 GB | decompressed SF=1 cache | +| tpcds_obt | 2,406,484,046 | 2.241 | ~2.4 GB | legacy total incl. TPC-DS source (method superseded; derived-only remeasure pending scratch) | +| datavault | 3,703,681,239 | 3.449 | ~3.7 GB | generated (derived tables only) | + +## Notes + +- The harmonization target (~1 GB at SF=1) holds for the adjustable + synthetic benchmarks: coffeeshop, amplab, h2odb, tsbs_devops, nyctaxi, + and joinorder all land within or near the 0.8-1.3 GB band. FlightData + (2.6 GiB) and datavault (3.4 GiB) sit above it; joinorder_synthetic + (0.4 GiB) and clickbench (0.5 GiB) sit below. +- TPC-H, TPC-DS, SSB, and DataVault follow external spec or derived + semantics and were never harmonization targets. +- `read_primitives` and `ai_primitives` reuse TPC-H source data, so they + match TPC-H exactly. +- `write_primitives` and `transaction_primitives` exceed the TPC-H base + because the total includes each generator's bulk-load auxiliary corpus + alongside the TPC-H tables. +- Row counts below come from the summed `_datagen_manifest.json` + `row_count` entries where generators write them; generators without + manifest row metadata report no row count. +- `tpcds_obt` and `datavault` derive from TPC-DS and TPC-H source data; + the totals count only the derived tables, never the staged source + corpus. A run that reuses an existing source cache needs only the + incremental derived bytes. +- `nyctaxi` at SF=1 samples the 2019 corpus at rate 0.1 (one month slice); + `flightdata` at SF=1 spans ~41 months of BTS data. +- Regenerate with `uv run -- python scripts/measure_sf1_sizes.py --output ` + (add `--jobs N` for bounded parallel runs with ample scratch space). + The full run needs roughly 20 GB of scratch space and 30-45 minutes + sequential; `flightdata` downloads its BTS slice and dominates wall time. + Copy changed byte counts into + `tests/unit/generators/sf1_size_baseline.json` in the same commit so the + baseline test keeps mirroring this table. diff --git a/docs/benchmarks/support-status.md b/docs/benchmarks/support-status.md index 8203fd870f..0ad23b5c1c 100644 --- a/docs/benchmarks/support-status.md +++ b/docs/benchmarks/support-status.md @@ -65,7 +65,7 @@ integrity spec, query count, dataset/source, scales, and DataFrame capability. | `tpcdi` | `beta` | 38-query data-integration workload; generated; integrity spec; docs; scales to SF10; DataFrame-capable. | Confirm canonical transform/query completeness and a maintenance commitment before `stable`. | | `h2odb` | `beta` | 10-query data-science groupby/join; generated; integrity spec; docs; DataFrame-capable. | Broaden cross-platform coverage evidence before `stable`. | | `amplab` | `beta` | 8-query big-data subset; generated; integrity spec; docs; DataFrame-capable. | Small canonical subset; confirm dataset contract breadth before `stable`. | -| `read_primitives` | `beta` | 136-query read-primitive matrix; derived from TPC-H data; integrity spec; docs; DataFrame-capable. | Pin the derived-from-`tpch` dataset contract and confirm DataFrame parity before `stable`. | +| `read_primitives` | `beta` | 157-query read-primitive matrix; derived from TPC-H data; integrity spec; docs; DataFrame-capable. | Pin the derived-from-`tpch` dataset contract and confirm DataFrame parity before `stable`. | | `write_primitives` | `beta` | 12-query non-transactional writes; derived from TPC-H; integrity spec; docs; DataFrame-capable. | Broad cross-platform write support still maturing. | | `metadata_primitives` | `beta` | 62-query catalog introspection; no data generation (`requires_tables_object=false`); integrity spec; docs; DataFrame-capable. | Cross-dialect catalog coverage still expanding. | | `transaction_primitives` | `beta` | 12-query ACID/isolation workload; derived from TPC-H; integrity spec (`high_failure_expected`); docs; DataFrame-capable. | ACID adapter coverage limited (PostgreSQL/MySQL/SQL Server adapters planned). | @@ -73,23 +73,19 @@ integrity spec, query count, dataset/source, scales, and DataFrame capability. | `tsbs_devops` | `beta` | 18-query time-series DevOps workload; generated; integrity spec; docs; DataFrame-capable. | Time-series engine coverage breadth still expanding. | | `nyctaxi` | `beta` | 25-query real-world OLAP; external NYC TLC data; integrity spec; docs; DataFrame-capable. | External-dataset availability and a pinned source contract before `stable`. | | `flightdata` | `beta` | 20-query real-world aviation OLAP; external BTS data; integrity spec; docs; DataFrame-capable. | External-dataset availability and a pinned source contract before `stable`. | -| `vector_search` | `beta` | 6-query vector similarity; synthetic embeddings; docs; not DataFrame-capable. | **No integrity spec** and ANN-recall validation still pending; capability is SQL-only. | +| `vector_search` | `stable` | 6-query vector similarity; synthetic embeddings; integrity spec; docs; not DataFrame-capable (SQL-only by recorded routing decision, `supports_dataframe: false`). Q5 measures the same brute-force kNN SQL as Q1: no loader or adapter builds an HNSW index today, so ANN-index acceleration is not part of the measured workload. | None — maintain. | | `tpcds_obt` | `experimental` | 17-query denormalized One-Big-Table; derived from `tpcds`; integrity spec; docs; SF1 only; DataFrame-capable. | Single-scale subset of TPC-DS; still on the deprecated core base. | | `ai_primitives` | `experimental` | 16-query SQL AI functions; derived from TPC-H text; docs; core-only ID; not DataFrame-capable. | Cloud-only AI functions, cost-gated, no integrity spec; intentionally research-only. | | `tpchavoc` | `experimental` | 220-variant optimizer stress (22 queries × 10 syntax variants); generated; integrity spec; docs; DataFrame-capable. | Optimizer-stress research tool, not a standard comparison workload. | | `tpch_skew` | `experimental` | 22-query TPC-H over skewed distributions; generated; integrity spec; docs; DataFrame-capable. | Non-canonical skew parameters; research workload. | | `datavault` | `experimental` | 22-query Data Vault 2.0 variant; generated from TPC-H source; integrity spec; docs; DataFrame-capable. | Modeling-variant research; still on the deprecated core base. | -| `joinorder_synthetic` | `repo_only` | 13-query synthetic Join Order scaling harness; `surface: internal`; no integrity spec; DataFrame-capable; runnable by explicit ID only. | Public/beta promotion would need user docs, an integrity spec, and a separate `surface` decision — out of scope for this matrix. | +| `joinorder_synthetic` | `repo_only` | 113-query synthetic Join Order scaling harness (canonical JOB surface, verbatim); `surface: internal`; no integrity spec; DataFrame-capable; runnable by explicit ID only. | Public/beta promotion would need user docs, an integrity spec, and a separate `surface` decision — out of scope for this matrix. | ## Promotion Candidates and Blockers Recorded as explicit follow-up work rather than reviewer memory. None of these are applied here: this matrix documents status, it does not change it. -- **`vector_search` (beta → stable candidate, blocked):** the only missing piece - for the integrity dimension is a `BenchmarkSpec`. Adding one (and a DataFrame - routing decision) is the concrete blocker. Tracked as a deferred follow-up on - `benchmark-support-status-criteria-matrix`. - **External-dataset betas (`nyctaxi`, `flightdata`):** promotion is gated on a complete set of reviewed SHA-256 pins for the default external corpus, not on query coverage. Until those maps are populated, generated corpora record diff --git a/docs/benchmarks/time-series-benchmarks.rst b/docs/benchmarks/time-series-benchmarks.md similarity index 60% rename from docs/benchmarks/time-series-benchmarks.rst rename to docs/benchmarks/time-series-benchmarks.md index c549028bce..d9d7de28ab 100644 --- a/docs/benchmarks/time-series-benchmarks.rst +++ b/docs/benchmarks/time-series-benchmarks.md @@ -1,14 +1,13 @@ -Time-Series Benchmarks -====================== +# Time-Series Benchmarks -.. tags:: reference, time-series +```{tags} reference, time-series +``` Benchmarks for infrastructure monitoring, IoT, and observability workloads - the workloads that time-series databases (TimescaleDB, InfluxDB, QuestDB) and columnar engines are routinely asked to handle. -Why Time-Series Benchmarks? ---------------------------- +## Why Time-Series Benchmarks? Time-series workloads have distinct shapes that generic OLAP benchmarks do not stress: @@ -21,38 +20,36 @@ stress: daily; engines differ sharply on materialised aggregate performance. - **Append-heavy ingest** - writes dominate, with rare updates or deletes. -Time-Series Benchmarks in BenchBox ----------------------------------- +## Time-Series Benchmarks in BenchBox -.. list-table:: - :header-rows: 1 - :widths: 20 30 50 +```{list-table} +:header-rows: 1 +:widths: 20 30 50 - * - Benchmark - - Data Source - - Focus - * - **TSBS DevOps** - - Simulated infrastructure monitoring (TSBS) - - Time-series aggregation, host tags, DevOps dashboards, 18 queries +* - Benchmark + - Data Source + - Focus +* - **TSBS DevOps** + - Simulated infrastructure monitoring (TSBS) + - Time-series aggregation, host tags, DevOps dashboards, 18 queries +``` -When to Use ------------ +## When to Use - **TSBS DevOps** - evaluate time-series databases (TimescaleDB, InfluxDB, QuestDB) and columnar engines (DuckDB, ClickHouse) on DevOps monitoring workloads. The queries exercise single-host lookups, multi-host rollups, and full-fleet aggregations. -Included Benchmarks -------------------- +## Included Benchmarks -.. toctree:: - :maxdepth: 1 +```{toctree} +:maxdepth: 1 - tsbs-devops +tsbs-devops +``` -See Also --------- +## See Also -- :doc:`real-world-benchmarks` - Real-world datasets (NYC Taxi, Flight Data) -- :doc:`industry-benchmarks` - Vendor / practitioner benchmarks +- {doc}`real-world-benchmarks` - Real-world datasets (NYC Taxi, Flight Data) +- {doc}`industry-benchmarks` - Vendor / practitioner benchmarks diff --git a/docs/benchmarks/tpc-ds.md b/docs/benchmarks/tpc-ds.md index 7c5edceb3c..1a0534cbbb 100644 --- a/docs/benchmarks/tpc-ds.md +++ b/docs/benchmarks/tpc-ds.md @@ -283,6 +283,30 @@ query_stream = tpcds.get_query(42, stream_id=0) ``` +### Scale factor and query parameters + +dsqgen derives some query parameters from the scale factor, so the SQL for a +few queries depends on it. Standard runs now generate every query with the scale +factor of the data you loaded. Earlier versions used the scale factor 1 values +at every scale, which made a few queries test different values than the data +called for. + +This can change the SQL of these queries when the scale factor is not 1: + +| Scale factor | Queries whose SQL can differ | +|--------------|------------------------------| +| 0.01 and 0.1 | Q9, Q44, Q46, Q68 | +| 10 | Q9, Q44, Q46, Q68, plus Q1, Q16, Q27, Q34, Q36, Q73 | +| 100 and above | Q9, Q44, Q46, Q68, plus Q1, Q16, Q27, Q33, Q34, Q36, Q43, Q73 | + +For a given seed some of these queries can come out the same at both scales, +because the random values they draw can coincide. + +Runs at scale factor 1 are unchanged. Power and throughput runs already +generated their queries for the data's scale factor and are unchanged. Results +recorded before this change at other scale factors used the old SQL for the +queries above, so don't compare those queries one for one with newer results. + ## Query Characteristics ### Query Complexity Categories diff --git a/docs/benchmarks/tpc-standards.md b/docs/benchmarks/tpc-standards.md new file mode 100644 index 0000000000..7cf116739c --- /dev/null +++ b/docs/benchmarks/tpc-standards.md @@ -0,0 +1,79 @@ +--- +myst: + enable_extensions: + - deflist +--- + +# TPC Standards + +```{tags} reference, tpc-h, tpc-ds, tpc-di +``` + +Official industry standards for comparing databases. + +## About the Transaction Processing Performance Council + +The **Transaction Processing Performance Council (TPC)** is a non-profit organization founded in 1988 that defines database benchmarking standards and publishes independently verified performance results. TPC benchmarks are the gold standard for database performance comparison because they provide: + +- **Standardized workloads** - Precisely defined queries, data schemas, and execution rules +- **Audited results** - Independent verification ensures published results are reproducible +- **Price/performance metrics** - Enables cost-effectiveness comparisons, not just raw speed +- **Full disclosure** - Complete system configurations must be published with results + +## Why TPC Benchmarks Matter + +When evaluating databases, TPC benchmarks provide several critical advantages: + +**Apples-to-apples comparison** +: Unlike vendor-provided benchmarks that may be optimized for specific systems, TPC benchmarks use identical workloads across all platforms. This makes cross-platform comparison meaningful. + +**Real-world complexity** +: TPC benchmarks model actual business scenarios with realistic data distributions, query complexity, and concurrency requirements. They test systems under conditions similar to production workloads. + +**Industry acceptance** +: TPC results are widely recognized by analysts, procurement teams, and engineers. Published TPC results carry credibility that proprietary benchmarks cannot match. + +**Historical tracking** +: With decades of results, TPC benchmarks enable tracking of performance improvements over time and across hardware generations. + +## TPC Benchmarks in BenchBox + +BenchBox implements three TPC benchmarks, each targeting different use cases: + +```{list-table} +:header-rows: 1 +:widths: 20 40 40 + +* - Benchmark + - Focus + - Best For +* - **TPC-H** + - Decision support queries on business data + - General OLAP performance, platform comparison +* - **TPC-DS** + - Complex analytics with modern SQL features + - Advanced analytics, query optimizer testing +* - **TPC-DI** + - Data integration and ETL operations + - ETL pipeline performance, data warehouse loading +``` + +### Note on Compliance + +BenchBox implements TPC benchmark *workloads* for development and evaluation purposes. Official TPC compliance requires independent auditing and full disclosure reports. Results from BenchBox should be labeled as "TPC-H-like" or "derived from TPC-H" rather than official TPC results unless properly audited. + +## Included Benchmarks + +```{toctree} +:maxdepth: 1 + +tpc-h +tpc-ds +tpc-di +``` + +## See Also + +- [TPC Organization](http://www.tpc.org/) - Official TPC website with specifications and published results +- {doc}`../guides/tpc/tpc-h-official-guide` - Running TPC-H compliant benchmarks +- {doc}`../guides/tpc/tpc-ds-official-guide` - Running TPC-DS compliant benchmarks diff --git a/docs/benchmarks/tpc-standards.rst b/docs/benchmarks/tpc-standards.rst deleted file mode 100644 index 48f1fdf407..0000000000 --- a/docs/benchmarks/tpc-standards.rst +++ /dev/null @@ -1,77 +0,0 @@ -TPC Standards -============= - -.. tags:: reference, tpc-h, tpc-ds, tpc-di - -Official industry standards for comparing databases. - -About the Transaction Processing Performance Council ----------------------------------------------------- - -The **Transaction Processing Performance Council (TPC)** is a non-profit organization founded in 1988 that defines database benchmarking standards and publishes independently verified performance results. TPC benchmarks are the gold standard for database performance comparison because they provide: - -- **Standardized workloads** - Precisely defined queries, data schemas, and execution rules -- **Audited results** - Independent verification ensures published results are reproducible -- **Price/performance metrics** - Enables cost-effectiveness comparisons, not just raw speed -- **Full disclosure** - Complete system configurations must be published with results - -Why TPC Benchmarks Matter -------------------------- - -When evaluating databases, TPC benchmarks provide several critical advantages: - -**Apples-to-apples comparison** - Unlike vendor-provided benchmarks that may be optimized for specific systems, TPC benchmarks use identical workloads across all platforms. This makes cross-platform comparison meaningful. - -**Real-world complexity** - TPC benchmarks model actual business scenarios with realistic data distributions, query complexity, and concurrency requirements. They test systems under conditions similar to production workloads. - -**Industry acceptance** - TPC results are widely recognized by analysts, procurement teams, and engineers. Published TPC results carry credibility that proprietary benchmarks cannot match. - -**Historical tracking** - With decades of results, TPC benchmarks enable tracking of performance improvements over time and across hardware generations. - -TPC Benchmarks in BenchBox --------------------------- - -BenchBox implements three TPC benchmarks, each targeting different use cases: - -.. list-table:: - :header-rows: 1 - :widths: 20 40 40 - - * - Benchmark - - Focus - - Best For - * - **TPC-H** - - Decision support queries on business data - - General OLAP performance, platform comparison - * - **TPC-DS** - - Complex analytics with modern SQL features - - Advanced analytics, query optimizer testing - * - **TPC-DI** - - Data integration and ETL operations - - ETL pipeline performance, data warehouse loading - -Note on Compliance -~~~~~~~~~~~~~~~~~~ - -BenchBox implements TPC benchmark *workloads* for development and evaluation purposes. Official TPC compliance requires independent auditing and full disclosure reports. Results from BenchBox should be labeled as "TPC-H-like" or "derived from TPC-H" rather than official TPC results unless properly audited. - -Included Benchmarks -------------------- - -.. toctree:: - :maxdepth: 1 - - tpc-h - tpc-ds - tpc-di - -See Also --------- - -- `TPC Organization `_ - Official TPC website with specifications and published results -- :doc:`../guides/tpc/tpc-h-official-guide` - Running TPC-H compliant benchmarks -- :doc:`../guides/tpc/tpc-ds-official-guide` - Running TPC-DS compliant benchmarks diff --git a/docs/benchmarks/write-primitives-sketch-functions.md b/docs/benchmarks/write-primitives-sketch-functions.md index 1958cf1121..443d862bf8 100644 --- a/docs/benchmarks/write-primitives-sketch-functions.md +++ b/docs/benchmarks/write-primitives-sketch-functions.md @@ -77,6 +77,74 @@ serialization, which is comparable algorithmically but **not** binary-compatible. ClickHouse-native variants are deferred to a follow-up to keep this benchmark scoped to the cross-engine portability story. +## Live cloud survey (2026-09-25) + +The 22 SQL sketch ops ran against Snowflake (account `ZY50805`, +warehouse `COMPUTE_WH`) and Databricks (Serverless Starter warehouse) +at SF=0.1 (600,572 LINEITEM rows) and SF=1.0 (6,001,215 rows), using +each op's catalog `platform_overrides` SQL timed per statement. The +sketch ops are currently unreachable via `benchbox run --queries` +(`get_queries()` excludes category `sketch` from the SQL path), so +the survey drove catalog SQL directly; wiring sketch selection into +the CLI is a tracked follow-up. + +| Op | Snowflake SF=0.1 | Snowflake SF=1.0 | Databricks SF=0.1 | Databricks SF=1.0 | +|----|------------------|------------------|-------------------|-------------------| +| `sketch_ddl_create_persistent_table` | pass (502 ms) | pass (625 ms) | pass (1.8 s) | pass (2.1 s) | +| `sketch_insert_theta_per_partition` | FAIL — unknown `DATASKETCHES_THETA_ACCUMULATE` | FAIL (same) | pass (insert 3.2 s) | pass (insert 10.3 s) | +| `sketch_insert_kll_per_partition` | FAIL — unknown `DATASKETCHES_KLL_ACCUMULATE` | FAIL (same) | pass (insert 1.6 s) | pass (insert 2.2 s) | +| `sketch_insert_topk_per_shard` | FAIL — `APPROX_TOP_K_ACCUMULATE` returns OBJECT, column is BINARY | FAIL (same) | FAIL — executed two-arg `approx_top_k_accumulate(l_shipmode, 10000)` (datatype mismatch); the catalog override is one-arg `approx_top_k_accumulate(l_shipmode)` and was not exercised — re-run pending | FAIL (same) | +| ★ `sketch_query_theta_union_merge` | FAIL (theta accumulate unknown) | FAIL (same) | pass (merge 0.7 s at SF=0.1; 1.2 s at SF=1.0; merge fan-in unchanged — see note) | pass | +| ★ `sketch_query_kll_quantiles_merge` | FAIL (KLL accumulate unknown) | FAIL (same) | pass (merge 0.5 s at SF=0.1; 0.6 s at SF=1.0; merge fan-in unchanged — see note) | pass | +| ★ `sketch_query_topk_combine` | FAIL (accumulate type mismatch) | FAIL (same) | FAIL (same unexercised-override cause as insert; re-run pending) | FAIL (same) | +| `sketch_drop_persistent_table` | pass (933 ms) | pass (750 ms) | pass (2.4 s) | pass (2.4 s) | +| `sketch_cpc_*` (4 ops), `sketch_req_*` (4 ops), `sketch_*_lgk*` (2), `sketch_*_k100/k1000` (2), `sketch_*_lgmm*` (2) | SKIP — explicit `snowflake: null` override (unsupported; never executed) | SKIP (same) | SKIP — explicit `databricks: null` override (unsupported; never executed) | SKIP (same) | + +Score: Snowflake 2/8 attempted, Databricks 6/8 attempted (identical at +both scale factors). The remaining 14 CPC/REQ/sweep ops carry explicit +`null` platform overrides and are skipped as unsupported before any SQL +runs (null-override skip in `benchmark.py`); they are excluded from the +scores rather than counted as failures. +Raw per-statement timings: `$BENCHBOX_OUTPUT_DIR/logs/approx-survey-20260925-1136/{snowflake,databricks}-sketch-{sf01,sf1}.jsonl`. + +Unsurveyed platforms and the reason: + +| Platform | Reason | +|----------|--------| +| Firebolt | no credentials on survey host | +| Starburst/Trino | no credentials on survey host | + +What this corrects in the matrix above: + +- Snowflake Theta/KLL rows assume `DATASKETCHES_*` functions exist. + On the surveyed account they do not — Snowflake's DataSketches + functions require an extension/account feature that is not enabled + here. Until the catalog gains a fallback or the docs scope the + requirement, treat Snowflake Theta/KLL sketch persistence as + **unverified**, not supported. +- Databricks Theta and KLL persist + merge + requery all pass with + native `theta_sketch_*` / `kll_sketch_*` functions. Merge times were + 0.7 s at SF=0.1 vs 1.2 s at SF=1.0 (Theta) and 0.5 s vs 0.6 s (KLL), + but Theta/KLL partition by `l_shipdate, l_returnflag`, so scaling + SF=0.1 to SF=1.0 grows rows within groups while the merge fan-in + (partition count) stays roughly fixed. These numbers do not + demonstrate sub-linear scaling with 10x merge input; a fan-in-varying + run (more persisted partitions) is needed before claiming the + O(1)-ish merge story. +- Databricks Top-K: the reported two-arg + `approx_top_k_accumulate(l_shipmode, 10000)` failure did not execute + the catalog override (one-arg `approx_top_k_accumulate(l_shipmode)`), + so it cannot establish catalog drift. Re-run the exact override before + recording this as a catalog failure; do not fix the catalog based on + this run alone. +- CPC/REQ/lgk/k100/lgmm ops carry explicit `null` overrides for both + Snowflake and Databricks, so the runner reports them as + skipped-unsupported and never executes SQL. They are out of scope for + this survey; extending overrides is a follow-up. +- Firebolt and Starburst/Trino remain unsurveyed (no credentials on + the survey host). Their rows belong in the matrix above once the + remaining-legs follow-up runs them. + ### Redshift HLL-only ceiling Redshift's approximate-aggregate surface is fundamentally HLL-only — @@ -352,17 +420,29 @@ Top-K only sweeps two values because `lg_max_map_size=6` (64 buckets) is too small for TPC-H lineitem's 7 distinct shipmodes — the merged sketch would saturate. -Cloud-engine sweep variants are deferred. Each cloud engine has +The sweep stays DuckDB-only. ClickHouse lacks parameterized accuracy +knobs for its native sketches, so a ClickHouse "counterpart" would +compare different algorithms (fixed-precision `uniqHLL12` versus +`uniqExact`, `quantileBFloat16` versus `quantileExact`) rather than the +swept parameter — results labeled as `lg_k`, `k`, or `lg_max_map_size` +effects would actually measure algorithm changes. The sweep ops are +therefore unsupported on ClickHouse (null platform override, the +established skip). Remaining cloud engines stay deferred: each has different parameter knobs (Snowflake `APPROX_TOP_K` `counters` vs -DataSketches `lg_max_map_size`, etc.); 6 families × 3 cloud engines is -out of proportion with the analytical value. DuckDB-only sweep is -enough to demonstrate the tradeoff; cloud users tune at their end with +DataSketches `lg_max_map_size`, etc.); 6 families × remaining cloud +engines is out of proportion with the analytical value. The DuckDB-only +sweep demonstrates the tradeoff; cloud users tune at their end with vendor-specific knobs. -KLL variants are end-to-end verified on the installed datasketches -extension (k=100: ~2KB merged at SF=0.01, median=7395 vs true=7500; -k=1000: ~18KB merged, median=7509). Theta and frequent-items variants -share the parent ops' fate w.r.t. the recorded extension drift in +Use the SF=0.01 TPC-H catalog validations to check the KLL median and +storage bounds. Results from synthetic integer sequences do not establish +those corpus bounds. Trino is unsupported for the headline sketch +operations and parameter sweeps; its operation overrides skip execution +before either the sketch SQL or validation SQL reaches the engine. +Check the parameterized casts and merge calls separately from the +headline functions: their availability alone does not establish sweep +compatibility. Theta and frequent-items variants remain subject to the +recorded extension drift in `_project/blind-spots/2026-05-02-155524-duckdb-datasketches-extension-drift.md`. ## Single-query scope: what this benchmark is **not** diff --git a/docs/blog/images/v041_lakehouse_matrix.png b/docs/blog/images/v041_lakehouse_matrix.png new file mode 100644 index 0000000000..8183eb08f9 Binary files /dev/null and b/docs/blog/images/v041_lakehouse_matrix.png differ diff --git a/docs/blog/index.md b/docs/blog/index.md new file mode 100644 index 0000000000..d517ad8eb4 --- /dev/null +++ b/docs/blog/index.md @@ -0,0 +1,53 @@ +--- +orphan: true +--- + +(blog)= + +% Keep this comment between the label and the heading: it makes the heading id +% "id1", as the previous RST page produced, so existing #id1 links keep working. + +# Blog + +The BenchBox blog features benchmarking insights, performance analysis, and updates about the project. + +## Recent Posts + +```{postlist} +:date: %B %d, %Y +:format: {date} - {title} +:list-style: none +:excerpts: +``` + +## Archives + +Browse by: + +```{eval-rst} +* `Archive `_ - Posts organized by year +* `Tags `_ - Posts organized by topic +* `Authors `_ - Posts organized by author +``` + +```{toctree} +:maxdepth: 1 +:hidden: + +2026-01-22-welcome +2026-02-15-v0-1-2-release-summary +2026-02-15-why-we-deleted-plotly +2026-02-23-v0-1-3-release-summary +2026-03-03-v0-1-4-release-summary +2026-03-03-duckdb-tpch-extension-vs-benchbox +2026-03-10-extracting-textcharts +2026-03-10-v0-1-5-release-summary +2026-04-01-v0-2-0-release-summary +2026-04-26-v0-2-1-release-summary +2026-04-26-scale-factor-harmonization +2026-05-18-sketch-functions-databricks-response +2026-05-18-v0-3-0-release-overview +2026-05-18-joinorder-imdb-2013-dataset +2026-08-31-v0-4-0-release-overview +2026-09-15-results-explorer-introduction +``` diff --git a/docs/blog/index.rst b/docs/blog/index.rst deleted file mode 100644 index 33f8f8dbc6..0000000000 --- a/docs/blog/index.rst +++ /dev/null @@ -1,48 +0,0 @@ -.. _blog: - -:orphan: - -==== -Blog -==== - -The BenchBox blog features benchmarking insights, performance analysis, and updates about the project. - -Recent Posts -============ - -.. postlist:: - :date: %B %d, %Y - :format: {date} - {title} - :list-style: none - :excerpts: - -Archives -======== - -Browse by: - -* :doc:`/blog/archive` - Posts organized by year -* :doc:`/blog/tag` - Posts organized by topic -* :doc:`/blog/author` - Posts organized by author - -.. toctree:: - :maxdepth: 1 - :hidden: - - 2026-01-22-welcome - 2026-02-15-v0-1-2-release-summary - 2026-02-15-why-we-deleted-plotly - 2026-02-23-v0-1-3-release-summary - 2026-03-03-v0-1-4-release-summary - 2026-03-03-duckdb-tpch-extension-vs-benchbox - 2026-03-10-extracting-textcharts - 2026-03-10-v0-1-5-release-summary - 2026-04-01-v0-2-0-release-summary - 2026-04-26-v0-2-1-release-summary - 2026-04-26-scale-factor-harmonization - 2026-05-18-sketch-functions-databricks-response - 2026-05-18-v0-3-0-release-overview - 2026-05-18-joinorder-imdb-2013-dataset - 2026-08-31-v0-4-0-release-overview - 2026-09-15-results-explorer-introduction diff --git a/docs/compat/capability-matrix.md b/docs/compat/capability-matrix.md index 216b117ed8..c9b61ea3cc 100644 --- a/docs/compat/capability-matrix.md +++ b/docs/compat/capability-matrix.md @@ -4,49 +4,49 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is the authoritative source of compatibility policy; this document is regenerated from it. See [adr-sql-compat-phase-aware-pipeline.md](../development/adr/adr-sql-compat-phase-aware-pipeline.md) for the design. -**Total registered rules:** 492 +**Total registered rules:** 557 **Platforms covered:** 35 ## Phase coverage by platform -| platform | benchmark_gate | query_source | query_adapter | schema_emit | ddl_optimize | execution_filter | total | -|---|---|---|---|---|---|---|---| -| athena | - | - | - | - | 1 | - | 1 | -| bigquery | - | 2 | - | 2 | 1 | - | 5 | -| clickhouse | - | 18 | 3 | 4 | 1 | - | 26 | -| clickhouse-cloud | - | - | - | - | - | 23 | 23 | -| clickhouse-local | 1 | - | - | - | - | 23 | 24 | -| clickhouse-server | - | - | - | - | - | 23 | 23 | -| databend | - | - | - | - | 1 | - | 1 | -| databricks | - | 1 | - | 2 | 1 | - | 4 | -| datafusion | - | 3 | 4 | 2 | - | 14 | 23 | -| doris | - | 7 | - | 2 | 1 | - | 10 | -| duckdb | - | - | - | - | - | 26 | 26 | -| ducklake | - | - | - | 2 | 1 | - | 3 | -| fabric_dw | - | - | - | - | 1 | - | 1 | -| firebolt | - | - | - | - | 1 | - | 1 | -| lakesail | 5 | 6 | 11 | - | 1 | 69 | 92 | -| mysql | - | 1 | - | - | - | - | 1 | -| pg-duckdb | 3 | - | - | - | - | 21 | 24 | -| pg-mooncake | 6 | - | - | - | - | 17 | 23 | -| pg_mooncake | - | - | - | - | 1 | - | 1 | -| postgres | - | - | - | 1 | - | 67 | 68 | -| postgresql | - | 6 | - | 1 | 1 | - | 8 | -| presto | - | - | - | 2 | 1 | - | 3 | -| questdb | 1 | - | - | - | 1 | - | 2 | -| redshift | - | - | - | 2 | 1 | - | 3 | -| singlestore | - | - | - | - | 4 | - | 4 | -| snowflake | - | 7 | - | 2 | 1 | - | 10 | -| spark | - | 6 | 11 | 2 | 1 | - | 20 | -| sqlite | - | 1 | - | - | - | - | 1 | -| starrocks | - | 14 | 1 | 2 | 1 | - | 18 | -| synapse | - | - | - | - | 1 | - | 1 | -| timescale | - | - | - | 3 | - | - | 3 | -| timescaledb | 3 | - | - | - | - | 19 | 22 | -| trino | - | - | - | 2 | 1 | - | 3 | -| tsql | - | - | - | 2 | - | - | 2 | -| velox | - | - | 11 | - | 1 | - | 12 | +| platform | benchmark_gate | query_source | query_compile | query_adapter | schema_emit | ddl_optimize | execution_filter | total | +|---|---|---|---|---|---|---|---|---| +| athena | - | - | - | - | - | 1 | - | 1 | +| bigquery | - | 2 | - | 25 | 2 | 1 | 8 | 38 | +| clickhouse | - | 19 | - | 3 | 4 | 1 | - | 27 | +| clickhouse-cloud | - | - | - | 2 | - | - | 21 | 23 | +| clickhouse-local | 1 | - | - | 2 | - | - | 21 | 24 | +| clickhouse-server | - | - | - | 2 | - | - | 21 | 23 | +| databend | - | - | - | - | - | 1 | - | 1 | +| databricks | - | 1 | - | 11 | 2 | 2 | 8 | 24 | +| datafusion | - | 3 | - | 6 | 2 | - | 12 | 23 | +| doris | - | 7 | - | - | 2 | 1 | - | 10 | +| duckdb | - | - | - | - | - | - | 26 | 26 | +| ducklake | - | - | - | - | 2 | 1 | - | 3 | +| fabric_dw | - | - | - | - | - | 1 | - | 1 | +| firebolt | - | - | - | - | - | 1 | - | 1 | +| lakesail | 5 | 6 | - | 11 | - | 1 | 69 | 92 | +| mysql | - | 1 | - | - | - | - | - | 1 | +| pg-duckdb | 3 | - | - | 10 | - | - | 11 | 24 | +| pg-mooncake | 6 | - | - | 10 | - | - | 7 | 23 | +| pg_mooncake | - | - | - | - | - | 1 | - | 1 | +| postgres | - | - | - | - | 1 | - | 67 | 68 | +| postgresql | - | 6 | - | - | 1 | 1 | - | 8 | +| presto | - | - | - | - | 2 | 1 | - | 3 | +| questdb | 1 | - | - | - | - | 1 | - | 2 | +| redshift | - | - | - | - | 2 | 1 | - | 3 | +| singlestore | - | - | - | - | - | 4 | - | 4 | +| snowflake | - | 7 | - | 2 | 2 | 1 | 7 | 19 | +| spark | - | 6 | - | 11 | 2 | 1 | - | 20 | +| sqlite | - | 1 | 1 | - | - | - | - | 2 | +| starrocks | - | 15 | - | 1 | 2 | 1 | - | 19 | +| synapse | - | - | - | - | - | 1 | - | 1 | +| timescale | - | - | - | - | 3 | - | - | 3 | +| timescaledb | 3 | - | - | 10 | - | - | 9 | 22 | +| trino | - | - | - | - | 2 | 1 | - | 3 | +| tsql | - | - | - | - | 2 | - | - | 2 | +| velox | - | - | - | 11 | - | 1 | - | 12 | ## Rules by platform @@ -62,9 +62,42 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is |---|---|---|---|---|---| | query_source | benchmark=h2odb, query=Q9 | select_variant | REWRITTEN | SYNTAX_ERROR | `query_source.bigquery.h2odb.q9_percentile_window_variant` | | query_source | benchmark=tpcdi, query=EQ7 | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.bigquery.tpcdi.eq7_derived_table_variant` | +| query_adapter | benchmark=tpchavoc, query=10_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.10_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=11_v4 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.11_v4.qualified_having` | +| query_adapter | benchmark=tpchavoc, query=11_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.11_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=12_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.12_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=12_v8 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.12_v8.one_row_source` | +| query_adapter | benchmark=tpchavoc, query=13_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.13_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=13_v8 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.13_v8.one_row_source` | +| query_adapter | benchmark=tpchavoc, query=14_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.14_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=15_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.15_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=16_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.16_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=17_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.17_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=18_v4 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.18_v4.one_row_source` | +| query_adapter | benchmark=tpchavoc, query=18_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.18_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=1_v6 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.1_v6.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=2_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.2_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=3_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.3_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=3_v9 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.3_v9.window_null_order` | +| query_adapter | benchmark=tpchavoc, query=4_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.4_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=5_v4 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.5_v4.qualified_having` | +| query_adapter | benchmark=tpchavoc, query=5_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.5_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=6_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.6_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=7_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.7_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=7_v8 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.7_v8.one_row_source` | +| query_adapter | benchmark=tpchavoc, query=8_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.8_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=9_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.bigquery.tpchavoc.9_v7.filtered_aggregate` | | schema_emit | benchmark=transaction_primitives | rewrite_ddl | INFORMATIONAL | SILENT_CORRUPTION | `schema_emit.bigquery.transaction_primitives.pk_not_enforced` | | schema_emit | benchmark=write_primitives | rewrite_ddl | INFORMATIONAL | SILENT_CORRUPTION | `schema_emit.bigquery.write_primitives.pk_not_enforced` | | ddl_optimize | platform-wide | rewrite_ddl | REWRITTEN | SYNTAX_ERROR | `ddl_optimize.bigquery.all.convert_to_bigquery_table` | +| execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.bigquery.tpchavoc.1_v7` | +| execution_filter | benchmark=tpchavoc, query=2_v2 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.bigquery.tpchavoc.2_v2` | +| execution_filter | benchmark=transaction_primitives, query=transaction_create_temp_table | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.bigquery.transaction_primitives.transaction_create_temp_table` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_read_committed | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.bigquery.transaction_primitives.transaction_isolation_read_committed` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.bigquery.transaction_primitives.transaction_isolation_repeatable_read` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_serializable | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.bigquery.transaction_primitives.transaction_isolation_serializable` | +| execution_filter | benchmark=transaction_primitives, query=transaction_savepoint_deep_nesting | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.bigquery.transaction_primitives.transaction_savepoint_deep_nesting` | +| execution_filter | benchmark=transaction_primitives, query=transaction_savepoint_nested | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.bigquery.transaction_primitives.transaction_savepoint_nested` | ### clickhouse @@ -73,6 +106,7 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | query_source | benchmark=coffeeshop, query=SA4 | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.clickhouse.coffeeshop.sa4_cross_join_variant` | | query_source | benchmark=coffeeshop, query=TM1 | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.clickhouse.coffeeshop.tm1_todatetime_variant` | | query_source | benchmark=h2odb, query=Q9 | select_variant | REWRITTEN | SYNTAX_ERROR | `query_source.clickhouse.h2odb.q9_quantile_variant` | +| query_source | benchmark=nyctaxi, query=fhv-base-volume | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.clickhouse.nyctaxi.fhv_base_volume_datediff_variant` | | query_source | benchmark=nyctaxi, query=rush-hour-analysis | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.clickhouse.nyctaxi.rush_hour_datediff_variant` | | query_source | benchmark=nyctaxi, query=trip-duration-analysis | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.clickhouse.nyctaxi.trip_duration_datediff_variant` | | query_source | benchmark=nyctaxi, query=trips-by-day-of-week | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.clickhouse.nyctaxi.trips_by_dow_todayofweek_variant` | @@ -101,9 +135,10 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | phase | scope | action | support | failure mode | rule_id | |---|---|---|---|---|---| +| query_adapter | benchmark=tpchavoc, query=12_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.clickhouse-cloud.tpchavoc.12_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=1_v6 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.clickhouse-cloud.tpchavoc.1_v6.filtered_aggregate` | | execution_filter | benchmark=tpchavoc, query=10_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.10_v1` | | execution_filter | benchmark=tpchavoc, query=11_v4 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.11_v4` | -| execution_filter | benchmark=tpchavoc, query=12_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.12_v7` | | execution_filter | benchmark=tpchavoc, query=13_v8 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.13_v8` | | execution_filter | benchmark=tpchavoc, query=14_v8 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.14_v8` | | execution_filter | benchmark=tpchavoc, query=16_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.16_v1` | @@ -111,7 +146,6 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | execution_filter | benchmark=tpchavoc, query=17_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.17_v10` | | execution_filter | benchmark=tpchavoc, query=17_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.17_v7` | | execution_filter | benchmark=tpchavoc, query=1_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.1_v10` | -| execution_filter | benchmark=tpchavoc, query=1_v6 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.1_v6` | | execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.1_v7` | | execution_filter | benchmark=tpchavoc, query=3_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.3_v1` | | execution_filter | benchmark=tpchavoc, query=3_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-cloud.tpchavoc.3_v10` | @@ -130,9 +164,10 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | phase | scope | action | support | failure mode | rule_id | |---|---|---|---|---|---| | benchmark_gate | benchmark=metadata_primitives | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.clickhouse-local.metadata_primitives.unsupported` | +| query_adapter | benchmark=tpchavoc, query=12_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.clickhouse-local.tpchavoc.12_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=1_v6 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.clickhouse-local.tpchavoc.1_v6.filtered_aggregate` | | execution_filter | benchmark=tpchavoc, query=10_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.10_v1` | | execution_filter | benchmark=tpchavoc, query=11_v4 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.11_v4` | -| execution_filter | benchmark=tpchavoc, query=12_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.12_v7` | | execution_filter | benchmark=tpchavoc, query=13_v8 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.13_v8` | | execution_filter | benchmark=tpchavoc, query=14_v8 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.14_v8` | | execution_filter | benchmark=tpchavoc, query=16_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.16_v1` | @@ -140,7 +175,6 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | execution_filter | benchmark=tpchavoc, query=17_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.17_v10` | | execution_filter | benchmark=tpchavoc, query=17_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.17_v7` | | execution_filter | benchmark=tpchavoc, query=1_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.1_v10` | -| execution_filter | benchmark=tpchavoc, query=1_v6 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.1_v6` | | execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.1_v7` | | execution_filter | benchmark=tpchavoc, query=3_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.3_v1` | | execution_filter | benchmark=tpchavoc, query=3_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-local.tpchavoc.3_v10` | @@ -158,9 +192,10 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | phase | scope | action | support | failure mode | rule_id | |---|---|---|---|---|---| +| query_adapter | benchmark=tpchavoc, query=12_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.clickhouse-server.tpchavoc.12_v7.filtered_aggregate` | +| query_adapter | benchmark=tpchavoc, query=1_v6 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.clickhouse-server.tpchavoc.1_v6.filtered_aggregate` | | execution_filter | benchmark=tpchavoc, query=10_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.10_v1` | | execution_filter | benchmark=tpchavoc, query=11_v4 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.11_v4` | -| execution_filter | benchmark=tpchavoc, query=12_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.12_v7` | | execution_filter | benchmark=tpchavoc, query=13_v8 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.13_v8` | | execution_filter | benchmark=tpchavoc, query=14_v8 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.14_v8` | | execution_filter | benchmark=tpchavoc, query=16_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.16_v1` | @@ -168,7 +203,6 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | execution_filter | benchmark=tpchavoc, query=17_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.17_v10` | | execution_filter | benchmark=tpchavoc, query=17_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.17_v7` | | execution_filter | benchmark=tpchavoc, query=1_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.1_v10` | -| execution_filter | benchmark=tpchavoc, query=1_v6 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.1_v6` | | execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.1_v7` | | execution_filter | benchmark=tpchavoc, query=3_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.3_v1` | | execution_filter | benchmark=tpchavoc, query=3_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.clickhouse-server.tpchavoc.3_v10` | @@ -193,9 +227,29 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | phase | scope | action | support | failure mode | rule_id | |---|---|---|---|---|---| | query_source | benchmark=tpcdi, query=EQ7 | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.databricks.tpcdi.eq7_derived_table_variant` | +| query_adapter | benchmark=tpchavoc, query=10_v1 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q10_scalar_group_by_first` | +| query_adapter | benchmark=tpchavoc, query=12_v1 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q12_scalar_group_by_first` | +| query_adapter | benchmark=tpchavoc, query=14_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q14_group_by_empty_drop` | +| query_adapter | benchmark=tpchavoc, query=16_v1 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q16_scalar_group_by_first` | +| query_adapter | benchmark=tpchavoc, query=17_v4 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q17_dual_column_alias` | +| query_adapter | benchmark=tpchavoc, query=1_v1 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q01_scalar_group_by_first` | +| query_adapter | benchmark=tpchavoc, query=3_v1 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q03_scalar_group_by_first` | +| query_adapter | benchmark=tpchavoc, query=6_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q06_group_by_empty_drop` | +| query_adapter | benchmark=tpchavoc, query=7_v1 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q07_scalar_group_by_first` | +| query_adapter | benchmark=tpchavoc, query=8_v1 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q08_scalar_group_by_first` | +| query_adapter | benchmark=tpchavoc, query=9_v1 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.databricks.tpchavoc.q09_scalar_group_by_first` | | schema_emit | benchmark=transaction_primitives | rewrite_ddl | INFORMATIONAL | SILENT_CORRUPTION | `schema_emit.databricks.transaction_primitives.pk_not_enforced` | | schema_emit | benchmark=write_primitives | rewrite_ddl | INFORMATIONAL | SILENT_CORRUPTION | `schema_emit.databricks.write_primitives.pk_not_enforced` | +| ddl_optimize | benchmark=transaction_primitives | rewrite_ddl | REWRITTEN | UNSUPPORTED_FEATURE | `ddl_optimize.databricks.transaction_primitives.txn_staging_catalog_managed` | | ddl_optimize | platform-wide | rewrite_ddl | REWRITTEN | SYNTAX_ERROR | `ddl_optimize.databricks.all.convert_to_delta_table` | +| execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.databricks.tpchavoc.1_v7` | +| execution_filter | benchmark=transaction_primitives, query=transaction_create_temp_table | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.databricks.transaction_primitives.transaction_create_temp_table` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_read_committed | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.databricks.transaction_primitives.transaction_isolation_read_committed` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.databricks.transaction_primitives.transaction_isolation_repeatable_read` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_serializable | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.databricks.transaction_primitives.transaction_isolation_serializable` | +| execution_filter | benchmark=transaction_primitives, query=transaction_savepoint_deep_nesting | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.databricks.transaction_primitives.transaction_savepoint_deep_nesting` | +| execution_filter | benchmark=transaction_primitives, query=transaction_savepoint_nested | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.databricks.transaction_primitives.transaction_savepoint_nested` | +| execution_filter | benchmark=transaction_primitives, query=transaction_truncate_in_transaction | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.databricks.transaction_primitives.transaction_truncate_in_transaction` | ### datafusion @@ -208,10 +262,11 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | query_adapter | benchmark=tpch, query=16 | rewrite_query | REWRITTEN | SILENT_CORRUPTION | `query_adapter.datafusion.tpch.q16_not_in_to_not_exists` | | query_adapter | benchmark=tpch, query=18 | rewrite_query | REWRITTEN | SILENT_CORRUPTION | `query_adapter.datafusion.tpch.q18_in_having_to_exists` | | query_adapter | benchmark=tpch, query=20 | rewrite_query | REWRITTEN | SILENT_CORRUPTION | `query_adapter.datafusion.tpch.q20_correlated_to_cte` | +| query_adapter | benchmark=tpchavoc, query=14_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.datafusion.tpchavoc.14_v2.empty_group` | +| query_adapter | benchmark=tpchavoc, query=6_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.datafusion.tpchavoc.6_v2.empty_group` | | schema_emit | benchmark=transaction_primitives | rewrite_ddl | SKIPPED_DDL_FRAGMENT | SYNTAX_ERROR | `schema_emit.datafusion.transaction_primitives.pk_lock_table_unsupported` | | schema_emit | benchmark=write_primitives | rewrite_ddl | SKIPPED_DDL_FRAGMENT | SYNTAX_ERROR | `schema_emit.datafusion.write_primitives.pk_lock_table_unsupported` | | execution_filter | benchmark=tpchavoc, query=12_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.12_v1` | -| execution_filter | benchmark=tpchavoc, query=14_v2 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.14_v2` | | execution_filter | benchmark=tpchavoc, query=14_v8 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.14_v8` | | execution_filter | benchmark=tpchavoc, query=16_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.16_v10` | | execution_filter | benchmark=tpchavoc, query=16_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.16_v7` | @@ -220,7 +275,6 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.1_v7` | | execution_filter | benchmark=tpchavoc, query=4_v10 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.4_v10` | | execution_filter | benchmark=tpchavoc, query=4_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.4_v7` | -| execution_filter | benchmark=tpchavoc, query=6_v2 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.6_v2` | | execution_filter | benchmark=tpchavoc, query=7_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.7_v1` | | execution_filter | benchmark=tpchavoc, query=8_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.8_v1` | | execution_filter | benchmark=tpchavoc, query=9_v1 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.datafusion.tpchavoc.9_v1` | @@ -401,22 +455,22 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | benchmark_gate | benchmark=ai_primitives | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.pg-duckdb.ai_primitives.unsupported` | | benchmark_gate | benchmark=read_primitives | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.pg-duckdb.read_primitives.duckdb_intrinsics` | | benchmark_gate | benchmark=vector_search | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.pg-duckdb.vector_search.no_vector_type` | -| execution_filter | benchmark=tpchavoc, query=10_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.10_v7` | +| query_adapter | benchmark=tpchavoc, query=10_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.10_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=11_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.11_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=17_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.17_v2.qualify_columns` | +| query_adapter | benchmark=tpchavoc, query=17_v4 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.17_v4.values_source` | +| query_adapter | benchmark=tpchavoc, query=2_v5 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.2_v5.qualify_columns` | +| query_adapter | benchmark=tpchavoc, query=3_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.3_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=4_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.4_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=5_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.5_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=7_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.7_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=9_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-duckdb.tpchavoc.9_v7.inline_select_alias` | | execution_filter | benchmark=tpchavoc, query=10_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.10_v9` | -| execution_filter | benchmark=tpchavoc, query=11_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.11_v7` | | execution_filter | benchmark=tpchavoc, query=11_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.11_v9` | | execution_filter | benchmark=tpchavoc, query=13_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.13_v9` | -| execution_filter | benchmark=tpchavoc, query=17_v2 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.17_v2` | -| execution_filter | benchmark=tpchavoc, query=17_v4 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.17_v4` | | execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.1_v7` | -| execution_filter | benchmark=tpchavoc, query=2_v5 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.2_v5` | -| execution_filter | benchmark=tpchavoc, query=3_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.3_v7` | -| execution_filter | benchmark=tpchavoc, query=4_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.4_v7` | -| execution_filter | benchmark=tpchavoc, query=5_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.5_v7` | | execution_filter | benchmark=tpchavoc, query=5_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.5_v9` | -| execution_filter | benchmark=tpchavoc, query=7_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.7_v7` | | execution_filter | benchmark=tpchavoc, query=7_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.7_v9` | -| execution_filter | benchmark=tpchavoc, query=9_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.9_v7` | | execution_filter | benchmark=tpchavoc, query=9_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.tpchavoc.9_v9` | | execution_filter | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.transaction_primitives.transaction_isolation_repeatable_read` | | execution_filter | benchmark=transaction_primitives, query=transaction_isolation_serializable | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-duckdb.transaction_primitives.transaction_isolation_serializable` | @@ -433,22 +487,22 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | benchmark_gate | benchmark=transaction_primitives | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.pg-mooncake.transaction_primitives.moonlink_read_only_mirrors` | | benchmark_gate | benchmark=vector_search | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.pg-mooncake.vector_search.no_vector_type` | | benchmark_gate | benchmark=write_primitives | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.pg-mooncake.write_primitives.moonlink_read_only_mirrors` | -| execution_filter | benchmark=tpchavoc, query=10_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.10_v7` | +| query_adapter | benchmark=tpchavoc, query=10_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.10_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=11_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.11_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=17_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.17_v2.qualify_columns` | +| query_adapter | benchmark=tpchavoc, query=17_v4 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.17_v4.values_source` | +| query_adapter | benchmark=tpchavoc, query=2_v5 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.2_v5.qualify_columns` | +| query_adapter | benchmark=tpchavoc, query=3_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.3_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=4_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.4_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=5_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.5_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=7_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.7_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=9_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.pg-mooncake.tpchavoc.9_v7.inline_select_alias` | | execution_filter | benchmark=tpchavoc, query=10_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.10_v9` | -| execution_filter | benchmark=tpchavoc, query=11_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.11_v7` | | execution_filter | benchmark=tpchavoc, query=11_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.11_v9` | | execution_filter | benchmark=tpchavoc, query=13_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.13_v9` | -| execution_filter | benchmark=tpchavoc, query=17_v2 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.17_v2` | -| execution_filter | benchmark=tpchavoc, query=17_v4 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.17_v4` | | execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.1_v7` | -| execution_filter | benchmark=tpchavoc, query=2_v5 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.2_v5` | -| execution_filter | benchmark=tpchavoc, query=3_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.3_v7` | -| execution_filter | benchmark=tpchavoc, query=4_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.4_v7` | -| execution_filter | benchmark=tpchavoc, query=5_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.5_v7` | | execution_filter | benchmark=tpchavoc, query=5_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.5_v9` | -| execution_filter | benchmark=tpchavoc, query=7_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.7_v7` | | execution_filter | benchmark=tpchavoc, query=7_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.7_v9` | -| execution_filter | benchmark=tpchavoc, query=9_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.9_v7` | | execution_filter | benchmark=tpchavoc, query=9_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.pg-mooncake.tpchavoc.9_v9` | ### pg_mooncake @@ -586,9 +640,18 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | query_source | benchmark=vector_search, query=Q4 | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.snowflake.vector_search.q4_variant` | | query_source | benchmark=vector_search, query=Q5 | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.snowflake.vector_search.q5_variant` | | query_source | benchmark=vector_search, query=Q6 | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.snowflake.vector_search.q6_variant` | +| query_adapter | benchmark=tpchavoc, query=14_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.snowflake.tpchavoc.14_v2.empty_group` | +| query_adapter | benchmark=tpchavoc, query=6_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.snowflake.tpchavoc.6_v2.empty_group` | | schema_emit | benchmark=transaction_primitives | rewrite_ddl | INFORMATIONAL | SILENT_CORRUPTION | `schema_emit.snowflake.transaction_primitives.pk_not_enforced` | | schema_emit | benchmark=write_primitives | rewrite_ddl | INFORMATIONAL | SILENT_CORRUPTION | `schema_emit.snowflake.write_primitives.pk_not_enforced` | | ddl_optimize | platform-wide | rewrite_ddl | REWRITTEN | SYNTAX_ERROR | `ddl_optimize.snowflake.all.optimize_table_definition` | +| execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.snowflake.tpchavoc.1_v7` | +| execution_filter | benchmark=tpchavoc, query=2_v2 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.snowflake.tpchavoc.2_v2` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_read_committed | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.snowflake.transaction_primitives.transaction_isolation_read_committed` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.snowflake.transaction_primitives.transaction_isolation_repeatable_read` | +| execution_filter | benchmark=transaction_primitives, query=transaction_isolation_serializable | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.snowflake.transaction_primitives.transaction_isolation_serializable` | +| execution_filter | benchmark=transaction_primitives, query=transaction_savepoint_deep_nesting | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.snowflake.transaction_primitives.transaction_savepoint_deep_nesting` | +| execution_filter | benchmark=transaction_primitives, query=transaction_savepoint_nested | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.snowflake.transaction_primitives.transaction_savepoint_nested` | ### spark @@ -620,12 +683,14 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | phase | scope | action | support | failure mode | rule_id | |---|---|---|---|---|---| | query_source | benchmark=h2odb, query=Q9 | select_variant | REWRITTEN | SYNTAX_ERROR | `query_source.sqlite.h2odb.q9_percentile_cont_variant` | +| query_compile | benchmark=tpch, query=6 | rewrite_query | REWRITTEN | SILENT_CORRUPTION | `query_compile.sqlite.tpch.q6_exact_discount_bounds` | ### starrocks | phase | scope | action | support | failure mode | rule_id | |---|---|---|---|---|---| | query_source | benchmark=h2odb, query=Q9 | select_variant | REWRITTEN | SYNTAX_ERROR | `query_source.starrocks.h2odb.q9_percentile_approx_variant` | +| query_source | benchmark=nyctaxi, query=fhv-base-volume | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.starrocks.nyctaxi.fhv_base_volume_timestampdiff_variant` | | query_source | benchmark=nyctaxi, query=rush-hour-analysis | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.starrocks.nyctaxi.rush_hour_timestampdiff_variant` | | query_source | benchmark=nyctaxi, query=trip-duration-analysis | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.starrocks.nyctaxi.trip_duration_timestampdiff_variant` | | query_source | benchmark=nyctaxi, query=trips-by-day-of-week | select_variant | REWRITTEN | UNSUPPORTED_FEATURE | `query_source.starrocks.nyctaxi.trips_by_dow_dayofweek_variant` | @@ -665,22 +730,22 @@ Every rule registered in `benchbox.sql_compat` is listed below. The registry is | benchmark_gate | benchmark=ai_primitives | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.timescaledb.ai_primitives.unsupported` | | benchmark_gate | benchmark=read_primitives | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.timescaledb.read_primitives.duckdb_intrinsics` | | benchmark_gate | benchmark=vector_search | block_benchmark | BLOCKED | UNSUPPORTED_FEATURE | `benchmark_gate.timescaledb.vector_search.no_vector_type` | -| execution_filter | benchmark=tpchavoc, query=10_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.10_v7` | +| query_adapter | benchmark=tpchavoc, query=10_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.10_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=11_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.11_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=17_v2 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.17_v2.qualify_columns` | +| query_adapter | benchmark=tpchavoc, query=17_v4 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.17_v4.values_source` | +| query_adapter | benchmark=tpchavoc, query=2_v5 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.2_v5.qualify_columns` | +| query_adapter | benchmark=tpchavoc, query=3_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.3_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=4_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.4_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=5_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.5_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=7_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.7_v7.inline_select_alias` | +| query_adapter | benchmark=tpchavoc, query=9_v7 | rewrite_query | REWRITTEN | UNSUPPORTED_FEATURE | `query_adapter.timescaledb.tpchavoc.9_v7.inline_select_alias` | | execution_filter | benchmark=tpchavoc, query=10_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.10_v9` | -| execution_filter | benchmark=tpchavoc, query=11_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.11_v7` | | execution_filter | benchmark=tpchavoc, query=11_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.11_v9` | | execution_filter | benchmark=tpchavoc, query=13_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.13_v9` | -| execution_filter | benchmark=tpchavoc, query=17_v2 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.17_v2` | -| execution_filter | benchmark=tpchavoc, query=17_v4 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.17_v4` | | execution_filter | benchmark=tpchavoc, query=1_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.1_v7` | -| execution_filter | benchmark=tpchavoc, query=2_v5 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.2_v5` | -| execution_filter | benchmark=tpchavoc, query=3_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.3_v7` | -| execution_filter | benchmark=tpchavoc, query=4_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.4_v7` | -| execution_filter | benchmark=tpchavoc, query=5_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.5_v7` | | execution_filter | benchmark=tpchavoc, query=5_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.5_v9` | -| execution_filter | benchmark=tpchavoc, query=7_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.7_v7` | | execution_filter | benchmark=tpchavoc, query=7_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.7_v9` | -| execution_filter | benchmark=tpchavoc, query=9_v7 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.9_v7` | | execution_filter | benchmark=tpchavoc, query=9_v9 | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.tpchavoc.9_v9` | | execution_filter | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.transaction_primitives.transaction_isolation_repeatable_read` | | execution_filter | benchmark=transaction_primitives, query=transaction_isolation_serializable | skip_query | SKIPPED_QUERY | UNSUPPORTED_FEATURE | `execution_filter.timescaledb.transaction_primitives.transaction_isolation_serializable` | diff --git a/docs/compat/skip-reference.md b/docs/compat/skip-reference.md index a787e3f92c..9ae1b85fc4 100644 --- a/docs/compat/skip-reference.md +++ b/docs/compat/skip-reference.md @@ -4,7 +4,7 @@ Rules the registry applies to queries, benchmarks, and DDL statements. Split into two sections based on whether the outcome is user-visible in result counts. Each entry names the platform, the scope the rule applies to, the registered reason, and the rule_id you can grep for in `benchbox/sql_compat/rules/`. -**Total rules:** 352 +**Total rules:** 337 **Platforms with rules:** 22 @@ -12,13 +12,25 @@ Rules the registry applies to queries, benchmarks, and DDL statements. Split int Queries or benchmarks that are **omitted from the result set** - either because the benchmark is refused at preflight (BLOCKED) or a specific query is excluded from execution (SKIPPED_QUERY). Users see these as missing entries in result counts. +### bigquery + +| support | scope | phase | reason | rule_id | +|---|---|---|---|---| +| SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | BigQuery does not support DuckDB LIST aggregates and LIST lambda functions in this variant. | `execution_filter.bigquery.tpchavoc.1_v7` | +| SKIPPED_QUERY | benchmark=tpchavoc, query=2_v2 | execution_filter | BigQuery rejects the correlated scalar subquery in this grouped variant. | `execution_filter.bigquery.tpchavoc.2_v2` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_create_temp_table | execution_filter | BigQuery runs each multi-statement script as chained single-statement jobs, so a TEMP TABLE created in one statement is invisible to the next. | `execution_filter.bigquery.transaction_primitives.transaction_create_temp_table` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_read_committed | execution_filter | BigQuery rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.bigquery.transaction_primitives.transaction_isolation_read_committed` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | execution_filter | BigQuery rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.bigquery.transaction_primitives.transaction_isolation_repeatable_read` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_serializable | execution_filter | BigQuery rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.bigquery.transaction_primitives.transaction_isolation_serializable` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_savepoint_deep_nesting | execution_filter | BigQuery has no SAVEPOINT statement. | `execution_filter.bigquery.transaction_primitives.transaction_savepoint_deep_nesting` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_savepoint_nested | execution_filter | BigQuery has no SAVEPOINT statement. | `execution_filter.bigquery.transaction_primitives.transaction_savepoint_nested` | + ### clickhouse-cloud | support | scope | phase | reason | rule_id | |---|---|---|---|---| | SKIPPED_QUERY | benchmark=tpchavoc, query=10_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-cloud.tpchavoc.10_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=11_v4 | execution_filter | ClickHouse rejects an aggregate over a select-list-alias aggregate (nested aggregate, Code 184 ILLEGAL_AGGREGATION). | `execution_filter.clickhouse-cloud.tpchavoc.11_v4` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=12_v7 | execution_filter | ClickHouse rejects DuckDB FILTER clauses on COUNT aggregates (Code 42, COUNT requires zero or one argument). | `execution_filter.clickhouse-cloud.tpchavoc.12_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=13_v8 | execution_filter | ClickHouse cannot lower this variant's correlated subquery (CommonSubplan plan step, Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-cloud.tpchavoc.13_v8` | | SKIPPED_QUERY | benchmark=tpchavoc, query=14_v8 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-cloud.tpchavoc.14_v8` | | SKIPPED_QUERY | benchmark=tpchavoc, query=16_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-cloud.tpchavoc.16_v1` | @@ -26,7 +38,6 @@ Queries or benchmarks that are **omitted from the result set** - either because | SKIPPED_QUERY | benchmark=tpchavoc, query=17_v10 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-cloud.tpchavoc.17_v10` | | SKIPPED_QUERY | benchmark=tpchavoc, query=17_v7 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-cloud.tpchavoc.17_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v10 | execution_filter | ClickHouse rejects a GROUP BY whose key is a CASE aliased to the same name as a column it references (alias shadowing, Code 215 NOT_AN_AGGREGATE). | `execution_filter.clickhouse-cloud.tpchavoc.1_v10` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=1_v6 | execution_filter | ClickHouse rejects the DuckDB FILTER clause on aggregate functions (Code 42, COUNT requires zero or one argument). | `execution_filter.clickhouse-cloud.tpchavoc.1_v6` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | ClickHouse has no DuckDB `LIST` aggregate / `list_transform`/`list_zip` lambda forms this variant uses (`Syntax error` at the `->` lambda). | `execution_filter.clickhouse-cloud.tpchavoc.1_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=3_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-cloud.tpchavoc.3_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=3_v10 | execution_filter | ClickHouse cannot SUM a `Variant(Decimal, Float64)` column produced by this variant's mixed-type CASE (Code 43 ILLEGAL_TYPE_OF_ARGUMENT). | `execution_filter.clickhouse-cloud.tpchavoc.3_v10` | @@ -47,7 +58,6 @@ Queries or benchmarks that are **omitted from the result set** - either because | BLOCKED | benchmark=metadata_primitives | benchmark_gate | Embedded ClickHouse Local has no privileged ACL introspection contract. | `benchmark_gate.clickhouse-local.metadata_primitives.unsupported` | | SKIPPED_QUERY | benchmark=tpchavoc, query=10_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-local.tpchavoc.10_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=11_v4 | execution_filter | ClickHouse rejects an aggregate over a select-list-alias aggregate (nested aggregate, Code 184 ILLEGAL_AGGREGATION). | `execution_filter.clickhouse-local.tpchavoc.11_v4` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=12_v7 | execution_filter | ClickHouse rejects DuckDB FILTER clauses on COUNT aggregates (Code 42, COUNT requires zero or one argument). | `execution_filter.clickhouse-local.tpchavoc.12_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=13_v8 | execution_filter | ClickHouse cannot lower this variant's correlated subquery (CommonSubplan plan step, Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-local.tpchavoc.13_v8` | | SKIPPED_QUERY | benchmark=tpchavoc, query=14_v8 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-local.tpchavoc.14_v8` | | SKIPPED_QUERY | benchmark=tpchavoc, query=16_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-local.tpchavoc.16_v1` | @@ -55,7 +65,6 @@ Queries or benchmarks that are **omitted from the result set** - either because | SKIPPED_QUERY | benchmark=tpchavoc, query=17_v10 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-local.tpchavoc.17_v10` | | SKIPPED_QUERY | benchmark=tpchavoc, query=17_v7 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-local.tpchavoc.17_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v10 | execution_filter | ClickHouse rejects a GROUP BY whose key is a CASE aliased to the same name as a column it references (alias shadowing, Code 215 NOT_AN_AGGREGATE). | `execution_filter.clickhouse-local.tpchavoc.1_v10` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=1_v6 | execution_filter | ClickHouse rejects the DuckDB FILTER clause on aggregate functions (Code 42, COUNT requires zero or one argument). | `execution_filter.clickhouse-local.tpchavoc.1_v6` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | ClickHouse has no DuckDB `LIST` aggregate / `list_transform`/`list_zip` lambda forms this variant uses (`Syntax error` at the `->` lambda). | `execution_filter.clickhouse-local.tpchavoc.1_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=3_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-local.tpchavoc.3_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=3_v10 | execution_filter | ClickHouse cannot SUM a `Variant(Decimal, Float64)` column produced by this variant's mixed-type CASE (Code 43 ILLEGAL_TYPE_OF_ARGUMENT). | `execution_filter.clickhouse-local.tpchavoc.3_v10` | @@ -75,7 +84,6 @@ Queries or benchmarks that are **omitted from the result set** - either because |---|---|---|---|---| | SKIPPED_QUERY | benchmark=tpchavoc, query=10_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-server.tpchavoc.10_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=11_v4 | execution_filter | ClickHouse rejects an aggregate over a select-list-alias aggregate (nested aggregate, Code 184 ILLEGAL_AGGREGATION). | `execution_filter.clickhouse-server.tpchavoc.11_v4` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=12_v7 | execution_filter | ClickHouse rejects DuckDB FILTER clauses on COUNT aggregates (Code 42, COUNT requires zero or one argument). | `execution_filter.clickhouse-server.tpchavoc.12_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=13_v8 | execution_filter | ClickHouse cannot lower this variant's correlated subquery (CommonSubplan plan step, Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-server.tpchavoc.13_v8` | | SKIPPED_QUERY | benchmark=tpchavoc, query=14_v8 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-server.tpchavoc.14_v8` | | SKIPPED_QUERY | benchmark=tpchavoc, query=16_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-server.tpchavoc.16_v1` | @@ -83,7 +91,6 @@ Queries or benchmarks that are **omitted from the result set** - either because | SKIPPED_QUERY | benchmark=tpchavoc, query=17_v10 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-server.tpchavoc.17_v10` | | SKIPPED_QUERY | benchmark=tpchavoc, query=17_v7 | execution_filter | ClickHouse does not support correlated subqueries in an aggregate-function argument (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-server.tpchavoc.17_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v10 | execution_filter | ClickHouse rejects a GROUP BY whose key is a CASE aliased to the same name as a column it references (alias shadowing, Code 215 NOT_AN_AGGREGATE). | `execution_filter.clickhouse-server.tpchavoc.1_v10` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=1_v6 | execution_filter | ClickHouse rejects the DuckDB FILTER clause on aggregate functions (Code 42, COUNT requires zero or one argument). | `execution_filter.clickhouse-server.tpchavoc.1_v6` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | ClickHouse has no DuckDB `LIST` aggregate / `list_transform`/`list_zip` lambda forms this variant uses (`Syntax error` at the `->` lambda). | `execution_filter.clickhouse-server.tpchavoc.1_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=3_v1 | execution_filter | ClickHouse does not support correlated subqueries in ORDER BY (Code 48 NOT_IMPLEMENTED). | `execution_filter.clickhouse-server.tpchavoc.3_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=3_v10 | execution_filter | ClickHouse cannot SUM a `Variant(Decimal, Float64)` column produced by this variant's mixed-type CASE (Code 43 ILLEGAL_TYPE_OF_ARGUMENT). | `execution_filter.clickhouse-server.tpchavoc.3_v10` | @@ -97,12 +104,24 @@ Queries or benchmarks that are **omitted from the result set** - either because | SKIPPED_QUERY | benchmark=tpchavoc, query=8_v1 | execution_filter | ClickHouse cannot execute the two correlated market-share subqueries (Code 1/48 correlated subquery planning failure). | `execution_filter.clickhouse-server.tpchavoc.8_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=9_v1 | execution_filter | ClickHouse cannot execute this correlated profit subquery (Code 1/48 correlated subquery planning failure). | `execution_filter.clickhouse-server.tpchavoc.9_v1` | +### databricks + +| support | scope | phase | reason | rule_id | +|---|---|---|---|---| +| SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | Databricks SQL has array aggregation but not DuckDB's LIST_SUM and LIST_ZIP functions used here. | `execution_filter.databricks.tpchavoc.1_v7` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_create_temp_table | execution_filter | Databricks SQL rejects CREATE TEMPORARY TABLE ... IF NOT EXISTS, and temporary views are not allowed inside a transaction (TRANSACTION_NOT_SUPPORTED.COMMAND). | `execution_filter.databricks.transaction_primitives.transaction_create_temp_table` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_read_committed | execution_filter | Databricks SQL rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.databricks.transaction_primitives.transaction_isolation_read_committed` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | execution_filter | Databricks SQL rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.databricks.transaction_primitives.transaction_isolation_repeatable_read` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_serializable | execution_filter | Databricks SQL rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.databricks.transaction_primitives.transaction_isolation_serializable` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_savepoint_deep_nesting | execution_filter | Databricks SQL multi-statement transactions do not support SAVEPOINT. | `execution_filter.databricks.transaction_primitives.transaction_savepoint_deep_nesting` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_savepoint_nested | execution_filter | Databricks SQL multi-statement transactions do not support SAVEPOINT. | `execution_filter.databricks.transaction_primitives.transaction_savepoint_nested` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_truncate_in_transaction | execution_filter | Databricks SQL does not allow TRUNCATE TABLE inside a transaction (TRANSACTION_NOT_SUPPORTED.COMMAND). | `execution_filter.databricks.transaction_primitives.transaction_truncate_in_transaction` | + ### datafusion | support | scope | phase | reason | rule_id | |---|---|---|---|---| | SKIPPED_QUERY | benchmark=tpchavoc, query=12_v1 | execution_filter | DataFusion `scalar_subquery_to_join` optimizer rule reports an ambiguous `count(*)` for this variant. | `execution_filter.datafusion.tpchavoc.12_v1` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=14_v2 | execution_filter | DataFusion rejects the generated empty-tuple sub-query (`Empty tuple not supported yet`). | `execution_filter.datafusion.tpchavoc.14_v2` | | SKIPPED_QUERY | benchmark=tpchavoc, query=14_v8 | execution_filter | DataFusion physical planning does not support EXISTS in this variant shape. | `execution_filter.datafusion.tpchavoc.14_v8` | | SKIPPED_QUERY | benchmark=tpchavoc, query=16_v10 | execution_filter | DataFusion physical planning does not support the IN sub-query in this variant shape. | `execution_filter.datafusion.tpchavoc.16_v10` | | SKIPPED_QUERY | benchmark=tpchavoc, query=16_v7 | execution_filter | DataFusion physical planning does not support the IN sub-query in this variant shape. | `execution_filter.datafusion.tpchavoc.16_v7` | @@ -111,7 +130,6 @@ Queries or benchmarks that are **omitted from the result set** - either because | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | DataFusion has no `LIST` aggregate used by this variant (`Invalid function 'list'`). | `execution_filter.datafusion.tpchavoc.1_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=4_v10 | execution_filter | DataFusion physical planning does not support EXISTS in this variant shape. | `execution_filter.datafusion.tpchavoc.4_v10` | | SKIPPED_QUERY | benchmark=tpchavoc, query=4_v7 | execution_filter | DataFusion physical planning does not support EXISTS in this variant shape. | `execution_filter.datafusion.tpchavoc.4_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=6_v2 | execution_filter | DataFusion rejects the generated empty-tuple sub-query (`Empty tuple not supported yet`). | `execution_filter.datafusion.tpchavoc.6_v2` | | SKIPPED_QUERY | benchmark=tpchavoc, query=7_v1 | execution_filter | DataFusion physical planning does not support the scalar sub-query in this variant shape. | `execution_filter.datafusion.tpchavoc.7_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=8_v1 | execution_filter | DataFusion `scalar_subquery_to_join` optimizer rule fails to resolve a sub-query field for this variant. | `execution_filter.datafusion.tpchavoc.8_v1` | | SKIPPED_QUERY | benchmark=tpchavoc, query=9_v1 | execution_filter | DataFusion physical planning does not support the scalar sub-query in this variant shape. | `execution_filter.datafusion.tpchavoc.9_v1` | @@ -239,22 +257,12 @@ Queries or benchmarks that are **omitted from the result set** - either because | BLOCKED | benchmark=ai_primitives | benchmark_gate | AI primitives is an LLM/tooling benchmark, not a PostgreSQL-family SQL engine workload. The 2026-05-13 enabled-platform UAT run failed before schema creation with `argument should be a str or an os.PathLike object` on pg-duckdb, pg-mooncake, and TimescaleDB. | `benchmark_gate.pg-duckdb.ai_primitives.unsupported` | | BLOCKED | benchmark=read_primitives | benchmark_gate | Read primitives currently includes a DuckDB-heavy SQL primitive catalog without PostgreSQL-family variants for approximate aggregates, arg_min/arg_max, GROUP BY ALL, ORDER BY ALL, ASOF JOIN, UNPIVOT, struct/map/list intrinsics, and related functions. The 2026-05-13 enabled-platform UAT run showed repeated unsupported-function and syntax failures across pg-duckdb, pg-mooncake, and TimescaleDB. | `benchmark_gate.pg-duckdb.read_primitives.duckdb_intrinsics` | | BLOCKED | benchmark=vector_search | benchmark_gate | Vector search requires a VECTOR column type and vector-distance operators. The 2026-05-13 enabled-platform UAT run failed during schema creation with `type "vector" does not exist` on pg-duckdb, pg-mooncake, and TimescaleDB. | `benchmark_gate.pg-duckdb.vector_search.no_vector_type` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=10_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.pg-duckdb.tpchavoc.10_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=10_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-duckdb.tpchavoc.10_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=11_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `value` in WHERE. | `execution_filter.pg-duckdb.tpchavoc.11_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=11_v9 | execution_filter | PostgreSQL-family engines reject this window expression in WHERE. | `execution_filter.pg-duckdb.tpchavoc.11_v9` | | SKIPPED_QUERY | benchmark=tpchavoc, query=13_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-duckdb.tpchavoc.13_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=17_v2 | execution_filter | PostgreSQL-family engines report ambiguous `l_partkey` resolution for this variant shape. | `execution_filter.pg-duckdb.tpchavoc.17_v2` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=17_v4 | execution_filter | PostgreSQL-family engines do not provide Oracle's `dual` table. | `execution_filter.pg-duckdb.tpchavoc.17_v4` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | PostgreSQL-family engines do not provide DuckDB's `LIST()` aggregate used by this variant. | `execution_filter.pg-duckdb.tpchavoc.1_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=2_v5 | execution_filter | PostgreSQL-family engines report ambiguous `ps_partkey` resolution for this variant shape. | `execution_filter.pg-duckdb.tpchavoc.2_v5` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=3_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.pg-duckdb.tpchavoc.3_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=4_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `order_count` in HAVING. | `execution_filter.pg-duckdb.tpchavoc.4_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=5_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.pg-duckdb.tpchavoc.5_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=5_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-duckdb.tpchavoc.5_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=7_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.pg-duckdb.tpchavoc.7_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=7_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-duckdb.tpchavoc.7_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=9_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `sum_profit` in HAVING. | `execution_filter.pg-duckdb.tpchavoc.9_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=9_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-duckdb.tpchavoc.9_v9` | | SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | execution_filter | pg_duckdb rejects non-default transaction isolation changes during operation execution. | `execution_filter.pg-duckdb.transaction_primitives.transaction_isolation_repeatable_read` | | SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_serializable | execution_filter | pg_duckdb rejects non-default transaction isolation changes during operation execution. | `execution_filter.pg-duckdb.transaction_primitives.transaction_isolation_serializable` | @@ -271,22 +279,12 @@ Queries or benchmarks that are **omitted from the result set** - either because | BLOCKED | benchmark=transaction_primitives | benchmark_gate | pg_mooncake benchmark tables are promoted to mooncake mirrors for analytical execution, but transaction_primitives requires repeated transactional writes against the TPC-H corpus. Targeted UAT on 2026-05-13 failed in the same promotion/write path, including Moonlink duplicate replication registration after the write_primitives attempt. | `benchmark_gate.pg-mooncake.transaction_primitives.moonlink_read_only_mirrors` | | BLOCKED | benchmark=vector_search | benchmark_gate | Vector search requires a VECTOR column type and vector-distance operators. The 2026-05-13 enabled-platform UAT run failed during schema creation with `type "vector" does not exist` on pg-duckdb, pg-mooncake, and TimescaleDB. | `benchmark_gate.pg-mooncake.vector_search.no_vector_type` | | BLOCKED | benchmark=write_primitives | benchmark_gate | pg_mooncake benchmark tables are promoted to mooncake mirrors for analytical execution, and those mirrors do not support the write_primitives setup/write contract. Targeted UAT on 2026-05-13 reached execution after raising replication sender limits, then failed setup with `DuckDB does not support modifying Postgres tables`. | `benchmark_gate.pg-mooncake.write_primitives.moonlink_read_only_mirrors` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=10_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.pg-mooncake.tpchavoc.10_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=10_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-mooncake.tpchavoc.10_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=11_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `value` in WHERE. | `execution_filter.pg-mooncake.tpchavoc.11_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=11_v9 | execution_filter | PostgreSQL-family engines reject this window expression in WHERE. | `execution_filter.pg-mooncake.tpchavoc.11_v9` | | SKIPPED_QUERY | benchmark=tpchavoc, query=13_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-mooncake.tpchavoc.13_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=17_v2 | execution_filter | PostgreSQL-family engines report ambiguous `l_partkey` resolution for this variant shape. | `execution_filter.pg-mooncake.tpchavoc.17_v2` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=17_v4 | execution_filter | PostgreSQL-family engines do not provide Oracle's `dual` table. | `execution_filter.pg-mooncake.tpchavoc.17_v4` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | PostgreSQL-family engines do not provide DuckDB's `LIST()` aggregate used by this variant. | `execution_filter.pg-mooncake.tpchavoc.1_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=2_v5 | execution_filter | PostgreSQL-family engines report ambiguous `ps_partkey` resolution for this variant shape. | `execution_filter.pg-mooncake.tpchavoc.2_v5` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=3_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.pg-mooncake.tpchavoc.3_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=4_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `order_count` in HAVING. | `execution_filter.pg-mooncake.tpchavoc.4_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=5_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.pg-mooncake.tpchavoc.5_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=5_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-mooncake.tpchavoc.5_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=7_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.pg-mooncake.tpchavoc.7_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=7_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-mooncake.tpchavoc.7_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=9_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `sum_profit` in HAVING. | `execution_filter.pg-mooncake.tpchavoc.9_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=9_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.pg-mooncake.tpchavoc.9_v9` | ### postgres @@ -367,6 +365,18 @@ Queries or benchmarks that are **omitted from the result set** - either because |---|---|---|---|---| | BLOCKED | benchmark=vector_search | benchmark_gate | QuestDB 9.3.4 has no VECTOR column type. Schema creation fails immediately. No fix planned: requires QuestDB to add native vector support. | `benchmark_gate.questdb.vector_search.unsupported` | +### snowflake + +| support | scope | phase | reason | rule_id | +|---|---|---|---|---| +| SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | Snowflake does not support DuckDB LIST aggregates and LIST lambda functions in this variant. | `execution_filter.snowflake.tpchavoc.1_v7` | +| SKIPPED_QUERY | benchmark=tpchavoc, query=2_v2 | execution_filter | Snowflake rejects the correlated scalar subquery in this grouped variant. | `execution_filter.snowflake.tpchavoc.2_v2` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_read_committed | execution_filter | Snowflake rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.snowflake.transaction_primitives.transaction_isolation_read_committed` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | execution_filter | Snowflake rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.snowflake.transaction_primitives.transaction_isolation_repeatable_read` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_serializable | execution_filter | Snowflake rejects SET TRANSACTION ISOLATION LEVEL. | `execution_filter.snowflake.transaction_primitives.transaction_isolation_serializable` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_savepoint_deep_nesting | execution_filter | Snowflake has no SAVEPOINT statement. | `execution_filter.snowflake.transaction_primitives.transaction_savepoint_deep_nesting` | +| SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_savepoint_nested | execution_filter | Snowflake has no SAVEPOINT statement. | `execution_filter.snowflake.transaction_primitives.transaction_savepoint_nested` | + ### starrocks | support | scope | phase | reason | rule_id | @@ -380,22 +390,12 @@ Queries or benchmarks that are **omitted from the result set** - either because | BLOCKED | benchmark=ai_primitives | benchmark_gate | AI primitives is an LLM/tooling benchmark, not a PostgreSQL-family SQL engine workload. The 2026-05-13 enabled-platform UAT run failed before schema creation with `argument should be a str or an os.PathLike object` on pg-duckdb, pg-mooncake, and TimescaleDB. | `benchmark_gate.timescaledb.ai_primitives.unsupported` | | BLOCKED | benchmark=read_primitives | benchmark_gate | Read primitives currently includes a DuckDB-heavy SQL primitive catalog without PostgreSQL-family variants for approximate aggregates, arg_min/arg_max, GROUP BY ALL, ORDER BY ALL, ASOF JOIN, UNPIVOT, struct/map/list intrinsics, and related functions. The 2026-05-13 enabled-platform UAT run showed repeated unsupported-function and syntax failures across pg-duckdb, pg-mooncake, and TimescaleDB. | `benchmark_gate.timescaledb.read_primitives.duckdb_intrinsics` | | BLOCKED | benchmark=vector_search | benchmark_gate | Vector search requires a VECTOR column type and vector-distance operators. The 2026-05-13 enabled-platform UAT run failed during schema creation with `type "vector" does not exist` on pg-duckdb, pg-mooncake, and TimescaleDB. | `benchmark_gate.timescaledb.vector_search.no_vector_type` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=10_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.timescaledb.tpchavoc.10_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=10_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.timescaledb.tpchavoc.10_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=11_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `value` in WHERE. | `execution_filter.timescaledb.tpchavoc.11_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=11_v9 | execution_filter | PostgreSQL-family engines reject this window expression in WHERE. | `execution_filter.timescaledb.tpchavoc.11_v9` | | SKIPPED_QUERY | benchmark=tpchavoc, query=13_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.timescaledb.tpchavoc.13_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=17_v2 | execution_filter | PostgreSQL-family engines report ambiguous `l_partkey` resolution for this variant shape. | `execution_filter.timescaledb.tpchavoc.17_v2` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=17_v4 | execution_filter | PostgreSQL-family engines do not provide Oracle's `dual` table. | `execution_filter.timescaledb.tpchavoc.17_v4` | | SKIPPED_QUERY | benchmark=tpchavoc, query=1_v7 | execution_filter | PostgreSQL-family engines do not provide DuckDB's `LIST()` aggregate used by this variant. | `execution_filter.timescaledb.tpchavoc.1_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=2_v5 | execution_filter | PostgreSQL-family engines report ambiguous `ps_partkey` resolution for this variant shape. | `execution_filter.timescaledb.tpchavoc.2_v5` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=3_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.timescaledb.tpchavoc.3_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=4_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `order_count` in HAVING. | `execution_filter.timescaledb.tpchavoc.4_v7` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=5_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.timescaledb.tpchavoc.5_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=5_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.timescaledb.tpchavoc.5_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=7_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `revenue` in HAVING. | `execution_filter.timescaledb.tpchavoc.7_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=7_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.timescaledb.tpchavoc.7_v9` | -| SKIPPED_QUERY | benchmark=tpchavoc, query=9_v7 | execution_filter | PostgreSQL-family engines do not allow the select-list alias `sum_profit` in HAVING. | `execution_filter.timescaledb.tpchavoc.9_v7` | | SKIPPED_QUERY | benchmark=tpchavoc, query=9_v9 | execution_filter | PostgreSQL-family engines reject aggregate expressions inside window definitions. | `execution_filter.timescaledb.tpchavoc.9_v9` | | SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_repeatable_read | execution_filter | TimescaleDB rejects non-default transaction isolation changes once the benchmark operation wrapper has already executed statements on the connection. | `execution_filter.timescaledb.transaction_primitives.transaction_isolation_repeatable_read` | | SKIPPED_QUERY | benchmark=transaction_primitives, query=transaction_isolation_serializable | execution_filter | TimescaleDB rejects non-default transaction isolation changes once the benchmark operation wrapper has already executed statements on the connection. | `execution_filter.timescaledb.transaction_primitives.transaction_isolation_serializable` | diff --git a/docs/conf.py b/docs/conf.py index c44fe7df1b..734c00737b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -114,7 +114,6 @@ # We intentionally document classes in multiple places for different contexts (api.rst + detailed refs) suppress_warnings = [ "autosummary", # Suppress autosummary warnings - "ref.doc", # Suppress unknown document references "ref.myst", # Suppress myst cross-reference warnings (internal anchors) "myst.xref_missing", # Suppress missing myst cross-references "toc.not_readable", # Suppress nonexisting document warnings (handled above) diff --git a/docs/design/architecture.md b/docs/design/architecture.md index f542ae5d70..c186b9d9b7 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -5,144 +5,189 @@ ```{tags} contributor, concept ``` -## Overview +## Read this first: the contract map + +[Public Contracts and Support Taxonomy](../reference/public-contracts.md) is +the authority on what BenchBox promises. It classifies every surface by +compatibility tier (`stable-public`, `beta-public`, `internal`, +`experimental`, `deprecated`, `generated`, `repo-only`) and names the source +of truth for each one. This document describes how the code delivers those +surfaces; when the two disagree, the contract map wins, and changing a +mapped surface requires updating the map in the same PR. + +Two classifications do most of the work: + +- **Compatibility tier** answers "can I depend on this?" for APIs, CLIs, + generated stores, and docs. +- **`support_status`** answers "is this benchmark or platform a supported + product?" (`stable`, `beta`, `experimental`, `repo_only`, `deprecated`, + `document_only`). For benchmarks it controls the label shown next to a + public entry; it never hides one. Hiding is the separate `surface` gate + (`public` vs `internal`), and capability flags such as `supports_dataframe` + are never inferred from either. (Platform discovery helpers additionally + filter `deprecated` and `document_only` platforms unless the caller opts + in; see Platform discovery below.) + +## Product surfaces and their code homes + +| Surface | Tier | Code | +|---|---|---| +| CLI commands and documented options | `beta-public` | `benchbox/cli/commands/`, `docs/reference/cli/` | +| Python wrapper facades (`benchbox.TPCH(...)`) and `BaseBenchmark.run_with_platform` | `beta-public` | `benchbox/__init__.py`, top-level wrapper modules, `benchbox/base.py` | +| Runtime loader and registries behind the facades | `internal` | `benchbox/core/benchmark_loader.py`, `benchbox/core/benchmark_registry.py` | +| Shared run engine below CLI and MCP | `internal` | `benchbox/core/run_service.py` (the `__all__` list is the cross-surface import contract) | +| MCP tools | `beta-public`, deliberately scoped | `benchbox/mcp/`; every omitted CLI control carries a tier in the `docs/reference/mcp.md` omission ledger | +| Result JSON bundles (schema-versioned product data) | `beta-public` | `benchbox/core/results/schema_policy.py`, `benchbox/core/results/schema.py` | +| Explorer browser store (DuckDB + summaries, built from bundles) | `generated` | `_project/scripts/explorer_pipeline/`; reproducible from source bundles plus pipeline code | +| Public result submissions and their validation behavior | `beta-public` | `scripts/validate_submission.py`, `docs/reference/hosted-results-contract.md`, `docs/contributing-results.md` | +| Semantic chart IDs shared by CLI, MCP, templates, and Explorer | `beta-public` | `benchbox/core/visualization/chart_types.py`, `results-explorer/src/lib/chartRegistry.ts`, `tests/parity/fixtures/chart_ids.json` | +| `benchbox.experimental` namespace | `experimental` | Ships in the wheel for convenience, outside the supported product surface | +| `_project/` scripts, audits, TODOs, ADRs | `repo-only` | Contributor tooling, not a user API | + +There are currently 22 benchmarks across TPC standards, academic, industry, +real-world, time-series, primitives, AI/ML, and experimental categories +(registry-derived; `test_public_benchmark_count_claims_are_registry_derived` +pins this claim to `list_public_benchmark_ids()`). + +## Execution architecture + +A run flows through one shared engine, no matter which surface starts it: -BenchBox is a modular SQL and DataFrame benchmarking framework for OLAP databases. The architecture separates concerns into four layers: - -1. **Benchmarks** - dataset definitions, schemas, queries, and data generation -2. **Platforms** - database adapters for SQL and DataFrame execution -3. **Core** - shared infrastructure (runner, results, validation, visualization, etc.) -4. **CLI** - user-facing commands and execution orchestration - -## Execution Model - -BenchBox uses a **lifecycle-based** execution model (not Template Method). A benchmark run progresses through phases orchestrated by `run_benchmark_lifecycle()` in `benchbox.core.runner.runner`: - -``` -generate → load → warmup → power → throughput → maintenance ``` - -### Key Types - -| Type | Location | Purpose | -|------|----------|---------| -| `LifecyclePhases` | `core.runner.runner` | Controls which phases to run | -| `BenchmarkResults` | `core.results.models` | Complete run output with phase results | -| `BenchmarkConfig` | `core.schemas` | Benchmark name, scale, query selection (Pydantic) | -| `DatabaseConfig` | `core.schemas` | Connection and platform configuration (Pydantic) | -| `PlatformAdapter` | `platforms.base.adapter` | Abstract base for all SQL platform adapters | - -### SQL Execution Path - -``` -CLI (run command) - → BenchmarkOrchestrator (cli/orchestrator.py) - → run_benchmark_lifecycle() (core/runner/runner.py) - → PlatformAdapter.execute_query() for each query - → BenchmarkResults -``` - -The `PlatformAdapter` base class (`benchbox/platforms/base/adapter.py`) provides the interface that all 33 SQL platform adapters implement. Each adapter handles connection management, DDL generation, data loading, and query execution for its target database. - -### DataFrame Execution Path - -DataFrame benchmarks use the same lifecycle orchestrator entry point as SQL, -then branch to the DataFrame adapter mixin: - -``` -CLI (run command with --platform *-df) - → run_benchmark_lifecycle() (core/runner/runner.py) - → adapter.run_benchmark() - → BenchmarkExecutionMixin.run_benchmark() (platforms/dataframe/benchmark_mixin.py) - → ExpressionFamilyAdapter or PandasFamilyAdapter +CLI (`benchbox run`) or MCP (`run_benchmark`) + → benchbox.core.run_service (shared engine, internal) + → run_benchmark_lifecycle() (benchbox/core/runner/runner.py) + → generate → load → execute (power/throughput test types) + → PlatformAdapter hooks per phase → BenchmarkResults ``` -| Type | Location | Purpose | -|------|----------|---------| -| `DataFrameContext` | `core.dataframe.context` | Protocol for table access and column references | -| `BenchmarkExecutionMixin` | `platforms.dataframe.benchmark_mixin` | Production DataFrame lifecycle implementation behind `adapter.run_benchmark()` | -| `ExpressionFamilyAdapter` | `platforms.dataframe.expression_family` | Base for Polars, PySpark, DataFusion, LakeSail | -| `PandasFamilyAdapter` | `platforms.dataframe.pandas_family` | Base for Pandas, cuDF, Dask | - -The **family-based** adapter architecture means adding a new expression-style platform (e.g., Polars-like API) requires only implementing a thin adapter on top of `ExpressionFamilyAdapter`, inheriting query translation, tuning, and execution logic. +Execution phases are declared in `LifecyclePhases` (`generate`, `load`, +`execute`, plus opt-in `statistics`), not inherited. Power and throughput +are test-type executions inside the execute phase, not lifecycle phases. -The deprecated internal compatibility runner `core/runner/dataframe_runner.py` -has been deleted; the adapter mixin path (`platforms/dataframe/benchmark_mixin.py`) -is the only production DataFrame lifecycle path. +`BaseBenchmark` (`benchbox/base.py`) remains the public wrapper base and the +benchmark-facing API boundary; it does not own the runtime workflow. Adapter +instances are serial execution objects: one instance may serve sequential +runs, concurrent calls on one instance are not supported, and +`run_benchmark()` resets run-scoped caches at run start. -## Benchmark Layer +### SQL path -Each benchmark (TPC-H, TPC-DS, SSB, ClickBench, etc.) lives under `benchbox/core//` and provides: +SQL adapters implement `PlatformAdapter` (`benchbox/platforms/base/adapter.py`): +connection lifecycle, platform DDL, bulk load, and per-query execution. +Queries are defined once per benchmark and translated per dialect with +sqlglot; heavy SDK imports stay lazy until the platform is actually used. -- **Schema** - table definitions and DDL generation via `get_create_tables_sql(dialect, tuning_config)` -- **Queries** - SQL templates with dialect translation via sqlglot -- **Data generation** - using official TPC tools (dbgen/dsdgen) or built-in generators -- **Validation** - expected result counts and answer verification +### DataFrame path -All benchmarks inherit from `BaseBenchmark` (`benchbox/base.py`). Benchmarks are registered in `benchbox/core/benchmark_registry.py` which maps CLI names (e.g., `tpch`) to class names and metadata. +DataFrame runs enter the same lifecycle, then branch to +`BenchmarkExecutionMixin.run_benchmark()` +(`benchbox/platforms/dataframe/benchmark_mixin.py`), which is the only +production DataFrame lifecycle path. Adapters group by API family +(`ExpressionFamilyAdapter` for Polars/PySpark/DataFusion-style APIs, +`PandasFamilyAdapter` for Pandas/cuDF/Dask-style APIs), so a new +expression-style platform inherits translation, tuning, and execution. -There are currently 22 benchmarks across TPC standards, academic, industry, real-world, time-series, primitives, AI/ML, and experimental categories. +## Data architecture: bundles are the product -## Platform Layer +The schema-versioned result bundle is the unit everything else consumes: +CLI output, submission validation, hosted results, the explorer, and +SQL/DataFrame comparisons all read the same shape, guarded by the schema +policy. SQL and DataFrame bundles for one benchmark must preserve the +cross-mode parity invariants. -### SQL Platforms (46 adapters) - -All SQL adapters inherit from `PlatformAdapter` and implement: -- `get_connection_from_pool()` / `close_connection()` - connection lifecycle -- `execute_query()` - query execution with timing -- `get_create_tables_sql()` - platform-specific DDL -- `load_data()` - bulk data loading - -Platforms span local engines (DuckDB, SQLite, DataFusion), cloud warehouses (Snowflake, BigQuery, Databricks, Redshift), and specialized systems (ClickHouse, StarRocks, QuestDB, TimescaleDB, etc.). - -### DataFrame Platforms (8 adapters) - -DataFrame adapters are organized by API family: -- **Expression family**: Polars, PySpark, DataFusion, LakeSail -- **Pandas family**: Pandas, cuDF, Dask - -## Core Infrastructure - -The `benchbox/core/` directory contains 39 subsystems: - -| Subsystem | Purpose | -|-----------|---------| -| `runner/` | Benchmark lifecycle orchestration | -| `results/` | Result models, serialization, aggregation | -| `validation/` | Answer validation, data verification | -| `visualization/` | ASCII chart generation and result rendering | -| `dataframe/` | DataFrame execution context, profiling, tuning | -| `query_plans/` | Query plan capture and analysis | -| `tuning/` | Unified tuning configuration system | -| `data_organization/` | Sorted ingestion, clustering strategies | -| `comparison/` | Result comparison and regression detection | -| `cost/` | Cloud cost estimation | -| `analysis/` | Statistical analysis of benchmark results | -| `contracts/` | Interface contracts and type validation | - -## CLI Layer - -The CLI (`benchbox/cli/`) uses Click and provides 25+ commands. The main `run` command orchestrates the full benchmark lifecycle through `BenchmarkOrchestrator` (`cli/orchestrator.py`) → `run_benchmark_lifecycle()`. - -Key command groups: `run`, `compare`, `visualize`, `report`, `metrics`, `tuning`, `platforms`, `shell`, `datagen`, `setup`. - -## Visualization - -BenchBox provides ASCII chart visualization via `core/visualization/`: -- **ASCII charts** (`core/visualization/ascii/`) - 15+ chart types rendered as terminal text (bar, box plot, heatmap, histogram, scatter, CDF, sparkline, etc.) -- **ResultPlotter** (`core/visualization/result_plotter.py`) - normalizes JSON results and orchestrates chart rendering -- **Templates** (`core/visualization/templates.py`) - named chart combinations (e.g., `flagship`, `comparison`, `executive_summary`) - -## MCP Integration - -The `benchbox/mcp/` package exposes BenchBox functionality as MCP (Model Context Protocol) tools, enabling AI assistants to discover, run, and analyze benchmarks. - -## Key Design Decisions +``` +run → BenchmarkResults → bundle JSON (beta-public data) + → validate-submission (deterministic errors, privacy/trust handling) + → published corpus (results-data/, trust labels + provenance) + → explorer pipeline (generated DuckDB, summaries, matrix artifacts) + → Results Explorer (browser) over URL-backed views +``` -1. **Lifecycle over Template Method** - execution phases are declared in configuration, not inherited -2. **Family-based DataFrame adapters** - minimize code duplication across similar platforms -3. **Official TPC tools** - use dbgen/dsdgen for specification-compliant data generation -4. **sqlglot translation** - single query definition with automatic dialect translation -5. **Lazy platform loading** - heavy SDK imports deferred until platform is actually used -6. **ASCII visualization** - terminal-rendered charts for CI/CD and MCP integration +The explorer's secondary navigation and benchmark browser groupings are +presentation over this pipeline, not new data sources: chart selection, +facets, filters, and anchors already live in explorer URL state, so those +views are shareable as URLs. + +### Tuning evidence is honest by construction + +Tuning claims follow fail-closed rules end to end. The applied-tuning ledger +records only statements that actually executed, using a shared vocabulary +(`PHASE_SESSION` and friends); benchmarking hygiene applied to every run is +never recorded as tuning, and a run with no tuning-derived statements +reports `noop`, never a false `applied`. A platform with no +tuning-derived session surface (Snowpark today) declares that explicitly +rather than inheriting another platform's capture story. Post-load +introspection corroborates the ledger before any `applied_verified` state +is earned, and the explorer renders the recorded verdicts read-only. + +## Discovery architecture: gate, label, capability + +Three independent registry fields govern how a benchmark appears; +conflating them is the most common extension-point bug: + +- `surface` is the **only benchmark discovery gate**. `internal` + benchmarks stay out of CLI listings, MCP listings, and resources, but + remain runnable by explicit ID. Platform entries have no `surface` + field. +- `support_status` is the **product-support label** (`Stable`, `Beta`, + `Experimental`, …). Public benchmarks are listed with their label; the + explorer benchmark browser groups on it. +- Capability flags (`supports_dataframe`, platform capabilities, dependency + hints) drive **routing**, never visibility. + +Platform discovery instead filters on `support_status`: the public +`PlatformRegistry.get_sql_platforms()` / `get_dataframe_platforms()` helpers +hide `deprecated` and `document_only` entries unless the caller passes +`include_deprecated=True`. (The `benchbox platforms list` CLI annotates every +registered platform with its status rather than hiding.) + +Sources of truth: `benchbox/core/benchmark_registry.py` (benchmarks; the +per-benchmark rationale and promotion criteria live in +`docs/benchmarks/support-status.md` and are drift-checked against the +registry) and `benchbox/core/platform_registry.py` (platforms, reading +adapter import specs and aliases from `benchbox/core/platform_manifest.py`). + +## Extension points and their contract obligations + +Mechanics for adding benchmarks, platforms, and query variants are covered +in [Custom Benchmarks](../advanced/custom-benchmarks.md) and [Adding New +Platforms](../development/adding-new-platforms.md). Each addition also +carries contract obligations from the map: + +- Every registry benchmark and platform entry declares exactly one + `support_status`; benchmark entries additionally satisfy the + drift-checked criteria in `docs/benchmarks/support-status.md`. +- A new platform manifest entry wires discovery, capabilities, dependency + hints, and docs together; aliases need a compatibility note. +- A new semantic chart ID lands in `benchbox/core/visualization/chart_types.py` first, then follows to + templates, ASCII runtime, Explorer registry, and parity fixtures in the + same PR; IDs are deprecated, never silently removed. +- A new `CREATE TABLE` rewrite path in an adapter must satisfy the SQL + compatibility governance in `benchbox/sql_compat/` (`make + compat-docs-check`). +- Behavior changes to any mapped surface update the contract map (or state + why it is unchanged) and land docs/tests in the same PR; removals go + through `docs/reference/backward-compatibility.md`. + +## Repository map + +| Path | Purpose | Tier | +|---|---|---| +| `benchbox/` | Installable product: benchmarks, platforms, CLI, MCP, results, visualization | `beta-public` surface over `internal` engine | +| `results-explorer/` | Browser application for published results | Product UI over a `generated` store | +| `results-data/` | Public result corpus and validation metadata | Published data | +| `docs/` | User, concept, reference, and contributor documentation | Varies by page; the map governs | +| `tests/` | Unit, integration, end-to-end, parity, and live suites | Verification gates per map row | +| `examples/` | Runnable examples, notebooks, tuning files | Illustrative, not contractual | +| `_project/` | Operations, audits, project tooling, TODO state | `repo-only` | + +## Related documentation + +- [Public Contracts and Support Taxonomy](../reference/public-contracts.md) — the map itself +- [Benchmark Support Status Criteria](../benchmarks/support-status.md) — per-benchmark rationale +- [Backward Compatibility](../reference/backward-compatibility.md) — deprecation and removal paths +- [MCP Reference](../reference/mcp.md) — scoped surface and omission ledger +- [One-engine scoped surfaces ADR](../development/adr/adr-one-engine-scoped-surfaces.md) — why CLI and MCP share `run_service` +- [Concepts: Architecture](../concepts/architecture.md) — user-facing component tour with examples diff --git a/docs/design/future-state/index.md b/docs/design/future-state/index.md index 668bf017c0..4f7a54ce65 100644 --- a/docs/design/future-state/index.md +++ b/docs/design/future-state/index.md @@ -18,7 +18,7 @@ treated as actionable. The support-tier freeze is evidence is `_project/decisions/future-state-extraction-evidence-2026-08-13.md`. The latest bounded CI-burden measurement, collected 2026-08-31, is recorded in -[`_project/analysis/ci-waste-remeasure-2026-08-31.md`](../../../_project/analysis/ci-waste-remeasure-2026-08-31.md); +[`_project/analysis/ci-waste-remeasure-2026-08-31.md`](https://github.com/BenchBox-dev/BenchBox/blob/develop/_project/analysis/ci-waste-remeasure-2026-08-31.md); it does not authorize extraction and the candidates below remain blocked on their stated evidence gates. diff --git a/docs/development/adding-new-platforms.md b/docs/development/adding-new-platforms.md index 8bedcf9511..198379fb60 100644 --- a/docs/development/adding-new-platforms.md +++ b/docs/development/adding-new-platforms.md @@ -68,6 +68,7 @@ The following inventory is generated from the manifest: | `athena-spark` | — | SQL + DataFrame | `experimental` | #35 `benchbox.platforms.aws:AthenaSparkAdapter` | | `bigquery` | `bq` (cli), `gbq` (cli) | SQL | `beta` | #12 `benchbox.platforms.bigquery:BigQueryAdapter` | | `cedardb` | — | SQL | `experimental` | #23 `benchbox.platforms.cedardb:CedarDBAdapter` | +| `citus` | — | SQL | `experimental` | #48 `benchbox.platforms.citus:CitusAdapter` | | `clickhouse` | — | SQL | `deprecated` | #6 `benchbox.platforms.clickhouse:ClickHouseAdapter` | | `clickhouse-cloud` | — | SQL | `beta` | #9 `benchbox.platforms.clickhouse_cloud:ClickHouseCloudAdapter` | | `clickhouse-local` | `ch` (cli) | SQL | `beta` | #7 `benchbox.platforms.clickhouse_local:ClickHouseLocalAdapter` | @@ -93,6 +94,7 @@ The following inventory is generated from the manifest: | `lakesail` | `lakesail-df` (cli, mode=dataframe) | SQL + DataFrame | `experimental` | #42 `benchbox.platforms.lakesail:LakeSailAdapter` | | `motherduck` | — | SQL | `beta` | #1 `benchbox.platforms.motherduck:MotherDuckAdapter` | | `pandas` | `pandas-df` (cli, mode=dataframe) | DataFrame | `stable` | DataFrame factory only | +| `paradedb` | — | SQL | `experimental` | #47 `benchbox.platforms.paradedb:ParadeDBAdapter` | | `pg-duckdb` | `pg_duckdb` (registry) | SQL | `experimental` | #20 `benchbox.platforms.pg_duckdb:PgDuckDBAdapter` | | `pg-mooncake` | — | SQL | `experimental` | #21 `benchbox.platforms.pg_mooncake:PgMooncakeAdapter` | | `polars` | `polars-df` (cli, mode=dataframe) | DataFrame | `stable` | #44 `benchbox.platforms.polars_platform:PolarsAdapter` | diff --git a/docs/development/adr/README.md b/docs/development/adr/README.md index 19ed33757b..aee1d5203b 100644 --- a/docs/development/adr/README.md +++ b/docs/development/adr/README.md @@ -14,9 +14,13 @@ Architecture Decision Records - [ADR: `public_result_id` permanence attaches at publication](adr-public-result-id-permanence.md) - [ADR: `clickhouse-server` Containerization and Linux SF1 Certification](adr-clickhouse-server-containerization.md) - [ADR: Corpus Public-Mirror Validation-Status Admission Policy](adr-corpus-validation-admission-policy.md) +- [ADR: Require complete TPC-Havoc variants for public submissions](adr-tpchavoc-submission-variant-coverage.md) - [ADR: Client-to-Platform Locality Disclosure and Statement Overhead Probe](adr-client-link-locality-disclosure.md) - [ADR: Scan billing units — decimal terabytes for Athena/Synapse, tebibytes for BigQuery](adr-billing-unit-tb-tib-contract.md) - [ADR: Concurrency Utility APIs — Remove the Quarantined Executors](adr-concurrency-public-api-reconciliation.md) - [ADR: Cross-benchmark power and throughput scores](adr-cross-benchmark-power-throughput-scores.md) - [ADR: Concurrency Outcome and Resource-Ownership Contract](adr-concurrency-outcome-and-ownership-contract.md) - [ADR: TPC-DI Enhanced Parallel Surface — Remove the Second Scheduler](adr-tpcdi-enhanced-parallel-support-decision.md) +- [ADR: Prove FlightData Source Months Before Reusing a Corpus](adr-flightdata-month-source-policy.md) +- [ADR: Preserve Read-Primitives Capabilities Across Cloud Dialects](adr-read-primitives-cloud-variants.md) +- [ADR: Development Loop Architecture (v2)](adr-dev-loop-v2.md) diff --git a/docs/development/adr/TEMPLATE-results-data-extraction.md b/docs/development/adr/TEMPLATE-results-data-extraction.md index ec5d387024..66007f1c23 100644 --- a/docs/development/adr/TEMPLATE-results-data-extraction.md +++ b/docs/development/adr/TEMPLATE-results-data-extraction.md @@ -5,7 +5,7 @@ > in when an extraction trigger fires (see > [`_project/analysis/results-data-extraction-trigger.md`][trigger]). -[trigger]: ../../../_project/analysis/results-data-extraction-trigger.md +[trigger]: https://github.com/BenchBox-dev/BenchBox/blob/develop/_project/analysis/results-data-extraction-trigger.md ## Status diff --git a/docs/development/adr/adr-dev-loop-v2.md b/docs/development/adr/adr-dev-loop-v2.md new file mode 100644 index 0000000000..7ef2aa15c7 --- /dev/null +++ b/docs/development/adr/adr-dev-loop-v2.md @@ -0,0 +1,309 @@ +# ADR: BenchBox Development Loop Architecture (v2) + +## Status + +Accepted (2026-09-28). + +Supersedes: + +- [`docs/operations/merge-queue-governance.md`](../../operations/merge-queue-governance.md) +- [`docs/development/pr-base-branch-policy.md`](../pr-base-branch-policy.md) +- [`docs/development/adr/adr-independent-publication-authorities.md`](adr-independent-publication-authorities.md) (per decision D2) +- Version-branch release flow in [`docs/operations/release-guide.md`](../../operations/release-guide.md) + +## Context + +BenchBox previously relied on an intricate set of 46 separate GitHub Actions workflows, +a long-lived protected `release` branch alongside `develop`, an automated post-merge +safety net with complex reconciliation, numerous PR-loop Make targets and scripts, and +a multi-workflow publication control plane. + +This design accumulated significant operational drag: + +- CI runs often took 40–60 minutes across duplicate workflow fanouts. +- PR-loop checks and post-merge jobs drifted from each other. +- The `release` branch required continuous cherry-picks, version-branch maintenance, + and canary runs that duplicated merge-queue work. +- The publication control plane relied on an external GitHub App and separate branch + transactions that could stall or disconnect from trunk state. + +This ADR establishes the architectural decisions, operational contracts, and migration +guardrails for a consolidated trunk-based development loop. Its original +pre-merge integration mechanism was the GitHub merge queue. + +Amended 2026-10-03: the merge queue and the strict up-to-date rule on `develop` are +retired, and a pull request merges by auto-merge once its required checks pass on its +own head. The queue's failures were mostly its own machinery: 38 of the 40 most recent +failed merge-group runs, with no confirmed cross-PR integration failure. A post-merge +workflow, `.github/workflows/trunk.yml`, tests pushes to `develop`, retains pending +runs with `concurrency.queue: max`, and produces verified release distributions. +A red trunk is reverted first. The sections below describe the replacement; +dated queue decisions remain historical. See +`_project/decisions/merge-queue-retirement-2026-10-03.md`. + +## Decisions + +### D1: Trunk branch remains `develop` + +The primary development branch remains `develop`. Renaming `develop` to `main` is +rejected (see Rejected Alternatives) to avoid disruption across downstream forks, +clones, documentation, tooling, and repository configurations. + +### D2: Replace the publication control plane + +The multi-workflow publication control plane is replaced with a single unified +`site-deploy` workflow for `benchbox.dev`. Entry criteria for the replacement: + +- Pinned source and corpus SHAs. +- Retained artifact identity across stages. +- Post-deploy route and digest health probes. +- Durable deployment receipts. +- Exact-artifact rollback support with last-known-good validation. +- One serialized production writer with generation checks. +- Route-manifest assembly (`/` and `/docs/` from latest release tag; `/docs/dev/`, + blog, and `/results/` from trunk). +- Rollback verified with `github-pages` environment reviewers enabled. + +Once `site-deploy` validates in shadow and cuts over, the legacy publication workflows, +publication transaction branch, and publication GitHub App are retired. + +### D3: Results corpus layout unchanged + +The results corpus homes remain unchanged: + +- `results-data/` on `develop` for active working runs and staged data. +- `published-results` as the slim corpus-only branch for public submissions. + +### D4: Soundness hold via connector review and thread resolution + +Soundness paths are classified by `.github/soundness-paths.txt`. The required +`oracle-review` check binds the Codex connector's review, or the stand-in approval, to +the current head, required thread resolution binds disposition of findings, and the +scheduled digest checks the merged evidence. Code-owner review and PR-body attestation +are retired. The soundness-path revocation is removed and the replacement check +is required in the live ruleset; explicit hold-label revocation remains. + +Amended 2026-10-02, updated 2026-10-03: the external review stays, but a PR-body +attestation checked by CI no longer binds it. The author writes its own attestation, so the check +can only test that the text is present; it never tests the review. The review is bound +by three controls: + +1. Review findings. The external reviewer's Critical and High findings are posted as PR + review threads, and the ruleset requires every thread to be resolved before a merge + (`required_review_thread_resolution`). GitHub lets an author with write access resolve + a thread, so resolution shows that someone dispositioned the finding, not that it was + fixed. The digest below is the check on that. +2. Review signal as a required check. For changes on the narrowed soundness path list, + the required `oracle-review` status check passes only when the Codex connector app has + reviewed the current head: its submitted review or thumbs-up reaction, or the stand-in + approval described below when the connector cannot review. An "eyes" + reaction alone means the review has started, not finished. A review that exists shows + that a reviewer looked, not what it examined. +3. Post-merge digest. A scheduled report lists the commits that reached `develop` on the + narrowed paths and records which review signal each had at merge. It also flags each + review thread that was resolved with no later commit. It opens a tracker item for + each commit with no signal or with such a thread. An agent then runs the external + review and either records a clean result or opens a fix or revert PR. + +The first two controls can be bypassed by an author acting alone with the owner's +rights, because every agent shares one account. They make a skipped review visible and +slower to skip; the digest detects a bypass after the merge and is the backstop. A +squash commit is one revert unit, but a revert on a busy trunk is not always clean, so +the backstop does not replace the review before the merge. + +The `oracle-review` check is a merge-blocking automated review signal. It supersedes item 3 of +`_project/decisions/codeowner-approving-count-zero-constraint-2026-09-15.md` for paths on +the soundness list only, and only that item. That decision kept such signals advisory +because a batch of failing Codex reviews exhausted the usage limit and deadlocked the +queue. The `codex` CLI, `muse` and `agy` remain available as optional extra depth, each +with its own quota; on their own they do not satisfy the check. + +When the connector cannot review, for example at its usage limit, a stand-in review +replaces it. An independent reviewer reviews the exact current head, and an account listed +in `STANDIN_ATTESTERS` (`_project/scripts/oracle_review_check.py`) then posts a PR comment +containing the line `Stand-in oracle review: APPROVE `. The check accepts +that comment only for the exact head, only when it was posted after the head commit and any +retarget and never edited, and never while a connector review thread is unresolved. A new +push needs a new stand-in comment. A plain owner review, thumbs-up or unmarked comment does +not count. The attester account is also the one local automation uses, so the marker +records who vouched for the review, not that a human read it. All four reviewers were +exhausted at once on 2026-10-02, which blocked every workflow change for three days under +the fail-closed rule alone. + +The soundness path list covers expected and reference results, digests, +result-validation and equivalence comparators, result capture, corpus overrides, +publication scripts, every workflow, and the arming helper. Release and binary +protections remain. Documentation, ordinary decision records, runbooks, threat +models, and `AGENTS.md` leave the list. The `single-repo-migration.md` decision +remains protected because release curation parses it as configuration. The manifest-based +`oracle-review` check replaces the former `tooling` attestation flag. The external +review requirement is unchanged except for the stand-in review above. +A new push or refresh changes the head and needs fresh CI and a fresh connector review +or stand-in approval before re-arming. No PR-body attestation satisfies or is required by the check. + +### D5: Retain agent write tooling, retire PR-loop scripts + +Agent workspace safety tooling is retained: + +- Worktree creation and lifecycle management (`make worktree-create`, `worktree-remove`). +- Git identity pinning and author verification (`agent-write-preflight`, `agent-git-identity`). +- Attribution trailer verification (`check_agent_trailers`). +- Issue tracker state branch operations (`todo-state`). + +Legacy PR-loop mechanics may retire only with their callers and safety contracts +accounted for. `pr_landing.py` still supplies the exact-head revision/readiness +transaction, `local_validation.py` supplies shared test-lock coordination, and the +soundness-drain report still diagnoses reviewed but unarmed PRs. These retained +contracts are not removed by queue retirement. + +### D6: Codecov retained as informational + +Codecov code coverage tracking is retained. The fast-test job in `ci.yml` produces +the coverage report and uploads it to Codecov. Upload failures remain informational; +the required test command's coverage threshold still applies. + +### D7: Ruleset drift detection retained + +Ruleset drift verification (`ruleset_drift_check.py`) is retained. It executes as a +advisory check in `nightly.yml`, by hand after a settings change, and as enforced +release-canary/bootstrap validation. It no longer runs inside the required +`tooling` unit. The `RULESET_DRIFT_TOKEN` secret is maintained; required thread +resolution and tag-creation protection remain checked. + +### Tag signatures and release verification + +Explicit cryptographic tag signatures are dropped. Release integrity relies on: + +1. The `v-tag-restricted` ruleset preventing unauthorized tag creation or mutation. +2. Verification that the tagged commit exists on `develop` and its latest exact-commit + `trunk.yml` push run on that branch succeeded, without falling back to older success. +3. Admission of the exact wheel and sdist from that run's successful `dist-artifact` + job, with matching producer-attempt evidence and SHA-256 hashes for release upload. +4. GitHub Actions build provenance attestations. + +## Standing Administrative Approvals + +The maintainer has granted standing approvals for the following operational changes +once corresponding preconditions and backups (per Guardrail G5) are satisfied: + +1. **Rulesets:** + - Add new required checks (`core`, `explorer`, `results-data`, `docs`, `landing`, `tooling`). + - Remove legacy checks after the transition window. + - Historical queue-parameter adjustments are retired with the queue. The dated + retirement decision records the specific queue, strict-rule and review changes. + - Delete the obsolete `release-only` ruleset. + +2. **Branches:** + - Delete `release` and `publication` branches after creating immutable archive tags. + - Delete stale `gh-readonly-queue/*` branches with no active queue entry. + - Delete merged `auto-revert/*` branches and merged feature/fix branches. + +3. **Labels and environments:** + - Delete legacy incident labels (`incident:develop-red`, `incident:develop-red-revert-conflict`, + `incident:release-canary-red`) and `no-auto-merge`. + - Create `quarantine` and `t3:*` labels. + - Delete the `publication-attestation` environment after site deployment cutover. + +4. **Secrets and GitHub Apps:** + - Delete `PUBLICATION_APP_ID` and `PUBLICATION_PRIVATE_KEY`, and uninstall the + publication GitHub App after `site-deploy` cutover. + - Delete unused legacy tracker secrets if no repository references exist. + +5. **Self-service merge:** + - Autonomous merge of modernization PRs targeting `develop` once all required checks, + self-review, and required soundness reviews pass. + +6. **Site deployment approval:** + - Approve pending `github-pages` deployments via GitHub API once pre-deployment + verification passes. + +7. **Release flow rehearsal:** + - Execute TestPyPI publication rehearsals autonomously. + - Note: Real PyPI production publication remains strictly owner-initiated. + +8. **Live cloud benchmarks (`t3:cloud`):** + - Wired behind configuration defaulting to disabled; live cloud runs remain off. + +9. **Medium-tier test budget:** + - If T2 exceeds the 15-minute target, standard runner sharding may increase up to 10. + If still exceeding 15 minutes, accept up to 20 minutes with durable rationale. + Paid larger runners are not authorized. + +10. **Waiting windows:** + - Active observation windows (5 business days shadow and 7 nightly runs) + are non-blocking for independent tasks. Queue-run windows are historical. + +## Guardrails + +- **G1 Replacement before removal:** A required check is removed only after its replacement + has passed a canary set of throwaway pull requests (one change per unit, one cross-unit + change, one soundness-path change, and one deliberately broken change per unit) AND the + property ledger (G3) maps every protected property to the replacement. Three real + merges must then pass under the new checks before the flip is considered complete. + With the queue retired, merged-tree evidence comes from exact-commit trunk runs; + this amendment does not assert that the canary or observation evidence is complete. + (Amended 2026-09-29: the earlier 20-run window assumed the old and new workflows could + run side by side; the merge groups already saturated the organization's runners, so + side-by-side operation is not possible and the cutover is a single flip.) +- **G2 Shadow parity:** Superseded by the canary set in G1 for the pull-request workflow. + Post-merge evidence names exact commits. Scheduled workflows (nightly) still validate with dispatched runs + before their predecessors retire. +- **G3 Property ledger (`docs/development/dev-loop-property-ledger.md`):** Every safety + property -> current guard -> new guard -> proof. Properties include: correctness oracle + (digest arming, query discrimination, no-skip), SQL self-binding lint, monotonic-clock + policy, submission validation, corpus trust boundary, artifact privacy, publication + rollback, explorer snapshot compatibility, public-site visual acceptance, binary integrity, + wheel installability, dependency bounds, release curation, ruleset/settings drift, + soundness-path connector review, workflow context validity and exact-commit trunk + coverage. The retired combined-tree queue property maps to PR checks and post-merge + validation, which detect composition failures after merge. A test or workflow + is deleted only if the ledger shows coverage or classifies it pure-process. +- **G4 Reference sweep:** Each deletion PR proves zero references to deleted names in + workflows, Makefile, make/, scripts/, _project/scripts/, tests/, docs/, AGENTS.md, + CONTRIBUTING.md, .claude/, .pre-commit-config.yaml, and the skill-sync source, excluding + CHANGELOG and archives. +- **G5 Backups first:** Export ruleset JSON, environments, labels, branch list, secret names, + and Pages settings to `_project/archive/dev-loop-v2/` before any GitHub-side change; tag + branches `archive/-` before deletion. +- **G6 One cluster per PR:** No PR mixes GitHub settings with code deletion; each PR is + independently revertible. +- **G7 Release freeze:** Active from the start of required-check transition until the first + new-path PyPI release; the old release path (release branch, test.yml, validate-release-pr, + release-canary, drift scripts, release-cut targets) stays fully working until then. +- **G8 Secrets and Apps:** Removed only after no workflow, script, or doc references them + and one full T3 cycle passed; PAT revoked at issuer; each removal is a separate owner-approved + step. +- **G9 Admin changes:** Admin changes covered by the standing approvals are made by the + agent after G5 backups; anything outside them stops. +- **G10 Instructions move in lockstep:** Any PR that removes or renames a Make target, + script, or workflow used by AGENTS.md, CONTRIBUTING.md, .claude/commands, or skills updates + those (skill-sync source, then re-sync) in the same PR. +- **G11 Settings-doc first:** Before any ruleset change, merge the PR that updates the drift + checker's expected settings so the live change does not fail `ruleset-drift` closed and + block the protected workflow. For the 2026-10-03 queue, strict-rule and soundness-review + retirement only, the owner approved implementation/settings changes before the docs + and drift expectations; see the dated retirement decision. Backup, replacement, + review and other administrative approval requirements remain. + +## Rejected Alternatives + +1. **Renaming default branch from `develop` to `main`:** + Rejected because renaming causes wide disruption across forks, clones, CI scripts, + git submodules, issue tracker references, and external links without delivering + engineering value. + +2. **Per-module test selection in PR builds:** + Rejected because fine-grained dynamic test selection introduces blind spots where + indirect interface dependencies fail to trigger tests. Coarse-grained unit classification + with always-reporting checks provides deterministic coverage. + +3. **Live cloud platform testing in PR or merge queue:** + Rejected due to network flakiness, credential exposure risks, high API costs, and + long runtimes that would violate the merge queue's 30-minute target. Live cloud testing + is isolated to scheduled nightly T3 execution. + +4. **CODEOWNERS-only hold for soundness paths:** + Rejected because the repository's required approval count is 0 and automated agents + acting with owner permissions would bypass the intended hold (observed empirically in + PR #2416). Explicit CI path detection and external adversarial review ensure rigorous + soundness verification. diff --git a/docs/development/adr/adr-flightdata-month-source-policy.md b/docs/development/adr/adr-flightdata-month-source-policy.md new file mode 100644 index 0000000000..31b3d67d42 --- /dev/null +++ b/docs/development/adr/adr-flightdata-month-source-policy.md @@ -0,0 +1,34 @@ +# ADR: Prove FlightData source months before reusing a corpus + +- Status: Accepted +- Date: 2026-09-24 +- Constrains: FlightData generation, manifest reuse, and result provenance. + +## Context + +FlightData uses monthly BTS archives. A failed download at scale factors of +0.1 or greater previously generated synthetic rows silently. A corpus could +therefore contain both remote and synthetic months, while its manifest and +result bundle did not identify the substituted months. The runner could also +reuse the corpus directly from its manifest without calling the downloader. + +## Decision + +At scale factors of 0.1 or greater, a monthly download failure stops +generation by default. Synthetic fallback requires the explicit +`allow_synthetic_fallback` benchmark option. Smaller scale factors retain +their documented synthetic generation behavior. + +The manifest records the exact downloaded and synthetic month lists and their +counts. The source contract includes the fallback policy, so switching that +policy changes the corpus identity. Both the runner and direct downloader +reject cached data unless the manifest partitions the expected window into +those two complete, disjoint lists. A result bundle includes the source +provenance only when the run established its link to a verified manifest. + +## Consequences + +Older FlightData caches lack complete month evidence and require regeneration. +Real-only runs stop when a required BTS archive is unavailable; the scale +factor is not silently redefined and synthetic rows are not mislabeled as +remote data. Explicit fallback runs remain identifiable as mixed or synthetic. diff --git a/docs/development/adr/adr-published-results-slim-corpus-branch.md b/docs/development/adr/adr-published-results-slim-corpus-branch.md index 4de9418ccc..e42fa9c86f 100644 --- a/docs/development/adr/adr-published-results-slim-corpus-branch.md +++ b/docs/development/adr/adr-published-results-slim-corpus-branch.md @@ -91,6 +91,8 @@ second corpus authority. | `scripts/validate_submission.py` | Per-bundle validator entrypoint — a thin CLI wrapper that must run inside the submission CI without shipping the rest of `benchbox/`. Imports the shared implementation from `benchbox/validation/bundle.py` (with an `importlib` file-loader fallback). | | `benchbox/validation/bundle.py` | The shared validator implementation used by both develop and this branch. Mirrored here so `validate_submission.py` runs without installing BenchBox; kept in sync by `sync-results-data-to-published.yml`. | | `benchbox/core/results/query_status.py` | Stdlib-only failed-query policy shared by publication, ranking, and the slim validator so both branches judge measurement failures identically. | +| `benchbox/core/results/schema_policy.py` | Stdlib-only schema-version policy required by the shared bundle validator. | +| `scripts/publication/validator_parity.py` | Trusted validator entry point comparing the PR-head and merge payload outcomes. Its runtime consists only of the allowlisted shared validator files. | | `scripts/generate_corpus_inventory.py` | Inventory generator — same rationale as above. | | `.github/workflows/validate-submission.yml` | The submission CI gate. | | `.gitignore` | Repository-root ignore — kept minimal. | @@ -104,7 +106,7 @@ in the slim-down. ### Explicit exclusions (deleted in the slim-down) -- `benchbox/` (the package source) — **except** the two mirrored validation +- `benchbox/` (the package source) — **except** the three mirrored validation files listed above. The rest of the package is not needed to validate or display bundles; `validate_submission.py` is a thin wrapper over that one shared module. @@ -126,20 +128,15 @@ in the slim-down. ### Validator invocation contract -`generate_corpus_inventory.py` is stdlib-only Python. `validate_submission.py` -is a thin wrapper that imports the shared implementation from -`benchbox/validation/bundle.py` (mirrored onto this branch), with an -`importlib` file-loader fallback if `benchbox` is not importable; that shared -module's own dependencies are stdlib-only (`hashlib`, `json`, `sys`, -`argparse`, `pathlib`, `decimal`, `collections`, `datetime`), but it also -*tries* one `benchbox.*` import — `benchbox.core.results.schema_policy` — and -falls back to a standalone policy check when that import fails (as it will on -this slim branch, which has no installed BenchBox). That is an optional -BenchBox import, not an absence of one: develop intentionally uses the -central schema policy when `benchbox` is importable there, while -`published-results` always takes the standalone fallback path. CI invokes -both scripts with `uv run --no-project --python 3.11`, so neither needs -project metadata or an installed BenchBox to run. +`generate_corpus_inventory.py`, `validate_submission.py`, and +`scripts/publication/validator_parity.py` use the same stdlib-only runtime on +both branches. The validator entry points import `benchbox/validation/bundle.py`, +which loads the mirrored `query_status.py` and `schema_policy.py` policies. +File-loader fallbacks support the slim checkout without an installed BenchBox; +they load those same shared policy files rather than substitute inline rules. +The mirror must carry all six runtime files together. CI invokes the scripts +with `uv run --no-project --python 3.11 -- python`, so neither project metadata +nor an installed BenchBox is required. The current `validate-submission.yml` invokes them via `uv run -- python scripts/" + + +def _write(path: Path, content: str | bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content, encoding="utf-8") + + +def _site(root: Path, label: str, snapshot: int = 11) -> Path: + for relative in ("index.html", "docs/index.html", "docs/dev/index.html", "blog/index.html", "results/index.html"): + _write(root / relative, f"{label} {relative}") + _write(root / "results" / "assets" / f"app-{label}.js", f"{label} ui") + _write(root / "404.html", FALLBACK) + database = root / "results" / "data" / "results.duckdb" + database.parent.mkdir(parents=True, exist_ok=True) + with duckdb.connect(str(database)) as connection: + connection.execute("CREATE TABLE metadata(read_model_version INTEGER)") + connection.execute("INSERT INTO metadata VALUES (?)", [snapshot]) + return root + + +def _stored_receipt(tmp_path: Path, tree: Path, run_id: int, ui: int, snapshot: int, trunk: str = SHA_A) -> Path: + digest, size, files = compute_tree_digest(tree) + built = make_receipt(run_id=run_id, trunk=trunk, ui=ui, snapshot=snapshot) + built["artifact"].update(sha256=digest, total_bytes=size, total_files=len(files)) + built["routes"] = [{"path": path, "source_sha": trunk} for path in ROUTES] + path = tmp_path / f"receipt-{run_id}.json" + path.write_bytes(receipt.canonical_bytes(built)) + return path + + +def _resolved(deployed: dict | None = None, mode: str = "rollback", stored: Path | None = None) -> dict: + rollback = None + if stored is not None: + rollback = {"run_id": 7, "phase": "full", "receipt_sha256": receipt.receipt_sha256(stored.read_bytes())} + return { + "mode": mode, + "trunk_sha": SHA_A, + "release_tag": "v0.4.1", + "release_sha": SHA_A, + "certifying_run_id": None, + "parent": None, + "rollback": rollback, + "deployed": deployed, + } + + +def test_rollback_prepare_full_phase_copies_the_restored_tree_and_describes_it(tmp_path: Path) -> None: + restored = _site(tmp_path / "restored", "old", snapshot=11) + stored = _stored_receipt(tmp_path, restored, 7, ui=11, snapshot=11) + resolved = tmp_path / "resolved.json" + resolved.write_text(json.dumps(_resolved(stored=stored)), encoding="utf-8") + out = tmp_path / "out" + + code = cli.main( + [ + "rollback-prepare", + "--receipt", + str(stored), + "--restored-tree", + str(restored), + "--resolved", + str(resolved), + "--site-dir", + str(tmp_path / "site-build"), + "--out-dir", + str(out), + ] + ) + + assert code == 0 + assembly = json.loads((out / "route-assembly.json").read_text(encoding="utf-8")) + assert assembly["tree_sha256"] == compute_tree_digest(tmp_path / "site-build")[0] + restore = json.loads((out / "restore.json").read_text(encoding="utf-8")) + assert restore["ui_version"] == 11 + assert restore["phase"] == "full" + + +def test_rollback_prepare_refuses_a_tampered_artifact(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + restored = _site(tmp_path / "restored", "old") + stored = _stored_receipt(tmp_path, restored, 7, ui=11, snapshot=11) + (restored / "index.html").write_text("tampered", encoding="utf-8") + resolved = tmp_path / "resolved.json" + resolved.write_text(json.dumps(_resolved(stored=stored)), encoding="utf-8") + code = cli.main( + [ + "rollback-prepare", + "--receipt", + str(stored), + "--restored-tree", + str(restored), + "--resolved", + str(resolved), + "--site-dir", + str(tmp_path / "site-build"), + "--out-dir", + str(tmp_path / "out"), + ] + ) + assert code == 1 + assert "does not match" in capsys.readouterr().err + assert not (tmp_path / "site-build").exists() + + +def test_rollback_prepare_ui_first_keeps_the_current_snapshot_and_current_corpus(tmp_path: Path) -> None: + restored = _site(tmp_path / "restored", "old", snapshot=11) + current = _site(tmp_path / "current", "new", snapshot=12) + stored = _stored_receipt(tmp_path, restored, 7, ui=11, snapshot=11) + current_receipt = _stored_receipt(tmp_path, current, 8, ui=12, snapshot=12, trunk=SHA_B) + resolved = tmp_path / "resolved.json" + current_digest = compute_tree_digest(current)[0] + deployed = { + "corpus_sha": "9" * 40, + "artifact_sha256": current_digest, + "receipt_sha256": receipt.receipt_sha256(current_receipt.read_bytes()), + } + resolved.write_text(json.dumps(_resolved(deployed, stored=stored)), encoding="utf-8") + out = tmp_path / "out" + + code = cli.main( + [ + "rollback-prepare", + "--receipt", + str(stored), + "--restored-tree", + str(restored), + "--current-tree", + str(current), + "--current-receipt", + str(current_receipt), + "--phase", + "ui-first", + "--resolved", + str(resolved), + "--site-dir", + str(tmp_path / "site-build"), + "--out-dir", + str(out), + ] + ) + + assert code == 0 + site = tmp_path / "site-build" + assert (site / "results" / "assets" / "app-old.js").is_file() + assert not (site / "results" / "assets" / "app-new.js").exists() + with duckdb.connect(str(site / "results" / "data" / "results.duckdb"), read_only=True) as connection: + assert connection.execute("SELECT read_model_version FROM metadata").fetchone() == (12,) + restore = json.loads((out / "restore.json").read_text(encoding="utf-8")) + assert restore["corpus_sha"] == "9" * 40 + routes = json.loads((out / "route-assembly.json").read_text(encoding="utf-8"))["routes"] + assert {route["path"]: route["source_sha"] for route in routes} == { + "/": SHA_B, + "/docs/": SHA_B, + "/docs/dev/": SHA_B, + "/blog/": SHA_B, + "/results/": SHA_A, + } + assert ( + json.loads((out / "route-assembly.json").read_text(encoding="utf-8"))["tree_sha256"] + == compute_tree_digest(site)[0] + ) + + +def test_finalize_probes_the_target_and_writes_a_receipt_that_the_generation_reader_accepts(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "b") + digest, size, files = compute_tree_digest(site) + out = tmp_path / "out" + out.mkdir() + (out / "resolved.json").write_text(json.dumps(_resolved(mode="deploy")), encoding="utf-8") + (out / "route-assembly.json").write_text( + json.dumps( + { + "routes": [{"path": path, "source_sha": SHA_A} for path in ROUTES], + "tree_sha256": digest, + "total_bytes": size, + "total_files": len(files), + } + ), + encoding="utf-8", + ) + (out / "gates.json").write_text( + json.dumps( + { + "ok": True, + "corpus_sha": "1" * 40, + "results": {"mixed_version": {"status": "pass", "versions": {"ui_expected": 11, "snapshot": 11}}}, + } + ), + encoding="utf-8", + ) + (out / "probe-checksums.json").write_text(json.dumps(checksums.checksum_manifest(site, ROUTES)), encoding="utf-8") + + with publish.running_target(tmp_path / "slot") as target: + target.publish(site) + code = cli.main( + [ + "finalize", + "--out-dir", + str(out), + "--base-url", + target.base_url, + "--target", + "local-dir", + "--run-id", + "42", + "--no-status", + "--attempts", + "1", + "--delay", + "0", + ] + ) + + assert code == 0 + built = receipt.validate_receipt(json.loads((out / "receipt" / "receipt.json").read_text(encoding="utf-8"))) + assert built["run_id"] == 42 + assert built["generation"] == 1 + assert built["probes"]["ok"] is True + assert built["versions"] == {"ui_expected": 11, "snapshot": 11} + assert built["artifact"]["sha256"] == digest + assert built["gates"]["gates_sha256"] + assert receipt.is_last_known_good(built, "local-dir") + + +def test_finalize_exits_nonzero_but_still_writes_the_receipt_when_probes_fail(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "b") + out = tmp_path / "out" + out.mkdir() + (out / "resolved.json").write_text(json.dumps(_resolved(mode="deploy")), encoding="utf-8") + digest, size, files = compute_tree_digest(site) + (out / "route-assembly.json").write_text( + json.dumps({"routes": [], "tree_sha256": digest, "total_bytes": size, "total_files": len(files)}), + encoding="utf-8", + ) + (out / "gates.json").write_text( + json.dumps({"ok": True, "corpus_sha": "1" * 40, "results": {"mixed_version": {"status": "pass"}}}), + encoding="utf-8", + ) + (out / "probe-checksums.json").write_text(json.dumps({"/": "0" * 64}), encoding="utf-8") + with publish.running_target(tmp_path / "slot") as target: + target.publish(site) + code = cli.main( + [ + "finalize", + "--out-dir", + str(out), + "--base-url", + target.base_url, + "--target", + "local-dir", + "--run-id", + "9", + "--no-status", + "--attempts", + "1", + "--delay", + "0", + ] + ) + assert code == 1 + built = json.loads((out / "receipt" / "receipt.json").read_text(encoding="utf-8")) + assert built["probes"]["ok"] is False + assert not receipt.is_last_known_good(built, "local-dir") + + +def _prepare(tmp_path: Path, stored: Path, restored: Path, resolved: dict, *extra: str) -> int: + path = tmp_path / "resolved.json" + path.write_text(json.dumps(resolved), encoding="utf-8") + return cli.main( + [ + "rollback-prepare", + "--receipt", + str(stored), + "--restored-tree", + str(restored), + "--resolved", + str(path), + "--site-dir", + str(tmp_path / "site-build"), + "--out-dir", + str(tmp_path / "out"), + *extra, + ] + ) + + +def test_rollback_prepare_refuses_a_receipt_that_differs_from_the_resolved_digest( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + restored = _site(tmp_path / "restored", "old") + stored = _stored_receipt(tmp_path, restored, 7, ui=11, snapshot=11) + resolved = _resolved(stored=stored) + resolved["rollback"]["receipt_sha256"] = "0" * 64 + assert _prepare(tmp_path, stored, restored, resolved) == 1 + assert "does not match the resolved" in capsys.readouterr().err + assert not (tmp_path / "site-build").exists() + + +def test_rollback_prepare_refuses_a_resolution_without_a_receipt_digest(tmp_path: Path) -> None: + restored = _site(tmp_path / "restored", "old") + stored = _stored_receipt(tmp_path, restored, 7, ui=11, snapshot=11) + assert _prepare(tmp_path, stored, restored, _resolved()) == 1 + + +def test_rollback_prepare_ui_first_refuses_a_current_tree_that_differs_from_the_deployed_digest( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + restored = _site(tmp_path / "restored", "old", snapshot=11) + current = _site(tmp_path / "current", "new", snapshot=12) + stored = _stored_receipt(tmp_path, restored, 7, ui=11, snapshot=11) + current_receipt = _stored_receipt(tmp_path, current, 8, ui=12, snapshot=12, trunk=SHA_B) + deployed = {"corpus_sha": "9" * 40, "artifact_sha256": compute_tree_digest(current)[0]} + (current / "index.html").write_text("tampered", encoding="utf-8") + code = _prepare( + tmp_path, + stored, + restored, + _resolved(deployed, stored=stored), + "--current-tree", + str(current), + "--current-receipt", + str(current_receipt), + "--phase", + "ui-first", + ) + assert code == 1 + assert "does not match recorded" in capsys.readouterr().err + assert not (tmp_path / "site-build").exists() + + +def test_deployment_lookup_failures_exit_cleanly(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None: + from tests.unit.scripts.site_deploy.site_deploy_fakes import FakeGitHub + + site = _site(tmp_path / "site", "b") + digest, size, files = compute_tree_digest(site) + out = tmp_path / "out" + out.mkdir() + (out / "resolved.json").write_text(json.dumps(_resolved(mode="deploy")), encoding="utf-8") + (out / "route-assembly.json").write_text( + json.dumps({"routes": [], "tree_sha256": digest, "total_bytes": size, "total_files": len(files)}), + encoding="utf-8", + ) + (out / "gates.json").write_text( + json.dumps({"ok": True, "corpus_sha": "1" * 40, "results": {"mixed_version": {"status": "pass"}}}), + encoding="utf-8", + ) + (out / "probe-checksums.json").write_text(json.dumps({}), encoding="utf-8") + monkeypatch.setenv("GITHUB_RUN_ID", "77") + monkeypatch.setattr(cli, "_client", FakeGitHub().client) + with publish.running_target(tmp_path / "slot") as target: + target.publish(site) + code = cli.main( + ["finalize", "--out-dir", str(out), "--base-url", target.base_url, "--attempts", "1", "--delay", "0"] + ) + assert code == 1 + assert "refuse: DeploymentLookupError" in capsys.readouterr().err diff --git a/tests/unit/scripts/site_deploy/test_site_deploy_cli_flow.py b/tests/unit/scripts/site_deploy/test_site_deploy_cli_flow.py new file mode 100644 index 0000000000..3938f471fa --- /dev/null +++ b/tests/unit/scripts/site_deploy/test_site_deploy_cli_flow.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +import duckdb +import pytest + +from scripts.site_deploy import candidate, checksums, cli, generation, receipt +from tests.unit.scripts.site_deploy.site_deploy_fakes import SHA_A, SHA_B, FakeGitHub, make_receipt + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + +TRUNK_PATH = ".github/workflows/trunk.yml" + + +def _use(monkeypatch: pytest.MonkeyPatch, api: FakeGitHub) -> None: + monkeypatch.setattr(cli, "_client", api.client) + monkeypatch.setattr(cli, "gh_receipt_loader", lambda repo: api.load_receipt) + monkeypatch.setattr(candidate, "release_tags", lambda repo_dir: ["v0.4.1"]) + monkeypatch.setattr(candidate, "tag_commit", lambda repo_dir, tag: SHA_A) + monkeypatch.setattr(candidate, "first_parent_shas", lambda repo_dir, ref, limit=50: [SHA_B]) + monkeypatch.setattr(candidate, "is_ancestor", lambda repo_dir, ancestor, descendant: ancestor == SHA_A) + + +def _green(api: FakeGitHub) -> None: + api.runs.append( + { + "id": 5, + "head_sha": SHA_B, + "head_branch": "develop", + "event": "push", + "conclusion": "success", + "path": TRUNK_PATH, + "created_at": "2026-01-01T00:00:00Z", + } + ) + + +def _resolve(tmp_path: Path, *args: str) -> tuple[int, dict[str, Any]]: + output = tmp_path / "resolved.json" + code = cli.main(["resolve", "--output", str(output), *args]) + return code, json.loads(output.read_text(encoding="utf-8")) if output.exists() else {} + + +def _legacy(api: FakeGitHub, deployment_id: int, created: str) -> None: + api.deployments.append({"id": deployment_id, "created_at": created}) + api.statuses[deployment_id] = [{"state": "success", "description": "legacy", "created_at": created}] + + +def test_bootstrap_resolve_orders_against_the_newest_receipt_and_records_the_carry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + api = FakeGitHub() + _green(api) + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A, generation=4), "2026-01-01T00:00:01Z") + _legacy(api, 20, "2026-01-01T00:00:02Z") + _use(monkeypatch, api) + code, resolved = _resolve(tmp_path, "--mode", "deploy", "--bootstrap") + assert code == 0 + assert resolved["action"] == "deploy" + assert resolved["parent"]["generation"] == 4 + assert resolved["parent"]["newer_unreceipted"] is True + code, _ = _resolve(tmp_path, "--mode", "deploy") + assert code == 1 + + +def test_preview_refuses_when_the_recorded_receipt_is_unreadable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + api = FakeGitHub() + _green(api) + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A, generation=4), "2026-01-01T00:00:01Z") + _use(monkeypatch, api) + + def gone(run_id: int, expected_sha256: str | None = None) -> bytes: + raise OSError("expired") + + monkeypatch.setattr(cli, "gh_receipt_loader", lambda repo: gone) + code, resolved = _resolve(tmp_path, "--mode", "preview") + assert code == 1 + assert resolved == {} + + +def test_current_unknown_rollback_requires_a_recorded_target_and_marks_the_parent_unknown( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A, generation=3), "2026-01-01T00:00:01Z") + _legacy(api, 20, "2026-01-01T00:00:02Z") + _use(monkeypatch, api) + strict, _ = _resolve(tmp_path, "--mode", "rollback", "--rollback-run-id", "1") + assert strict == 1 + code, resolved = _resolve(tmp_path, "--mode", "rollback", "--rollback-run-id", "1", "--current-unknown") + assert code == 0 + assert resolved["current_unknown"] is True + assert resolved["newest_deployment_id"] == 20 + assert resolved["parent"]["unknown"] is True + assert resolved["parent"]["generation"] == 3 + assert resolved["deployed"]["unknown"] is True + assert resolved["rollback"]["receipt_sha256"] == receipt.receipt_sha256(api.receipts[1]) + + +def test_current_unknown_rollback_refuses_an_unrecorded_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + api = FakeGitHub() + _legacy(api, 20, "2026-01-01T00:00:02Z") + _use(monkeypatch, api) + code, _ = _resolve(tmp_path, "--mode", "rollback", "--rollback-run-id", "1", "--current-unknown") + assert code == 1 + assert "no receipt of run 1 is recorded" in capsys.readouterr().err + + +def test_current_unknown_rollback_supports_only_the_full_phase( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A), "2026-01-01T00:00:01Z") + _use(monkeypatch, api) + args = ["--mode", "rollback", "--rollback-run-id", "1", "--current-unknown", "--rollback-phase", "ui-first"] + code, _ = _resolve(tmp_path, *args) + assert code == 1 + assert "supports only full" in capsys.readouterr().err + + +def test_current_unknown_is_rejected_outside_rollback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _use(monkeypatch, FakeGitHub()) + code, _ = _resolve(tmp_path, "--mode", "deploy", "--current-unknown") + assert code == 1 + assert "only to rollback" in capsys.readouterr().err + + +def test_recheck_of_an_unknown_current_rollback_compares_the_newest_deployment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + api = FakeGitHub() + _legacy(api, 20, "2026-01-01T00:00:02Z") + _use(monkeypatch, api) + resolved = tmp_path / "resolved.json" + resolved.write_text( + json.dumps({"mode": "rollback", "bootstrap": False, "current_unknown": True, "newest_deployment_id": 20}), + encoding="utf-8", + ) + assert cli.main(["recheck", "--resolved", str(resolved)]) == 0 + _legacy(api, 21, "2026-01-01T00:00:03Z") + assert cli.main(["recheck", "--resolved", str(resolved)]) == 1 + + +def _snapshot(path: Path, version: int) -> Path: + with duckdb.connect(str(path)) as connection: + connection.execute("CREATE TABLE metadata(read_model_version INTEGER)") + connection.execute("INSERT INTO metadata VALUES (?)", [version]) + return path + + +def test_gates_derive_unknown_current_versions_from_the_live_snapshot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from scripts.site_deploy import gates as gates_module + + captured: dict[str, Any] = {} + + def fake_run(inputs: Any) -> dict[str, Any]: + captured["deployed"] = inputs.deployed + captured["tag"] = inputs.release_tag + return {"ok": True, "results": {}, "corpus_sha": inputs.corpus_sha} + + monkeypatch.setattr(gates_module, "run_gates", fake_run) + resolved = tmp_path / "resolved.json" + resolved.write_text( + json.dumps( + { + "mode": "rollback", + "trunk_sha": SHA_A, + "release_tag": "v0.4.1", + "rollback": {"phase": "full"}, + "deployed": {"unknown": True, "ui_version": None, "snapshot_version": None}, + } + ), + encoding="utf-8", + ) + assembly = tmp_path / "assembly.json" + assembly.write_text(json.dumps({"routes": []}), encoding="utf-8") + site = tmp_path / "site" + (site / "results" / "data").mkdir(parents=True) + _snapshot(site / "results" / "data" / "results.duckdb", 12) + out = tmp_path / "out" + live = _snapshot(tmp_path / "live.duckdb", 12) + base = [ + "gates", + "--repo-root", + str(tmp_path), + "--site-dir", + str(site), + "--resolved", + str(resolved), + "--assembly", + str(assembly), + "--out-dir", + str(out), + "--corpus-sha", + "1" * 40, + ] + assert cli.main([*base, "--deployed-snapshot", str(live)]) == 0 + assert captured["deployed"]["ui_version"] == 12 + assert captured["deployed"]["snapshot_version"] == 12 + assert captured["tag"] == "v0.4.1" + written = json.loads((out / "gates.json").read_text(encoding="utf-8")) + assert written["live_versions"] == {"ui_upper_bound": 12, "snapshot": 12} + assert cli.main(base) == 1 + + +def test_finalize_binds_the_deployment_to_the_run_and_record_posts_after_the_receipt_exists( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from scripts.publication.assembler import compute_tree_digest + from scripts.site_deploy import publish + + api = FakeGitHub() + api.deployments = [{"id": 1, "created_at": "2026-01-01T00:00:00Z"}, {"id": 2, "created_at": "2030-01-01T00:00:00Z"}] + api.statuses[1] = [ + { + "state": "success", + "log_url": "https://github.com/owner/repo/actions/runs/77", + "created_at": "2026-01-01T00:00:00Z", + } + ] + api.statuses[2] = [ + { + "state": "success", + "log_url": "https://github.com/owner/repo/actions/runs/88", + "created_at": "2030-01-01T00:00:00Z", + } + ] + monkeypatch.setattr(cli, "_client", api.client) + monkeypatch.setenv("GITHUB_RUN_ID", "77") + site = tmp_path / "site" + (site / "index.html").parent.mkdir(parents=True) + (site / "index.html").write_text("x", encoding="utf-8") + (site / "results" / "data").mkdir(parents=True) + (site / "404.html").write_text( + "", encoding="utf-8" + ) + _snapshot(site / "results" / "data" / "results.duckdb", 11) + digest, size, files = compute_tree_digest(site) + out = tmp_path / "out" + out.mkdir() + (out / "resolved.json").write_text( + json.dumps( + { + "mode": "deploy", + "trunk_sha": SHA_A, + "release_tag": "v0.4.1", + "release_sha": SHA_A, + "certifying_run_id": 5, + "parent": {"unknown": True, "generation": 3}, + "rollback": None, + "deployed": None, + } + ), + encoding="utf-8", + ) + (out / "route-assembly.json").write_text( + json.dumps({"routes": [], "tree_sha256": digest, "total_bytes": size, "total_files": len(files)}), + encoding="utf-8", + ) + (out / "gates.json").write_text( + json.dumps( + { + "ok": True, + "corpus_sha": "1" * 40, + "live_versions": {"ui_upper_bound": 12, "snapshot": 12}, + "results": {"mixed_version": {"status": "pass"}}, + } + ), + encoding="utf-8", + ) + (out / "probe-checksums.json").write_text(json.dumps(checksums.checksum_manifest(site, ["/"])), encoding="utf-8") + with publish.running_target(tmp_path / "slot") as target: + target.publish(site) + code = cli.main( + [ + "finalize", + "--out-dir", + str(out), + "--base-url", + target.base_url, + "--artifact-name", + "site-deploy-artifact-77-2", + "--attempts", + "1", + "--delay", + "0", + ] + ) + assert code == 0 + assert api.statuses[1][-1].get("description") is None + built = receipt.validate_receipt(json.loads((out / "receipt" / "receipt.json").read_text(encoding="utf-8"))) + assert built["deployment_id"] == 1 + assert built["artifact"]["name"] == "site-deploy-artifact-77-2" + assert built["generation"] == 4 + assert built["parent"]["live_versions"] == {"ui_upper_bound": 12, "snapshot": 12} + assert cli.main(["record", "--out-dir", str(out), "--base-url", target.base_url]) == 0 + posted = api.statuses[1][-1] + raw = (out / "receipt" / "receipt.json").read_bytes() + assert posted["description"] == receipt.status_description(receipt.receipt_sha256(raw), 77) + assert 2 not in {key for key, rows in api.statuses.items() if rows and rows[-1].get("description")} + + +def test_record_refuses_a_receipt_without_a_deployment_id(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + built = make_receipt(run_id=77, trunk=SHA_A) + built["deployment_id"] = None + (tmp_path / "receipt").mkdir() + (tmp_path / "receipt" / "receipt.json").write_bytes(receipt.canonical_bytes(built)) + assert cli.main(["record", "--out-dir", str(tmp_path)]) == 1 + assert "no deployment id" in capsys.readouterr().err + + +def test_record_refuses_a_receipt_whose_probes_failed(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + built = make_receipt(run_id=77, trunk=SHA_A) + built["deployment_id"] = 5 + built["probes"]["ok"] = False + (tmp_path / "receipt").mkdir() + (tmp_path / "receipt" / "receipt.json").write_bytes(receipt.canonical_bytes(built)) + assert cli.main(["record", "--out-dir", str(tmp_path)]) == 1 + assert "not last-known-good" in capsys.readouterr().err + + +def test_receipt_artifact_names_pick_the_run_and_order_attempts_newest_first() -> None: + names = [ + "site-deploy-receipt-9-1", + "site-deploy-receipt-9-3", + "site-deploy-receipt-9-2", + "site-deploy-receipt-99-9", + "site-deploy-receipt-9", + "site-deploy-artifact-9-1", + ] + assert cli.receipt_artifact_names(names, 9) == [ + "site-deploy-receipt-9-3", + "site-deploy-receipt-9-2", + "site-deploy-receipt-9-1", + ] + + +def test_receipt_loader_returns_the_attempt_whose_digest_matches(monkeypatch: pytest.MonkeyPatch) -> None: + first = receipt.canonical_bytes(make_receipt(run_id=9, trunk=SHA_A)) + second = receipt.canonical_bytes(make_receipt(run_id=9, trunk=SHA_B)) + by_name = {"site-deploy-receipt-9-1": first, "site-deploy-receipt-9-2": second} + monkeypatch.setattr(cli, "_gh", lambda *args: "\n".join(by_name) + "\n") + + def download(repo: str, run_id: int, name: str, destination: Path) -> None: + (destination / cli.RECEIPT_FILE).write_bytes(by_name[name]) + + monkeypatch.setattr(cli, "gh_download", download) + load = cli.gh_receipt_loader("owner/repo") + assert load(9) == second + assert load(9, receipt.receipt_sha256(first)) == first + with pytest.raises(OSError, match="matches the recorded digest"): + load(9, "0" * 64) + + +def test_gh_failures_surface_as_oserror(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*args: Any, **kwargs: Any) -> Any: + raise subprocess.CalledProcessError(1, "gh", stderr="no") + + monkeypatch.setattr(subprocess, "run", boom) + with pytest.raises(OSError, match="gh api"): + cli.gh_receipt_loader("owner/repo")(9) + + +def test_unreadable_receipt_authority_is_a_clean_refusal(monkeypatch: pytest.MonkeyPatch) -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A), "2026-01-01T00:00:01Z") + + def gone(run_id: int, expected_sha256: str | None = None) -> bytes: + raise OSError("expired") + + with pytest.raises(generation.GenerationAuthorityError, match="unavailable"): + generation.read_deployed(api.client(), gone) + + +def test_recheck_ignores_the_run_s_own_waiting_deployment_but_refuses_a_foreign_newer_one( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + api = FakeGitHub() + _legacy(api, 20, "2026-01-01T00:00:02Z") + _use(monkeypatch, api) + monkeypatch.setenv("GITHUB_RUN_ID", "77") + resolved = tmp_path / "resolved.json" + resolved.write_text( + json.dumps({"mode": "rollback", "bootstrap": False, "current_unknown": True, "newest_deployment_id": 20}), + encoding="utf-8", + ) + api.deployments.append({"id": 30, "created_at": "2026-01-01T00:00:09Z"}) + api.statuses[30] = [ + { + "state": "waiting", + "log_url": "https://github.com/owner/repo/actions/runs/77/job/5", + "created_at": "2026-01-01T00:00:09Z", + } + ] + assert cli.main(["recheck", "--resolved", str(resolved)]) == 0 + api.deployments.append({"id": 31, "created_at": "2026-01-01T00:00:10Z"}) + api.statuses[31] = [ + { + "state": "success", + "log_url": "https://github.com/owner/repo/actions/runs/78", + "created_at": "2026-01-01T00:00:10Z", + } + ] + assert cli.main(["recheck", "--resolved", str(resolved)]) == 1 diff --git a/tests/unit/scripts/site_deploy/test_site_deploy_gates.py b/tests/unit/scripts/site_deploy/test_site_deploy_gates.py new file mode 100644 index 0000000000..6da614eccb --- /dev/null +++ b/tests/unit/scripts/site_deploy/test_site_deploy_gates.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import duckdb +import pytest + +from scripts.site_deploy import gates as g + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + +ROUTES = Path(__file__).resolve().parents[4] / "deploy" / "routes.yml" +TRUNK = "a" * 40 +OLD_TRUNK = "b" * 40 +CORPUS = "1" * 40 +OTHER_CORPUS = "2" * 40 + + +class Recorder: + def __init__(self, failing: tuple[str, ...] = (), outputs: dict[str, str] | None = None) -> None: + self.commands: list[list[str]] = [] + self.failing = failing + self.outputs = outputs or {} + + def __call__(self, command: list[str], cwd: Path) -> tuple[int, str]: + self.commands.append(command) + script = Path(command[1]).name + key = f"{script} {command[2]}" if script == "compare_db_digest.py" or script == "site_inventory.py" else script + if script == "compare_db_digest.py" and command[2] == "digest": + return 0, self.outputs.get(command[3], "digest-" + Path(command[3]).name) + if "--write-known-broken" in command: + Path(command[command.index("--write-known-broken") + 1]).write_text("[]", encoding="utf-8") + code = 1 if any(name in key for name in self.failing) else 0 + return code, f"{key} output" + + def scripts(self) -> list[str]: + return [Path(command[1]).name for command in self.commands] + + +def _site(tmp_path: Path, ui_snapshot: int = 11) -> Path: + site = tmp_path / "site" + (site / "results" / "data").mkdir(parents=True, exist_ok=True) + (site / "results" / "data" / "results.duckdb").unlink(missing_ok=True) + with duckdb.connect(str(site / "results" / "data" / "results.duckdb")) as connection: + connection.execute("CREATE TABLE metadata(read_model_version INTEGER)") + connection.execute("INSERT INTO metadata VALUES (?)", [ui_snapshot]) + return site + + +def _repo(tmp_path: Path, ui: int = 11) -> Path: + repo = tmp_path / "repo" + (repo / "results-explorer" / "src").mkdir(parents=True, exist_ok=True) + (repo / "results-explorer" / "src" / "db.ts").write_text( + f"const EXPECTED_READ_MODEL_VERSION = {ui};\n", encoding="utf-8" + ) + return repo + + +def _inputs(tmp_path: Path, **overrides) -> g.GateInputs: + deployed = overrides.pop( + "deployed", + {"trunk_sha": OLD_TRUNK, "corpus_sha": CORPUS, "ui_version": 11, "snapshot_version": 11}, + ) + snapshot = tmp_path / "deployed.duckdb" + snapshot.write_bytes(b"x") + values = { + "repo_root": _repo(tmp_path), + "site_dir": _site(tmp_path), + "work_dir": tmp_path / "work", + "trunk_sha": TRUNK, + "corpus_sha": CORPUS, + "mode": "deploy", + "deployed": deployed, + "deployed_snapshot": snapshot, + "release_tag": "v0.4.1", + "routes_manifest": ROUTES, + } + values.update(overrides) + return g.GateInputs(**values) + + +def test_every_decided_gate_is_wired_and_calls_its_script(tmp_path: Path) -> None: + runner = Recorder() + outcome = g.run_gates( + _inputs( + tmp_path, + deployed={"trunk_sha": OLD_TRUNK, "corpus_sha": OTHER_CORPUS, "ui_version": 11, "snapshot_version": 11}, + ), + runner, + ) + assert set(outcome["results"]) == { + "privacy", + "explorer_compat", + "mixed_version", + "corpus_bijection", + "digest", + "validator_parity", + "links", + } + scripts = runner.scripts() + for expected in ( + "check_artifact_privacy.py", + "check_explorer_compat.py", + "check_corpus_bijection.py", + "compare_db_digest.py", + "validator_parity.py", + "site_inventory.py", + ): + assert expected in scripts + assert outcome["ok"] is True + + +def test_privacy_scans_the_assembled_site(tmp_path: Path) -> None: + runner = Recorder() + inputs = _inputs(tmp_path) + g.privacy_gate(inputs, runner) + assert runner.commands[0][2:] == [str(inputs.site_dir)] + + +def test_explorer_compat_receives_the_explorer_tree_and_snapshot(tmp_path: Path) -> None: + runner = Recorder() + inputs = _inputs(tmp_path) + g.explorer_compat_gate(inputs, runner) + command = runner.commands[0] + assert command[command.index("--artifact") + 1] == str(inputs.site_dir / "results") + assert command[command.index("--db-path") + 1].endswith("results/data/results.duckdb") + + +def test_corpus_bijection_is_pinned_to_the_trunk_sha_and_requires_the_snapshot(tmp_path: Path) -> None: + runner = Recorder() + g.corpus_bijection_gate(_inputs(tmp_path), runner) + command = runner.commands[0] + assert command[command.index("--accepted-ref") + 1] == TRUNK + assert command[command.index("--expect-source") + 1] == TRUNK + assert "--require-artifact" in command + assert command[command.index("--ledger-seed") + 1].endswith("publication/ledger-seed.json") + + +@pytest.mark.parametrize( + "failing", + [ + "check_artifact_privacy", + "check_explorer_compat", + "check_corpus_bijection", + "validator_parity", + "site_inventory.py check", + ], +) +def test_any_failing_script_fails_the_aggregate(tmp_path: Path, failing: str) -> None: + deployed = {"trunk_sha": OLD_TRUNK, "corpus_sha": OTHER_CORPUS, "ui_version": 11, "snapshot_version": 11} + outcome = g.run_gates(_inputs(tmp_path, deployed=deployed), Recorder(failing=(failing,))) + assert outcome["ok"] is False + failed = [name for name, result in outcome["results"].items() if result["status"] == g.FAIL] + assert len(failed) == 1 + + +def test_digest_gate_requires_equality_when_the_corpus_is_unchanged(tmp_path: Path) -> None: + runner = Recorder() + result = g.digest_gate(_inputs(tmp_path), runner) + assert result["status"] == g.PASS + assert runner.commands[0][2] == "compare" + failing = g.digest_gate(_inputs(tmp_path), Recorder(failing=("compare_db_digest.py compare",))) + assert failing["status"] == g.FAIL + + +def test_digest_gate_records_both_digests_when_the_corpus_changed(tmp_path: Path) -> None: + runner = Recorder() + deployed = {"trunk_sha": OLD_TRUNK, "corpus_sha": OTHER_CORPUS, "ui_version": 11, "snapshot_version": 11} + result = g.digest_gate(_inputs(tmp_path, deployed=deployed), runner) + assert result["status"] == g.SKIPPED + assert set(result["digests"]) == {"candidate", "deployed"} + + +def test_digest_gate_fails_when_a_deployed_generation_has_no_snapshot(tmp_path: Path) -> None: + result = g.digest_gate(_inputs(tmp_path, deployed_snapshot=None), Recorder()) + assert result["status"] == g.FAIL + + +def test_digest_and_parity_are_skipped_without_a_deployed_generation(tmp_path: Path) -> None: + runner = Recorder() + inputs = _inputs(tmp_path, deployed=None, deployed_snapshot=None) + assert g.digest_gate(inputs, runner)["status"] == g.SKIPPED + assert g.validator_parity_gate(inputs, runner)["status"] == g.SKIPPED + assert runner.commands == [] + + +def test_validator_parity_uses_the_deployed_trunk_as_base_only_when_the_corpus_changed(tmp_path: Path) -> None: + unchanged = Recorder() + assert g.validator_parity_gate(_inputs(tmp_path), unchanged)["status"] == g.SKIPPED + assert unchanged.commands == [] + runner = Recorder() + deployed = {"trunk_sha": OLD_TRUNK, "corpus_sha": OTHER_CORPUS, "ui_version": 11, "snapshot_version": 11} + g.validator_parity_gate(_inputs(tmp_path, deployed=deployed), runner) + command = runner.commands[0] + assert command[command.index("--base-sha") + 1] == OLD_TRUNK + assert command[command.index("--merge-sha") + 1] == TRUNK + assert command[command.index("--head-sha") + 1] == TRUNK + + +def test_link_gate_builds_an_inventory_then_checks_it(tmp_path: Path) -> None: + runner = Recorder() + g.link_gate(_inputs(tmp_path), runner) + assert [command[2] for command in runner.commands] == ["build", "check", "check"] + assert "--write-known-broken" in runner.commands[2] + + +def test_link_gate_stops_when_the_inventory_cannot_be_built(tmp_path: Path) -> None: + runner = Recorder(failing=("site_inventory.py build",)) + result = g.link_gate(_inputs(tmp_path), runner) + assert result["status"] == g.FAIL + assert len(runner.commands) == 1 + + +SUMMARY = "summary: 1 missing path, 2 missing fragment, {} broken internal link, {} missing image, 0 feed regression" + + +def _link_runner(listing: list[list[str]], broken: int = 1, images: int = 0): + def run(command: list[str], cwd: Path) -> tuple[int, str]: + if "--write-known-broken" in command: + Path(command[command.index("--write-known-broken") + 1]).write_text(json.dumps(listing), encoding="utf-8") + return 0, "wrote" + if command[2] == "check": + return 1, SUMMARY.format(broken, images) + return 0, "wrote inventory" + + return run + + +def test_routed_allowance_repeats_docs_entries_under_the_dev_prefix() -> None: + known = [ + ["/docs/blog.html", "/docs/blog/a.html", "missing path"], + ["/blog/a.html", "/usage/x.html", "missing path"], + ] + routed = g.routed_allowance(known) + assert ["/docs/dev/blog.html", "/docs/dev/blog/a.html", "missing path"] in routed + assert known[0] in routed + assert len(routed) == 3 + + +def _deployed_with_baseline(links: list[list[str]] | None, tag: str = "v0.4.1") -> dict: + deployed = {"trunk_sha": OLD_TRUNK, "corpus_sha": CORPUS, "ui_version": 11, "snapshot_version": 11} + deployed["link_baseline"] = {} if links is None else {tag: links} + return deployed + + +RELEASE_BROKEN = ["/docs/old.html", "/docs/gone.html", "missing path"] + + +def test_link_gate_tolerates_release_breakage_up_to_the_recorded_baseline(tmp_path: Path) -> None: + result = g.link_gate( + _inputs(tmp_path, deployed=_deployed_with_baseline([RELEASE_BROKEN])), _link_runner([RELEASE_BROKEN]) + ) + assert result["status"] == g.PASS + assert "release tag v0.4.1" in result["detail"] + assert result["link_baseline"] == {"release_tag": "v0.4.1", "broken": 1, "links": [RELEASE_BROKEN]} + + +def test_link_gate_fails_when_a_newer_release_tag_adds_broken_links(tmp_path: Path) -> None: + listing = [RELEASE_BROKEN, ["/docs/x.html", "/docs/y.html", "missing path"]] + result = g.link_gate(_inputs(tmp_path, deployed=_deployed_with_baseline([RELEASE_BROKEN])), _link_runner(listing)) + assert result["status"] == g.FAIL + assert "outside the last deployed receipt baseline of 1" in result["detail"] + + +def test_link_gate_fails_when_fixed_links_are_swapped_for_new_ones(tmp_path: Path) -> None: + swapped = ["/docs/x.html", "/docs/y.html", "missing path"] + result = g.link_gate(_inputs(tmp_path, deployed=_deployed_with_baseline([RELEASE_BROKEN])), _link_runner([swapped])) + assert result["status"] == g.FAIL + assert "/docs/x.html" in result["detail"] + + +def test_link_gate_passes_a_pure_reduction_and_records_the_smaller_set(tmp_path: Path) -> None: + other = ["/docs/x.html", "/docs/y.html", "missing path"] + deployed = _deployed_with_baseline([RELEASE_BROKEN, other]) + result = g.link_gate(_inputs(tmp_path, deployed=deployed), _link_runner([other])) + assert result["status"] == g.PASS + assert result["link_baseline"]["links"] == [other] + + +def test_link_gate_ignores_a_baseline_recorded_for_another_tag(tmp_path: Path) -> None: + inputs = _inputs(tmp_path, deployed=_deployed_with_baseline([RELEASE_BROKEN], tag="v0.4.0")) + result = g.link_gate(inputs, _link_runner([RELEASE_BROKEN])) + assert result["status"] == g.FAIL + assert "outside the allowance file baseline of 0" in result["detail"] + + +def test_link_gate_without_a_receipt_pins_release_breakage_to_the_allowance_file(tmp_path: Path) -> None: + inputs = _inputs(tmp_path, deployed=None, deployed_snapshot=None) + allowance = inputs.repo_root / "_project" / "design" / "site-inventory" / "known-broken-links.json" + allowance.parent.mkdir(parents=True) + allowance.write_text(json.dumps([RELEASE_BROKEN]), encoding="utf-8") + assert g.link_gate(inputs, _link_runner([RELEASE_BROKEN]))["status"] == g.PASS + worse = [RELEASE_BROKEN, ["/docs/x.html", "/docs/y.html", "missing path"]] + assert g.link_gate(inputs, _link_runner(worse))["status"] == g.FAIL + + +def test_link_gate_fails_new_breakage_whose_source_is_a_trunk_route(tmp_path: Path) -> None: + runner = _link_runner([RELEASE_BROKEN, ["/blog/p.html", "/x.html", "missing path"]]) + result = g.link_gate(_inputs(tmp_path, deployed=_deployed_with_baseline([RELEASE_BROKEN])), runner) + assert result["status"] == g.FAIL + assert "/blog/p.html" in result["detail"] + + +@pytest.mark.parametrize( + "entry", + [ + ["/docs/old.html", "/docs/dev/gone.html", "missing path"], + ["/docs/old.html", "/results/gone.html", "missing path"], + ["/index.html", "/blog/gone/", "missing path"], + ["/docs/old.html", "/404.html", "missing path"], + ["/docs/old.html", "/_static/gone.css", "missing path"], + ], +) +def test_link_gate_fails_new_breakage_whose_target_is_trunk_owned(tmp_path: Path, entry: list[str]) -> None: + result = g.link_gate(_inputs(tmp_path, deployed=_deployed_with_baseline([])), _link_runner([entry])) + assert result["status"] == g.FAIL + assert "trunk routes" in result["detail"] + + +def test_link_gate_allows_allowance_listed_entries_that_touch_trunk_routes(tmp_path: Path) -> None: + entry = ["/blog/p.html", "/x.html", "missing path"] + inputs = _inputs(tmp_path, deployed=_deployed_with_baseline([])) + allowance = inputs.repo_root / "_project" / "design" / "site-inventory" / "known-broken-links.json" + allowance.parent.mkdir(parents=True) + allowance.write_text(json.dumps([entry]), encoding="utf-8") + result = g.link_gate(inputs, _link_runner([entry])) + assert result["status"] == g.PASS + assert result["link_baseline"]["links"] == [] + + +def test_link_gate_derives_ownership_from_the_routes_manifest(tmp_path: Path) -> None: + manifest = tmp_path / "routes.yml" + manifest.write_text( + "version: 1\nrefs:\n release:\n kind: release-tag\n trunk:\n kind: trunk\n" + "routes:\n - path: /\n ref: release\n builder: landing\n" + " - path: /docs/\n ref: trunk\n builder: docs\n" + "root_files:\n ref: release\n files: [CNAME]\n", + encoding="utf-8", + ) + inputs = _inputs(tmp_path, deployed=_deployed_with_baseline([]), routes_manifest=manifest) + result = g.link_gate(inputs, _link_runner([RELEASE_BROKEN])) + assert result["status"] == g.FAIL + assert "trunk routes" in result["detail"] + + +def test_link_gate_fails_without_a_routes_manifest(tmp_path: Path) -> None: + inputs = _inputs(tmp_path, routes_manifest=tmp_path / "missing.yml") + outcome = g.run_gates(inputs, Recorder()) + assert outcome["results"]["links"]["status"] == g.FAIL + + +def test_link_gate_fails_on_missing_images_even_without_broken_links(tmp_path: Path) -> None: + runner = _link_runner([], broken=0, images=2) + assert g.link_gate(_inputs(tmp_path), runner)["status"] == g.FAIL + + +def test_mixed_version_gate_refuses_a_snapshot_older_than_the_ui(tmp_path: Path) -> None: + inputs = _inputs(tmp_path) + inputs = g.GateInputs(**{**inputs.__dict__, "repo_root": _repo(tmp_path, ui=12)}) + result = g.mixed_version_gate(inputs, Recorder()) + assert result["status"] == g.FAIL + assert result["versions"] == {"ui_expected": 12, "snapshot": 11} + + +def test_mixed_version_gate_refuses_a_new_snapshot_that_the_deployed_ui_cannot_read_older_than_ui( + tmp_path: Path, +) -> None: + deployed = {"trunk_sha": OLD_TRUNK, "corpus_sha": CORPUS, "ui_version": 12, "snapshot_version": 12} + result = g.mixed_version_gate(_inputs(tmp_path, deployed=deployed), Recorder()) + assert result["status"] == g.FAIL + assert any(not pair["ok"] for pair in result["evaluation"]["pairs"]) + + +def test_mixed_version_gate_passes_when_snapshot_is_at_least_ui_for_each_pair(tmp_path: Path) -> None: + result = g.mixed_version_gate(_inputs(tmp_path), Recorder()) + assert result["status"] == g.PASS + assert result["versions"] == {"ui_expected": 11, "snapshot": 11} + + +def test_rollback_runs_only_the_artifact_level_gates(tmp_path: Path) -> None: + runner = Recorder() + outcome = g.run_gates(_inputs(tmp_path, mode="rollback", ui_version=11), runner) + skipped = {name for name, result in outcome["results"].items() if result["status"] == g.SKIPPED} + assert skipped == {"corpus_bijection", "digest", "validator_parity", "links"} + assert set(runner.scripts()) == {"check_artifact_privacy.py", "check_explorer_compat.py"} + + +def test_a_raising_gate_is_recorded_as_a_failure(tmp_path: Path) -> None: + def boom(command: list[str], cwd: Path) -> tuple[int, str]: + raise RuntimeError("tool crashed") + + outcome = g.run_gates(_inputs(tmp_path), boom) + assert outcome["ok"] is False + assert "RuntimeError" in outcome["results"]["privacy"]["detail"] + + +def test_write_gates_emits_json_and_returns_its_digest(tmp_path: Path) -> None: + outcome = g.run_gates(_inputs(tmp_path), Recorder()) + target = tmp_path / "out" / "gates.json" + digest = g.write_gates(target, outcome) + loaded = json.loads(target.read_text(encoding="utf-8")) + assert loaded["ok"] is True + assert len(digest) == 64 diff --git a/tests/unit/scripts/site_deploy/test_site_deploy_generation.py b/tests/unit/scripts/site_deploy/test_site_deploy_generation.py new file mode 100644 index 0000000000..9af8f3b85c --- /dev/null +++ b/tests/unit/scripts/site_deploy/test_site_deploy_generation.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import dataclasses +import json +from dataclasses import dataclass, field + +import pytest + +from scripts.site_deploy import generation, receipt as receipt_module +from scripts.site_deploy.generation import DEPLOY, NOOP, REFUSE, Deployed +from tests.unit.scripts.site_deploy.site_deploy_fakes import SHA_A, SHA_B, SHA_C, FakeGitHub, make_receipt + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +ORDER = {SHA_A: 1, SHA_B: 2, SHA_C: 3} +SIDE = "d" * 40 + + +def ancestry(ancestor: str, descendant: str) -> bool: + if SIDE in (ancestor, descendant): + return ancestor == descendant + return ORDER[ancestor] < ORDER[descendant] + + +def deployed(trunk: str = SHA_B, tag: str = "v0.4.1", corpus: str = "1" * 40, generation_number: int = 4) -> Deployed: + return Deployed( + generation=generation_number, + trunk_sha=trunk, + release_tag=tag, + corpus_sha=corpus, + run_id=40, + receipt_sha256="e" * 64, + artifact_sha256="f" * 64, + ui_version=11, + snapshot_version=11, + ) + + +def gate( + trunk: str, tag: str = "v0.4.1", current: Deployed | None = None, mode: str = "deploy", corpus: str | None = None +): + return generation.generation_gate( + candidate_trunk=trunk, + candidate_tag=tag, + candidate_corpus=corpus, + deployed=current, + mode=mode, + is_ancestor=ancestry, + ) + + +def test_first_generation_deploys_and_first_rollback_is_refused() -> None: + assert gate(SHA_A).action == DEPLOY + assert gate(SHA_A, mode="rollback").action == REFUSE + + +FORWARD_CASES = [ + (SHA_C, "v0.4.1", DEPLOY), + (SHA_B, "v0.4.2", DEPLOY), + (SHA_C, "v0.5.0", DEPLOY), + (SHA_B, "v0.4.1", NOOP), + (SHA_A, "v0.4.1", REFUSE), + (SHA_C, "v0.4.0", REFUSE), + (SHA_A, "v0.5.0", REFUSE), + (SIDE, "v0.4.1", REFUSE), + (SHA_B, "v0.4.0", REFUSE), + (SHA_B, "v0.10.0", DEPLOY), + (SHA_B, "v0.3.9", REFUSE), +] + + +def test_forward_modes_never_regress_trunk_or_release() -> None: + for trunk, tag, expected in FORWARD_CASES: + for mode in ("deploy", "preview"): + assert gate(trunk, tag, deployed(), mode).action == expected + + +def test_identical_generation_redeploys_when_a_newer_deployment_carries_no_receipt() -> None: + unreceipted = dataclasses.replace(deployed(), newer_unreceipted=True) + assert gate(SHA_B, "v0.4.1", unreceipted).action == DEPLOY + assert gate(SHA_B, "v0.4.1", unreceipted, "rollback").action == DEPLOY + assert gate(SHA_B, "v0.4.1", deployed()).action == NOOP + + +def test_rollback_may_move_backwards_and_diverge_but_identical_state_is_noop() -> None: + assert gate(SHA_A, "v0.3.0", deployed(), "rollback").action == DEPLOY + assert gate(SIDE, "v0.4.1", deployed(), "rollback").action == DEPLOY + assert gate(SHA_B, "v0.4.1", deployed(), "rollback").action == NOOP + same_source_other_tree = generation.generation_gate( + candidate_trunk=SHA_B, + candidate_tag="v0.4.1", + candidate_corpus="1" * 40, + candidate_artifact="0" * 64, + deployed=deployed(), + mode="rollback", + is_ancestor=ancestry, + ) + assert same_source_other_tree.action == DEPLOY + + +def test_rollback_to_same_trunk_with_different_corpus_still_deploys() -> None: + assert gate(SHA_B, "v0.4.1", deployed(corpus="1" * 40), "rollback", corpus="2" * 40).action == DEPLOY + + +def test_unknown_ancestry_fails_closed() -> None: + def broken(ancestor: str, descendant: str) -> bool: + raise generation.CandidateError("missing object") + + decision = generation.generation_gate( + candidate_trunk=SHA_C, candidate_tag="v0.4.1", deployed=deployed(), mode="deploy", is_ancestor=broken + ) + assert decision.action == REFUSE + + +@dataclass +class Pages: + api: FakeGitHub + current: dict | None = None + clock: int = 0 + log: list[str] = field(default_factory=list) + + def read(self) -> Deployed | None: + return generation.read_deployed(self.api.client(), self.api.load_receipt, allow_bootstrap=True) + + def resolve(self, trunk: str, tag: str = "v0.4.1") -> tuple[str, str | None, generation.Decision]: + current = self.read() + decision = gate(trunk, tag, current) + return trunk, current.receipt_sha256 if current else None, decision + + def deploy( + self, run_id: int, trunk: str, resolved: tuple[str, str | None, generation.Decision], tag: str = "v0.4.1" + ) -> bool: + _, resolved_sha, decision = resolved + if decision.action != DEPLOY: + self.log.append(f"run {run_id} {decision.action}") + return False + current = self.read() + recheck = generation.recheck_decision( + resolved_receipt_sha256=resolved_sha, + deployed=current, + candidate_trunk=trunk, + candidate_tag=tag, + candidate_corpus=None, + mode="deploy", + is_ancestor=ancestry, + ) + if recheck.action != DEPLOY: + self.log.append(f"run {run_id} recheck {recheck.action}") + return False + parent = None + number = 1 + if current is not None: + parent = {"generation": current.generation} + number = current.generation + 1 + built = make_receipt(run_id=run_id, trunk=trunk, tag=tag, generation=number, parent=parent) + self.clock += 1 + self.api.record_deployment(run_id * 10, built, f"2026-01-01T00:00:{self.clock:02d}Z") + self.log.append(f"run {run_id} deployed {trunk[0]}") + return True + + +def test_older_then_newer_arrival_deploys_both_in_order() -> None: + pages = Pages(FakeGitHub()) + pages.deploy(1, SHA_A, pages.resolve(SHA_A)) + pages.deploy(2, SHA_B, pages.resolve(SHA_B)) + assert pages.read().trunk_sha == SHA_B + assert pages.read().generation == 2 + assert pages.log == ["run 1 deployed a", "run 2 deployed b"] + + +def test_newer_then_older_arrival_refuses_the_delayed_older_run() -> None: + pages = Pages(FakeGitHub()) + pages.deploy(2, SHA_B, pages.resolve(SHA_B)) + assert pages.deploy(1, SHA_A, pages.resolve(SHA_A)) is False + assert pages.read().trunk_sha == SHA_B + assert pages.log == ["run 2 deployed b", "run 1 refuse"] + + +def test_overlapping_runs_resolved_together_deploy_only_the_first_to_write() -> None: + pages = Pages(FakeGitHub()) + resolved_a = pages.resolve(SHA_A) + resolved_b = pages.resolve(SHA_B) + assert pages.deploy(2, SHA_B, resolved_b) is True + assert pages.deploy(1, SHA_A, resolved_a) is False + assert pages.read().trunk_sha == SHA_B + assert pages.log[-1] == "run 1 recheck refuse" + + +def test_overlapping_newer_run_resolved_before_older_wrote_is_rechecked_and_refused() -> None: + pages = Pages(FakeGitHub()) + resolved_a = pages.resolve(SHA_A) + resolved_b = pages.resolve(SHA_B) + assert pages.deploy(1, SHA_A, resolved_a) is True + assert pages.deploy(2, SHA_B, resolved_b) is False + assert pages.log[-1] == "run 2 recheck refuse" + retry = pages.resolve(SHA_B) + assert pages.deploy(3, SHA_B, retry) is True + assert pages.read().trunk_sha == SHA_B + + +def test_delayed_duplicate_of_the_deployed_generation_is_a_noop() -> None: + pages = Pages(FakeGitHub()) + pages.deploy(1, SHA_B, pages.resolve(SHA_B)) + assert pages.resolve(SHA_B)[2].action == NOOP + assert pages.deploy(2, SHA_B, pages.resolve(SHA_B)) is False + + +def test_read_deployed_uses_newest_successful_deployment_with_a_receipt() -> None: + api = FakeGitHub() + older = make_receipt(run_id=1, trunk=SHA_A) + newer = make_receipt(run_id=2, trunk=SHA_B, generation=2) + api.record_deployment(10, older, "2026-01-01T00:00:01Z") + api.record_deployment(20, newer, "2026-01-01T00:00:02Z") + api.deployments.append({"id": 30, "created_at": "2026-01-01T00:00:03Z"}) + api.statuses[30] = [{"state": "failure", "description": "x", "created_at": "2026-01-01T00:00:03Z"}] + found = generation.read_deployed(api.client(), api.load_receipt) + assert found is not None + assert (found.trunk_sha, found.generation, found.run_id) == (SHA_B, 2, 2) + + +def _legacy_deployment(api: FakeGitHub, deployment_id: int, created: str) -> None: + api.deployments.append({"id": deployment_id, "created_at": created}) + api.statuses[deployment_id] = [{"state": "success", "description": "legacy deploy", "created_at": created}] + + +def test_receiptless_newest_deployment_fails_closed_without_bootstrap() -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A), "2026-01-01T00:00:01Z") + _legacy_deployment(api, 20, "2026-01-01T00:00:02Z") + with pytest.raises(generation.GenerationAuthorityError, match="bootstrap"): + generation.read_deployed(api.client(), api.load_receipt) + + +def test_bootstrap_narrows_the_guard_to_the_newest_receipt_bearing_generation() -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_B, generation=4), "2026-01-01T00:00:01Z") + api.record_deployment(11, make_receipt(run_id=2, trunk=SHA_A, generation=3), "2026-01-01T00:00:00Z") + _legacy_deployment(api, 20, "2026-01-01T00:00:02Z") + found = generation.read_deployed(api.client(), api.load_receipt, allow_bootstrap=True) + assert found is not None + assert (found.trunk_sha, found.generation, found.newer_unreceipted) == (SHA_B, 4, True) + assert gate(SHA_A, current=found).action == REFUSE + assert gate(SHA_C, current=found).action == DEPLOY + + +def test_bootstrap_with_a_clean_history_does_not_flag_unreceipted_deployments() -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A), "2026-01-01T00:00:01Z") + found = generation.read_deployed(api.client(), api.load_receipt, allow_bootstrap=True) + assert found is not None and found.newer_unreceipted is False + + +def test_only_a_history_without_any_receipt_is_a_first_deploy() -> None: + api = FakeGitHub() + _legacy_deployment(api, 20, "2026-01-01T00:00:02Z") + with pytest.raises(generation.GenerationAuthorityError): + generation.read_deployed(api.client(), api.load_receipt) + assert generation.read_deployed(api.client(), api.load_receipt, allow_bootstrap=True) is None + + +def test_empty_history_needs_bootstrap() -> None: + api = FakeGitHub() + with pytest.raises(generation.GenerationAuthorityError): + generation.read_deployed(api.client(), api.load_receipt) + assert generation.read_deployed(api.client(), api.load_receipt, allow_bootstrap=True) is None + + +def test_link_baseline_is_read_from_the_receipt() -> None: + api = FakeGitHub() + built = make_receipt(run_id=1, trunk=SHA_A) + built["link_baseline"] = {"release_tag": "v0.4.1", "broken": 1, "links": [["/a", "/b", "missing path"]]} + api.record_deployment(10, built, "2026-01-01T00:00:01Z") + found = generation.read_deployed(api.client(), api.load_receipt) + assert found is not None and found.link_baseline == {"v0.4.1": [["/a", "/b", "missing path"]]} + + +def test_receipt_that_does_not_match_its_recorded_digest_is_rejected() -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A), "2026-01-01T00:00:01Z") + forged = json.loads(api.receipts[1]) + forged["trunk_sha"] = SHA_C + api.receipts[1] = receipt_module.canonical_bytes(forged) + with pytest.raises(generation.GenerationAuthorityError, match="does not match"): + generation.read_deployed(api.client(), api.load_receipt) + + +def test_missing_receipt_artifact_fails_closed() -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A), "2026-01-01T00:00:01Z") + + def missing(run_id: int, expected_sha256: str | None = None) -> bytes: + raise OSError("expired") + + with pytest.raises(generation.GenerationAuthorityError, match="unavailable"): + generation.read_deployed(api.client(), missing) + + +def test_receipt_with_failed_probes_blocks_deploys_but_not_rollbacks() -> None: + api = FakeGitHub() + api.record_deployment(10, make_receipt(run_id=1, trunk=SHA_A, probes_ok=False), "2026-01-01T00:00:01Z") + with pytest.raises(generation.GenerationAuthorityError, match="last-known-good"): + generation.read_deployed(api.client(), api.load_receipt) + found = generation.read_deployed(api.client(), api.load_receipt, require_good=False) + assert found is not None and found.trunk_sha == SHA_A + + +def test_api_failure_reading_deployments_fails_closed() -> None: + api = FakeGitHub(fail=True) + with pytest.raises(generation.GenerationAuthorityError): + generation.read_deployed(api.client(), api.load_receipt, allow_bootstrap=True) diff --git a/tests/unit/scripts/site_deploy/test_site_deploy_githubapi.py b/tests/unit/scripts/site_deploy/test_site_deploy_githubapi.py new file mode 100644 index 0000000000..8fc279741c --- /dev/null +++ b/tests/unit/scripts/site_deploy/test_site_deploy_githubapi.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import pytest + +from scripts.site_deploy import deployments +from scripts.site_deploy.githubapi import MAX_PAGES, ApiError +from tests.unit.scripts.site_deploy.site_deploy_fakes import FakeGitHub + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +def _rows(count: int) -> list[dict[str, object]]: + return [{"id": index, "created_at": f"2026-01-01T00:{index // 60:02d}:{index % 60:02d}Z"} for index in range(count)] + + +def test_get_list_follows_every_page() -> None: + api = FakeGitHub(deployments=_rows(250)) + assert len(api.client().get_list("deployments")) == 250 + + +def test_get_list_errors_instead_of_truncating_past_the_page_bound() -> None: + api = FakeGitHub(deployments=_rows(MAX_PAGES * 100 + 1)) + with pytest.raises(ApiError, match="truncated"): + api.client().get_list("deployments") + + +def test_get_first_returns_exactly_the_requested_window() -> None: + api = FakeGitHub(deployments=_rows(150)) + assert len(api.client().get_first("deployments", 100)) == 100 + assert len(api.client().get_first("deployments", 10)) == 10 + assert len(FakeGitHub(deployments=_rows(3)).client().get_first("deployments", 100)) == 3 + + +def test_deployment_scan_window_is_one_hundred_everywhere() -> None: + api = FakeGitHub(deployments=_rows(150)) + assert deployments.DEPLOYMENT_SCAN_LIMIT == 100 + assert len(deployments.scan_deployments(api.client())) == 100 diff --git a/tests/unit/scripts/site_deploy/test_site_deploy_mixed_version.py b/tests/unit/scripts/site_deploy/test_site_deploy_mixed_version.py new file mode 100644 index 0000000000..06cc11a23e --- /dev/null +++ b/tests/unit/scripts/site_deploy/test_site_deploy_mixed_version.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pathlib import Path + +import duckdb +import pytest + +from scripts.assemble_public_site import REPO_ROOT +from scripts.site_deploy import mixed_version as mv +from scripts.site_deploy.mixed_version import FORWARD, PHASE_FULL, PHASE_UI_FIRST, ROLLBACK, Versions + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + + +def _snapshot(path: Path, version: int | None) -> Path: + with duckdb.connect(str(path)) as connection: + if version is None: + connection.execute("CREATE TABLE other(x INTEGER)") + else: + connection.execute("CREATE TABLE metadata(read_model_version INTEGER)") + connection.execute("INSERT INTO metadata VALUES (?)", [version]) + return path + + +def test_ui_expected_version_is_read_from_the_real_explorer_source() -> None: + assert mv.ui_expected_from_tree(REPO_ROOT) >= 1 + + +def test_ui_expected_version_requires_exactly_one_constant() -> None: + assert mv.parse_ui_expected("const EXPECTED_READ_MODEL_VERSION = 12;\n") == 12 + with pytest.raises(mv.VersionError): + mv.parse_ui_expected("const OTHER = 1;") + with pytest.raises(mv.VersionError): + mv.parse_ui_expected("const EXPECTED_READ_MODEL_VERSION = 1;\nconst EXPECTED_READ_MODEL_VERSION = 2;\n") + + +def test_snapshot_version_reads_the_metadata_row(tmp_path: Path) -> None: + assert mv.snapshot_version(_snapshot(tmp_path / "a.duckdb", 11)) == 11 + with pytest.raises(mv.VersionError): + mv.snapshot_version(_snapshot(tmp_path / "b.duckdb", None)) + with pytest.raises(mv.VersionError): + mv.snapshot_version(tmp_path / "missing.duckdb") + + +@pytest.mark.parametrize( + ("candidate", "current", "ok"), + [ + (Versions(11, 11), Versions(11, 11), True), + (Versions(11, 12), Versions(11, 11), True), + (Versions(12, 12), Versions(11, 11), True), + (Versions(12, 11), Versions(11, 11), False), + (Versions(11, 10), Versions(11, 11), False), + (Versions(11, 11), Versions(12, 12), False), + (Versions(11, 11), None, True), + (Versions(12, 11), None, False), + ], +) +def test_forward_deploys_need_snapshot_at_least_ui_for_every_pair( + candidate: Versions, current: Versions | None, ok: bool +) -> None: + evaluation = mv.evaluate(candidate, current, FORWARD) + assert evaluation.ok is ok + assert all(pair.ok for pair in evaluation.pairs) is ok + + +def test_forward_deploy_checks_the_old_ui_against_the_new_snapshot_and_the_new_pair() -> None: + names = {pair.name for pair in mv.required_pairs(Versions(12, 12), Versions(11, 11), FORWARD)} + assert names == {"candidate-ui/candidate-snapshot", "deployed-ui/candidate-snapshot"} + + +def test_rollback_to_a_generation_with_an_older_snapshot_restores_the_ui_first() -> None: + restored = Versions(ui=11, snapshot=11) + current = Versions(ui=12, snapshot=12) + full = mv.evaluate(restored, current, ROLLBACK, PHASE_FULL) + assert full.ok is False + assert full.plan == [PHASE_UI_FIRST, PHASE_FULL] + ui_first = mv.evaluate(restored, current, ROLLBACK, PHASE_UI_FIRST) + assert ui_first.ok is True + + +def test_rollback_without_a_snapshot_regression_is_a_single_step() -> None: + evaluation = mv.evaluate(Versions(11, 12), Versions(12, 12), ROLLBACK, PHASE_FULL) + assert evaluation.ok is True + assert evaluation.plan == [PHASE_FULL] + + +def test_rollback_refuses_when_no_order_keeps_the_snapshot_at_least_the_ui() -> None: + evaluation = mv.evaluate(Versions(ui=13, snapshot=13), Versions(ui=12, snapshot=12), ROLLBACK, PHASE_UI_FIRST) + assert evaluation.ok is False + broken = mv.evaluate(Versions(ui=13, snapshot=11), Versions(ui=12, snapshot=12), ROLLBACK, PHASE_FULL) + assert broken.ok is False + assert broken.plan == [] + + +def test_rollback_refuses_an_artifact_pairing_its_ui_with_an_older_snapshot() -> None: + assert mv.evaluate(Versions(12, 11), Versions(12, 12), ROLLBACK).ok is False + + +def test_ui_first_pairs_describe_the_composed_artifact_not_a_restored_snapshot() -> None: + restored = Versions(ui=11, snapshot=11) + current = Versions(ui=12, snapshot=12) + names = [pair.name for pair in mv.required_pairs(restored, current, ROLLBACK, PHASE_UI_FIRST)] + assert names == ["restored-ui/composed-snapshot", "restored-ui/current-snapshot", "current-ui/composed-snapshot"] + full = [pair.name for pair in mv.required_pairs(restored, current, ROLLBACK, PHASE_FULL)] + assert full == ["candidate-ui/candidate-snapshot", "restored-ui/current-snapshot", "current-ui/restored-snapshot"] + evaluation = mv.evaluate(restored, current, ROLLBACK, PHASE_UI_FIRST) + assert [pair.name for pair in evaluation.pairs] == names diff --git a/tests/unit/scripts/site_deploy/test_site_deploy_publish.py b/tests/unit/scripts/site_deploy/test_site_deploy_publish.py new file mode 100644 index 0000000000..7c7685a3ae --- /dev/null +++ b/tests/unit/scripts/site_deploy/test_site_deploy_publish.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +from scripts.publication.assembler import compute_tree_digest +from scripts.site_deploy import artifacts, checksums, deployments, parity, probe, publish, receipt, rollback +from tests.unit.scripts.site_deploy.site_deploy_fakes import SHA_A, FakeGitHub, make_receipt + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + +ROUTES = ["/", "/docs/", "/docs/dev/", "/blog/", "/results/"] +FALLBACK = "" + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _site(root: Path, label: str, *, dev: bool) -> Path: + _write(root / "index.html", f"{label} landing") + _write(root / "docs" / "index.html", f"{label} docs") + _write(root / "docs" / "guide.html", f"{label} guide") + _write(root / "blog" / "index.html", f"{label} blog") + _write(root / "results" / "index.html", f"{label} explorer") + _write(root / "results" / "data" / "results.duckdb", f"{label} snapshot") + _write(root / "404.html", FALLBACK) + _write(root / ".nojekyll", "") + if dev: + _write(root / "docs" / "dev" / "index.html", f"{label} dev docs") + return root + + +def _fetch(url: str) -> tuple[int, bytes]: + try: + with urllib.request.urlopen(url, timeout=5) as response: + return response.status, response.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + + +def test_checksum_manifest_maps_served_urls_to_file_hashes(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "b", dev=True) + manifest = checksums.checksum_manifest(site, ROUTES) + _, _, files = compute_tree_digest(site) + assert manifest["/"] == files["index.html"] + assert manifest["/docs/dev/"] == files["docs/dev/index.html"] + assert manifest["/results/data/results.duckdb"] == files["results/data/results.duckdb"] + assert "/docs/guide.html" in manifest + + +def test_checksum_manifest_fails_when_a_route_is_not_served(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "a", dev=False) + with pytest.raises(ValueError, match="/docs/dev/"): + checksums.checksum_manifest(site, ROUTES) + + +def test_local_target_emulates_pages_index_redirect_and_404_fallback(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "b", dev=True) + with publish.running_target(tmp_path / "slot") as target: + target.publish(site) + assert _fetch(target.base_url + "/") == (200, b"b landing") + assert _fetch(target.base_url + "/docs/") == (200, b"b docs") + assert _fetch(target.base_url + "/docs/guide") == (200, b"b guide") + status, body = _fetch(target.base_url + "/results/deep/link") + assert status == 404 + assert b"benchbox.results.redirect" in body + assert _fetch(target.base_url + "/../../etc/passwd")[0] == 404 + + +def test_probe_matches_served_bytes_and_flags_a_changed_route(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "b", dev=True) + manifest = checksums.checksum_manifest(site, ROUTES) + with publish.running_target(tmp_path / "slot") as target: + target.publish(site) + good = probe.probe(target.base_url, manifest, tmp_path / "probe", attempts=1, delay=0) + assert good["ok"] is True + assert good["matched"] == len(manifest) + assert good["deep_link"]["ok"] is True + (target.slot_dir / "docs" / "index.html").write_text("tampered", encoding="utf-8") + bad = probe.probe(target.base_url, manifest, tmp_path / "probe2", attempts=2, delay=0, sleep=lambda _: None) + assert bad["ok"] is False + assert "/docs/" in bad["mismatched"] + assert bad["attempts"] == 2 + + +def test_probe_retries_a_stale_deep_link_fallback_with_the_checksums( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + deep_links = iter([{"ok": False, "status": 404, "detail": "404 fallback body missing"}, {"ok": True}]) + monkeypatch.setattr(probe, "run_verify_live", lambda *_: {"ok": True, "matched_checksums": {"/": "a" * 64}}) + monkeypatch.setattr(probe, "check_deep_link", lambda *_: next(deep_links)) + result = probe.probe("http://example.invalid", {"/": "a" * 64}, tmp_path, attempts=3, delay=0, sleep=lambda _: None) + assert result["ok"] is True + assert result["attempts"] == 2 + + +def test_probe_fails_when_the_target_is_unreachable(tmp_path: Path) -> None: + result = probe.probe("http://127.0.0.1:9", {"/": "a" * 64}, tmp_path, attempts=1, delay=0, timeout=1) + assert result["ok"] is False + + +def _drill(tmp_path: Path) -> dict: + legacy = _site(tmp_path / "legacy", "a", dev=False) + routes = _site(tmp_path / "routes", "b", dev=True) + digest, size, manifest = compute_tree_digest(routes) + assembly = { + "routes": [{"path": path, "source_sha": SHA_A} for path in ROUTES], + "tree_sha256": digest, + "total_bytes": size, + "total_files": len(manifest), + } + return publish.run_drill( + legacy_tree=legacy, + routes_tree=routes, + route_paths=ROUTES, + trunk_sha=SHA_A, + release_tag="v0.4.1", + release_sha=SHA_A, + corpus_sha="1" * 40, + routes_assembly=assembly, + versions={"ui_expected": 11, "snapshot": 11}, + gates={"ok": True, "results": {"privacy": {"status": "pass"}}}, + work_dir=tmp_path / "work", + receipts_dir=tmp_path / "receipts", + ) + + +def test_rollback_drill_restores_the_exact_first_generation(tmp_path: Path) -> None: + drill = _drill(tmp_path) + assert drill["ok"] is True + assert drill["restored_digest_equals_a"] is True + assert [step["step"] for step in drill["steps"]] == [ + "publish A (legacy single-ref assembly)", + "publish B (route build)", + "rollback to A by receipt", + ] + assert drill["steps"][0]["tree_sha256"] == drill["steps"][2]["tree_sha256"] != drill["steps"][1]["tree_sha256"] + names = sorted(path.name for path in (tmp_path / "receipts").iterdir()) + assert names == ["receipt-a.json", "receipt-b.json", "receipt-rollback.json"] + rollback_receipt = json.loads((tmp_path / "receipts" / "receipt-rollback.json").read_text(encoding="utf-8")) + assert rollback_receipt["mode"] == "rollback" + assert rollback_receipt["parent"]["generation"] == 2 + assert rollback_receipt["rollback"]["phase"] == "full" + b_receipt = json.loads((tmp_path / "receipts" / "receipt-b.json").read_text(encoding="utf-8")) + assert b_receipt["parent"]["run_id"] == 1 + + +def test_load_restore_verifies_the_artifact_against_the_receipt(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "a", dev=False) + digest, _, _ = compute_tree_digest(site) + built = make_receipt(run_id=3, trunk=SHA_A) + built["artifact"]["sha256"] = digest + built["target"] = "local-dir" + path = tmp_path / "receipt.json" + path.write_bytes(receipt.canonical_bytes(built)) + restored = rollback.load_restore(path, site, target="local-dir") + assert restored.receipt["run_id"] == 3 + (site / "docs" / "index.html").write_text("changed", encoding="utf-8") + with pytest.raises(rollback.RollbackError, match="does not match"): + rollback.load_restore(path, site, target="local-dir") + + +def test_load_restore_refuses_receipts_that_were_not_last_known_good(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "a", dev=False) + digest, _, _ = compute_tree_digest(site) + built = make_receipt(run_id=3, trunk=SHA_A, probes_ok=False) + built["artifact"]["sha256"] = digest + path = tmp_path / "receipt.json" + path.write_bytes(receipt.canonical_bytes(built)) + with pytest.raises(rollback.RollbackError, match="last-known-good"): + rollback.load_restore(path, site) + with pytest.raises(rollback.RollbackError, match="missing"): + rollback.load_restore(tmp_path / "none.json", site) + path.write_text("{not json", encoding="utf-8") + with pytest.raises(rollback.RollbackError, match="invalid"): + rollback.load_restore(path, site) + + +def test_receipt_must_be_recorded_on_a_successful_deployment_status() -> None: + api = FakeGitHub() + built = make_receipt(run_id=3, trunk=SHA_A) + sha = api.record_deployment(30, built, "2026-01-01T00:00:01Z") + restore = rollback.Restore(receipt=built, receipt_sha256=sha, tree=Path(".")) + assert rollback.recorded_in_deployments([api.statuses[30]], restore) is True + forged = rollback.Restore(receipt=built, receipt_sha256="0" * 64, tree=Path(".")) + assert rollback.recorded_in_deployments([api.statuses[30]], forged) is False + + +def test_verify_recorded_scans_deployment_history() -> None: + api = FakeGitHub() + built = make_receipt(run_id=3, trunk=SHA_A) + sha = api.record_deployment(30, built, "2026-01-01T00:00:01Z") + rollback.verify_recorded(api.client(), sha, 3) + with pytest.raises(rollback.RollbackError, match="not recorded"): + rollback.verify_recorded(api.client(), "0" * 64, 3) + with pytest.raises(rollback.RollbackError, match="unreadable"): + rollback.verify_recorded(FakeGitHub(fail=True).client(), sha, 3) + + +def test_ui_first_artifact_keeps_the_current_snapshot_and_restores_the_ui(tmp_path: Path) -> None: + current = _site(tmp_path / "current", "new", dev=True) + _write(current / "results" / "assets" / "app-new.js", "new ui") + restored = _site(tmp_path / "restored", "old", dev=False) + _write(restored / "results" / "assets" / "app-old.js", "old ui") + _write(restored / "404.html", "old fallback") + out = tmp_path / "composed" + digest = artifacts.compose_ui_first(current, restored, out) + assert (out / "404.html").read_text(encoding="utf-8") == "old fallback" + assert (out / "results" / "index.html").read_text(encoding="utf-8") == "old explorer" + assert (out / "results" / "assets" / "app-old.js").is_file() + assert not (out / "results" / "assets" / "app-new.js").exists() + assert (out / "results" / "data" / "results.duckdb").read_text(encoding="utf-8") == "new snapshot" + assert (out / "docs" / "dev" / "index.html").read_text(encoding="utf-8") == "new dev docs" + assert digest == compute_tree_digest(out)[0] + + +def test_ui_first_refuses_a_restored_artifact_without_the_fallback(tmp_path: Path) -> None: + current = _site(tmp_path / "current", "new", dev=True) + restored = _site(tmp_path / "restored", "old", dev=False) + (restored / "404.html").unlink() + with pytest.raises(artifacts.ArtifactError, match="deep-link fallback"): + artifacts.compose_ui_first(current, restored, tmp_path / "composed") + + +def test_verify_tree_rejects_a_digest_mismatch(tmp_path: Path) -> None: + site = _site(tmp_path / "site", "a", dev=False) + with pytest.raises(artifacts.ArtifactError): + artifacts.verify_tree(site, "0" * 64) + with pytest.raises(artifacts.ArtifactError): + artifacts.verify_tree(tmp_path / "missing", "0" * 64) + + +def test_parity_report_explains_added_changed_and_removed_files_per_route(tmp_path: Path) -> None: + legacy = _site(tmp_path / "legacy", "a", dev=False) + routes = _site(tmp_path / "routes", "b", dev=True) + (legacy / "extra.txt").write_text("gone", encoding="utf-8") + report = parity.classify(legacy, routes) + assert report["added"] == 1 + assert report["removed"] == 1 + assert report["by_route"]["/docs/dev/"]["added"] == 1 + assert "new route" in report["by_route"]["/docs/dev/"]["explanation"] + assert report["by_route"]["/docs/"]["changed"] == 2 + assert report["identical_files"] == 2 + + +def _linked_deployment(api: FakeGitHub, deployment_id: int, created: str, link: str | None) -> None: + api.deployments.append({"id": deployment_id, "created_at": created}) + api.statuses[deployment_id] = [{"state": "success", "log_url": link, "created_at": created}] + + +def test_deployment_lookup_binds_to_the_run_link_not_the_clock() -> None: + api = FakeGitHub() + _linked_deployment(api, 1, "2026-01-01T00:00:00Z", "https://github.com/owner/repo/actions/runs/77/job/1") + _linked_deployment(api, 2, "2026-01-02T00:00:00Z", "https://github.com/owner/repo/actions/runs/770") + _linked_deployment(api, 3, "2026-01-03T00:00:00Z", None) + assert deployments.find_run_deployment_id(api.client(), 77) == 1 + assert deployments.find_run_deployment_id(api.client(), 770) == 2 + with pytest.raises(deployments.DeploymentLookupError, match="links to run 7"): + deployments.find_run_deployment_id(api.client(), 7) + + +def test_deployment_lookup_survives_a_rerun_of_the_failed_jobs() -> None: + api = FakeGitHub() + _linked_deployment(api, 1, "2026-01-01T00:00:00Z", "https://github.com/owner/repo/actions/runs/77") + _linked_deployment(api, 2, "2030-01-01T00:00:00Z", "https://github.com/owner/repo/actions/runs/88") + assert deployments.find_run_deployment_id(api.client(), 77) == 1 + + +def test_deployment_lookup_fails_closed_on_api_errors() -> None: + with pytest.raises(deployments.DeploymentLookupError, match="unreadable"): + deployments.find_run_deployment_id(FakeGitHub(fail=True).client(), 77) + + +def test_newest_deployment_id() -> None: + api = FakeGitHub() + assert deployments.newest_deployment_id(api.client()) is None + _linked_deployment(api, 1, "2026-01-01T00:00:00Z", None) + _linked_deployment(api, 2, "2026-01-02T00:00:00Z", None) + assert deployments.newest_deployment_id(api.client()) == 2 + + +def test_post_receipt_status_records_a_non_inactivating_success() -> None: + api = FakeGitHub() + api.deployments = [{"id": 2, "created_at": "2026-01-02T00:00:00Z"}] + deployments.post_receipt_status(api.client(), 2, "a" * 64, 77, "https://log", "https://site") + posted = api.statuses[2][-1] + assert posted["state"] == "success" + assert posted["description"] == receipt.status_description("a" * 64, 77) + assert posted["auto_inactive"] is False + with pytest.raises(deployments.DeploymentLookupError, match="not recorded"): + deployments.post_receipt_status(FakeGitHub(fail=True).client(), 2, "a" * 64, 77, "l", "u") + + +def test_recorded_shas_lists_newest_first_for_the_run() -> None: + api = FakeGitHub() + first = api.record_deployment(30, make_receipt(run_id=3, trunk=SHA_A), "2026-01-01T00:00:01Z") + api.statuses[30].append( + { + "state": "success", + "description": receipt.status_description("b" * 64, 3), + "created_at": "2026-01-01T00:00:09Z", + } + ) + assert rollback.recorded_shas(api.client(), 3) == ["b" * 64, first] + assert rollback.recorded_shas(api.client(), 4) == [] diff --git a/tests/unit/scripts/site_deploy/test_site_deploy_receipt.py b/tests/unit/scripts/site_deploy/test_site_deploy_receipt.py new file mode 100644 index 0000000000..054a5a762d --- /dev/null +++ b/tests/unit/scripts/site_deploy/test_site_deploy_receipt.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import copy + +import pytest + +from scripts.site_deploy import receipt as r +from tests.unit.scripts.site_deploy.site_deploy_fakes import SHA_A, make_receipt + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +def test_receipt_records_every_required_field() -> None: + built = make_receipt(run_id=5, trunk=SHA_A) + assert r.validate_receipt(built) is built + assert built["schema"] == "site-deploy-receipt/v1" + for key in ("deployment_id", "run_id", "routes", "corpus_sha", "parent", "gates", "probes", "artifact"): + assert key in built + assert built["artifact"]["sha256"] == "f" * 64 + assert built["gates"]["results"] == {"privacy": "pass"} + assert len(built["rollback_order"]) == 3 + + +def test_generation_follows_the_parent() -> None: + parent = {"generation": 6} + child = make_receipt(run_id=2, trunk=SHA_A, parent=parent) + assert child["generation"] == 1 + from_builder = r.build_receipt( + mode="deploy", + target="github-pages", + run_id=9, + deployment_id=1, + trunk_sha=SHA_A, + release_tag="v0.4.1", + release_sha=SHA_A, + corpus_sha=SHA_A, + assembly={"routes": [], "tree_sha256": "a" * 64, "total_bytes": 1, "total_files": 1}, + artifact_name="x", + versions={}, + gates={"ok": True, "results": {}}, + probes={"ok": True}, + parent=parent, + certifying_run_id=None, + ) + assert from_builder["generation"] == 7 + + +def test_status_description_round_trips_and_stays_within_the_github_limit() -> None: + sha = "ab" * 32 + description = r.status_description(sha, 123456789012) + assert len(description) <= 140 + assert r.parse_status_description(description) == (sha, 123456789012) + assert r.parse_status_description("legacy deploy") is None + assert r.parse_status_description(description + " ") is None + + +def test_canonical_bytes_are_stable_and_hash_matches() -> None: + built = make_receipt(run_id=5, trunk=SHA_A) + assert r.canonical_bytes(built) == r.canonical_bytes(copy.deepcopy(built)) + assert len(r.receipt_sha256(r.canonical_bytes(built))) == 64 + + +def test_validation_rejects_every_missing_required_key() -> None: + for key in r.REQUIRED_KEYS: + broken = make_receipt(run_id=1, trunk=SHA_A) + broken.pop(key) + with pytest.raises(r.ReceiptError): + r.validate_receipt(broken) + + +def test_validation_rejects_bad_digest_schema_and_shas() -> None: + for mutate in ( + lambda d: d.update(schema="other"), + lambda d: d["artifact"].update(sha256="short"), + lambda d: d.update(trunk_sha="abc"), + lambda d: d.update(generation=0), + lambda d: d["probes"].pop("ok"), + ): + broken = make_receipt(run_id=1, trunk=SHA_A) + mutate(broken) + with pytest.raises(r.ReceiptError): + r.validate_receipt(broken) + + +def test_last_known_good_requires_passing_probes_gates_and_matching_target() -> None: + good = make_receipt(run_id=1, trunk=SHA_A) + assert r.is_last_known_good(good) + assert not r.is_last_known_good(make_receipt(run_id=1, trunk=SHA_A, probes_ok=False)) + assert not r.is_last_known_good(good, target="local-dir") + bad_gates = make_receipt(run_id=1, trunk=SHA_A) + bad_gates["gates"]["ok"] = False + assert not r.is_last_known_good(bad_gates) diff --git a/tests/unit/scripts/site_deploy/test_site_deploy_routes.py b/tests/unit/scripts/site_deploy/test_site_deploy_routes.py new file mode 100644 index 0000000000..4169e2ac56 --- /dev/null +++ b/tests/unit/scripts/site_deploy/test_site_deploy_routes.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +from scripts.assemble_public_site import REPO_ROOT, assemble_public_site, main +from scripts.publication.assembler import PathOwnershipError +from scripts.site_deploy import routes + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + +MANIFEST = REPO_ROOT / "deploy" / "routes.yml" + + +def _write(path: Path, content: str = "content") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _checkout(root: Path, label: str, *, explorer: bool, extra_docs: tuple[str, ...] = ()) -> Path: + _write(root / "docs" / "_build" / "html" / "index.html", f"{label} docs") + _write(root / "docs" / "_build" / "html" / "guide" / "index.html", f"{label} guide") + _write(root / "docs" / "_build" / "html" / "blog" / "post.html", f"{label} blog") + _write(root / "docs" / "_build" / "html" / "_static" / "theme.css", f"{label} css") + _write(root / "docs" / "_build" / "html" / "_images" / "logo.svg", f"{label} svg") + for name in extra_docs: + _write(root / "docs" / "_build" / "html" / name, label) + _write(root / "docs" / "CNAME", "benchbox.dev\n") + _write(root / "landing" / "index.html", f"{label} landing") + _write(root / "landing" / "prompts" / "docs" / "nested.txt", "landing asset in a docs folder") + if explorer: + _write(root / "results-explorer" / "package.json", "{}") + _write(root / "results-explorer" / "dist" / "index.html", f"{label} explorer") + _write(root / "results-explorer" / "dist" / "data" / "results.duckdb", f"{label} snapshot") + return root + + +def _git_init(root: Path) -> str: + env = ["-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"] + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(root), *env, "commit", "-q", "-m", "fixture"], check=True) + return subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ).stdout.strip() + + +def _read(site: Path, relative: str) -> str: + return (site / relative).read_text(encoding="utf-8") + + +def test_committed_manifest_declares_the_decided_routes() -> None: + manifest = routes.load_manifest(MANIFEST) + by_path = {route.path: (route.ref, route.builder) for route in manifest.routes} + assert by_path == { + "/": ("release", "landing"), + "/docs/": ("release", "docs"), + "/docs/dev/": ("trunk", "docs"), + "/blog/": ("trunk", "blog"), + "/results/": ("trunk", "explorer"), + } + assert manifest.refs == {"release": "release-tag", "trunk": "trunk"} + assert manifest.root_files_ref == "trunk" + + +def test_manifest_is_tracked_not_ignored() -> None: + manifest = subprocess.run(["git", "-C", str(REPO_ROOT), "check-ignore", "-q", "deploy/routes.yml"], check=False) + assert manifest.returncode == 1 + legacy = subprocess.run(["git", "-C", str(REPO_ROOT), "check-ignore", "-q", "site/index.html"], check=False) + assert legacy.returncode == 0 + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + (lambda d: d.update(version=2), "unsupported"), + (lambda d: d["routes"][0].update(ref="nope"), "unknown ref"), + (lambda d: d["routes"][0].update(builder="nope"), "unknown builder"), + (lambda d: d["routes"][0].update(path="docs"), "slash-terminated"), + (lambda d: d["routes"][1].update(path="/"), "twice"), + (lambda d: d["refs"]["trunk"].update(kind="branch"), "unsupported kind"), + (lambda d: d["root_files"].update(files=["secrets.txt"]), "unsupported files"), + (lambda d: d.update(routes=[]), "no routes"), + ], +) +def test_manifest_validation_rejects_malformed_input(mutate, message: str) -> None: + data = yaml.safe_load(MANIFEST.read_text(encoding="utf-8")) + mutate(data) + with pytest.raises(routes.RouteManifestError, match=message): + routes.parse_manifest(data) + + +def test_routes_mode_mounts_each_route_from_its_own_ref(tmp_path: Path) -> None: + release = _checkout(tmp_path / "release", "release", explorer=False) + trunk = _checkout(tmp_path / "trunk", "trunk", explorer=True) + release_sha = _git_init(release) + trunk_sha = _git_init(trunk) + site = tmp_path / "site-build" + receipt_path = tmp_path / "receipt.json" + + exit_code = main( + [ + "--routes", + str(MANIFEST), + "--ref-root", + f"release={release}", + "--ref-root", + f"trunk={trunk}", + "--site-dir", + str(site), + "--work-dir", + str(tmp_path / "work"), + "--receipt-out", + str(receipt_path), + ] + ) + + assert exit_code == 0 + assert _read(site, "index.html") == "release landing" + assert _read(site, "prompts/docs/nested.txt") == "landing asset in a docs folder" + assert _read(site, "docs/index.html") == "release docs" + assert _read(site, "docs/guide/index.html") == "release guide" + assert _read(site, "docs/_static/theme.css") == "release css" + assert _read(site, "docs/dev/index.html") == "trunk docs" + assert _read(site, "docs/dev/_static/theme.css") == "trunk css" + assert _read(site, "blog/post.html") == "trunk blog" + assert _read(site, "_static/theme.css") == "trunk css" + assert _read(site, "_images/logo.svg") == "trunk svg" + assert _read(site, "results/index.html") == "trunk explorer" + assert _read(site, "results/data/results.duckdb") == "trunk snapshot" + assert (site / "CNAME").read_text(encoding="utf-8") == "benchbox.dev\n" + assert (site / ".nojekyll").is_file() + assert "benchbox.results.redirect" in _read(site, "404.html") + + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + sources = {route["path"]: route["source_sha"] for route in receipt["routes"]} + assert sources == { + "/": release_sha, + "/docs/": release_sha, + "/docs/dev/": trunk_sha, + "/blog/": trunk_sha, + "/results/": trunk_sha, + } + assert receipt["refs"]["release"]["kind"] == "release-tag" + assert len(receipt["tree_sha256"]) == 64 + owners = receipt["file_owners"] + assert owners["_static/theme.css"].startswith("/blog/") + assert owners["docs/_static/theme.css"].startswith("/docs/:") + assert owners["docs/dev/_static/theme.css"].startswith("/docs/dev/:") + assert len(owners) == receipt["total_files"] + + +def test_assets_of_the_release_build_never_claim_root_static_paths(tmp_path: Path) -> None: + release = _checkout(tmp_path / "release", "release", explorer=False) + trunk = _checkout(tmp_path / "trunk", "trunk", explorer=True) + manifest = routes.load_manifest(MANIFEST) + summary = routes.assemble_routes( + manifest=manifest, + ref_roots={"release": release, "trunk": trunk}, + site_dir=tmp_path / "out", + work_dir=tmp_path / "work", + stage_builder=assemble_public_site, + resolve_sha=lambda root: root.name, + ) + static_owners = {lane for path, lane in summary["file_owners"].items() if path.startswith(("_static/", "_images/"))} + assert static_owners == {"/blog/:_static", "/blog/:_images"} + + +def test_path_collision_between_release_and_trunk_docs_is_refused(tmp_path: Path) -> None: + release = _checkout(tmp_path / "release", "release", explorer=False, extra_docs=("dev/index.html",)) + trunk = _checkout(tmp_path / "trunk", "trunk", explorer=True) + with pytest.raises(PathOwnershipError, match="docs/dev/index.html"): + routes.assemble_routes( + manifest=routes.load_manifest(MANIFEST), + ref_roots={"release": release, "trunk": trunk}, + site_dir=tmp_path / "out", + work_dir=tmp_path / "work", + stage_builder=assemble_public_site, + resolve_sha=lambda root: root.name, + ) + + +def test_ref_roots_must_match_manifest_refs(tmp_path: Path) -> None: + trunk = _checkout(tmp_path / "trunk", "trunk", explorer=True) + with pytest.raises(routes.RouteManifestError, match="do not match"): + routes.assemble_routes( + manifest=routes.load_manifest(MANIFEST), + ref_roots={"trunk": trunk}, + site_dir=tmp_path / "out", + work_dir=tmp_path / "work", + stage_builder=assemble_public_site, + ) + + +def test_release_without_landing_fails_closed_for_the_landing_route(tmp_path: Path) -> None: + release = _checkout(tmp_path / "release", "release", explorer=False) + trunk = _checkout(tmp_path / "trunk", "trunk", explorer=True) + shutil.rmtree(release / "landing") + with pytest.raises(routes.RouteManifestError, match="needs landing"): + routes.assemble_routes( + manifest=routes.load_manifest(MANIFEST), + ref_roots={"release": release, "trunk": trunk}, + site_dir=tmp_path / "out", + work_dir=tmp_path / "work", + stage_builder=assemble_public_site, + resolve_sha=lambda root: root.name, + ) + + +def test_routes_mode_refuses_the_manifest_directory(tmp_path: Path) -> None: + with pytest.raises(SystemExit, match="refuses --site-dir site"): + main( + [ + "--routes", + str(MANIFEST), + "--ref-root", + "release=x", + "--ref-root", + "trunk=y", + "--site-dir", + str(REPO_ROOT / "site"), + ] + ) + + +@pytest.mark.parametrize("flag", [["--prose-only"], ["--repo-root", "elsewhere"]]) +def test_routes_mode_rejects_single_ref_flags(tmp_path: Path, flag: list[str]) -> None: + with pytest.raises(SystemExit, match="rejects --prose-only and --repo-root"): + main( + [ + "--routes", + str(MANIFEST), + "--ref-root", + "release=x", + "--ref-root", + "trunk=y", + "--site-dir", + str(tmp_path / "out"), + *flag, + ] + ) + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("/", "release"), + ("/index.html", "release"), + ("/docs/index.html", "release"), + ("/docs", "release"), + ("/docs/dev/index.html", "trunk"), + ("/docs/dev/x.html#frag", "trunk"), + ("/blog/post/", "trunk"), + ("/results/data/results.duckdb", "trunk"), + ("/_static/site.css", "trunk"), + ("/_images/a.png", "trunk"), + ("/404.html", "trunk"), + ("/CNAME", "trunk"), + ("/usage/x.html", "release"), + ], +) +def test_ownership_is_derived_from_the_manifest(path: str, expected: str) -> None: + manifest = routes.load_manifest(MANIFEST) + assert routes.owner_ref(manifest, path) == expected + assert routes.is_trunk_owned(manifest, path) is (expected == "trunk") diff --git a/tests/unit/scripts/test_api_reference_url_map.py b/tests/unit/scripts/test_api_reference_url_map.py new file mode 100644 index 0000000000..fe16b8fe66 --- /dev/null +++ b/tests/unit/scripts/test_api_reference_url_map.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts import api_reference_url_map as tool + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +PAGE = """
+

Widgets

+ +
Widget
run
run
+
Gone
size
size
+
""" + + +@pytest.fixture +def tree(tmp_path: Path) -> dict[str, Path]: + docs = tmp_path / "docs" + (docs / "reference" / "python-api").mkdir(parents=True) + (docs / "api.rst").write_text("API\n===\n\n.. automodule:: benchbox\n", encoding="utf-8") + (docs / "reference" / "python-api" / "thing.rst").write_text( + "Thing\n=====\n\n.. method:: run()\n", encoding="utf-8" + ) + (docs / "reference" / "python-api" / "prose.rst").write_text("Prose\n=====\n\nNo autodoc.\n", encoding="utf-8") + (docs / "guide.rst").write_text(".. autoclass:: benchbox.x\n", encoding="utf-8") + html = tmp_path / "html" + (html / "reference" / "python-api").mkdir(parents=True) + (html / "api.html").write_text(PAGE, encoding="utf-8") + (html / "reference" / "python-api" / "thing.html").write_text(PAGE, encoding="utf-8") + symbols = tmp_path / "symbols.json" + symbols.write_text( + json.dumps( + { + "dropped": ["benchbox.old.Gone"], + "symbols": [{"symbol": "benchbox.thing.Widget", "aliases": []}], + } + ), + encoding="utf-8", + ) + return {"docs": docs, "html": html, "symbols": symbols, "out": tmp_path / "map.json"} + + +def _run(tree: dict[str, Path]) -> dict: + argv = [ + "--html", + str(tree["html"]), + "--docs", + str(tree["docs"]), + "--symbols", + str(tree["symbols"]), + "--out", + str(tree["out"]), + "--source-sha", + "abc123", + ] + assert tool.main(argv) == 0 + return json.loads(tree["out"].read_text(encoding="utf-8")) + + +def test_page_discovery_uses_autodoc_and_directive_lines(tree: dict[str, Path]) -> None: + data = _run(tree) + + assert [page["url"] for page in data["pages"]] == ["/docs/api.html", "/docs/reference/python-api/thing.html"] + assert data["source_sha"] == "abc123" + assert "scripts/api_reference_url_map.py" in data["command"] + + +def test_every_id_and_name_is_classified(tree: dict[str, Path]) -> None: + ids = _run(tree)["pages"][1]["ids"] + + assert "chrome" not in ids + assert ids["widgets"]["class"] == "section" + assert ids["widget-heading"]["class"] == "section" + assert ids["module-benchbox.thing"]["class"] == "other" + assert ids["legacy"] == {"class": "other", "disposition": "alias", "via": ["name"]} + assert ids["benchbox.thing.Widget"] == {"class": "object", "disposition": "heading", "via": ["id"]} + assert ids["benchbox.thing.Widget.run"]["disposition"] == "alias" + assert ids["run"]["class"] == "object" + assert ids["run"]["disposition"] == "alias" + + +def test_dropped_symbols_alias_to_note(tree: dict[str, Path]) -> None: + page = _run(tree)["pages"][1] + + for key in ("benchbox.old.Gone", "benchbox.old.Gone.size", "size"): + assert page["ids"][key]["disposition"] == "alias-to-note" + assert page["ids"][key]["target"] == "#not-part-of-public-contract" + assert page["note_anchor"] == "not-part-of-public-contract" + assert page["counts"]["object:alias-to-note"] == 3 + + +def test_totals_and_bare_anchor_count(tree: dict[str, Path]) -> None: + totals = _run(tree)["totals"] + + assert totals["bare_object_anchors"] == 4 + assert totals["class:object"] == 12 + assert totals["disposition:alias-to-note"] == 6 + + +def test_output_is_deterministic(tree: dict[str, Path]) -> None: + _run(tree) + first = tree["out"].read_text(encoding="utf-8") + _run(tree) + + assert tree["out"].read_text(encoding="utf-8") == first + assert first.endswith("}\n") + + +def test_missing_built_page_exits_2(tree: dict[str, Path], capsys: pytest.CaptureFixture[str]) -> None: + (tree["html"] / "api.html").unlink() + + assert ( + tool.main( + [ + "--html", + str(tree["html"]), + "--docs", + str(tree["docs"]), + "--symbols", + str(tree["symbols"]), + "--out", + str(tree["out"]), + "--source-sha", + "x", + ] + ) + == 2 + ) + assert "built page missing" in capsys.readouterr().err diff --git a/tests/unit/scripts/test_assemble_public_site.py b/tests/unit/scripts/test_assemble_public_site.py index 0104e568db..28fb5577fc 100644 --- a/tests/unit/scripts/test_assemble_public_site.py +++ b/tests/unit/scripts/test_assemble_public_site.py @@ -98,22 +98,21 @@ def test_prose_only_omits_cname_and_404(tmp_path: Path) -> None: assert (site_dir / "404.html").is_file() -@pytest.mark.parametrize("destination", [Path("/"), Path.home()]) -def test_refuses_broad_output_directories(tmp_path: Path, destination: Path) -> None: +@pytest.mark.parametrize("destination", [Path("/"), None]) +def test_refuses_broad_output_directories(tmp_path: Path, destination: Path | None) -> None: + destination = Path.home() if destination is None else destination _pages_inputs(tmp_path) with pytest.raises(ValueError, match="refusing unsafe site output directory"): assemble_public_site(repo_root=tmp_path, site_dir=destination) -def test_docs_workflow_reuses_assembler_and_binds_visual_approval_to_pr_head() -> None: +def test_docs_workflow_reuses_assembler_and_binds_visual_approval_to_event_head() -> None: workflow = (REPO_ROOT / ".github" / "workflows" / "docs.yml").read_text(encoding="utf-8") assert "uv run -- python scripts/assemble_public_site.py --site-dir site" in workflow assert "cat > site/404.html" not in workflow - assert ( - "PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}" - in workflow - ) - assert "APPROVED_HEAD_SHA: ${{ github.event_name == 'pull_request' && vars.APPROVED_HEAD_SHA || '' }}" in workflow - assert "APPROVAL_REASON: ${{ github.event_name == 'pull_request' && vars.APPROVAL_REASON || '' }}" in workflow + assert "github.event.pull_request.head.sha" in workflow + assert "merge_group" not in workflow + assert "github.event_name == 'pull_request' && vars.APPROVED_HEAD_SHA" in workflow + assert "github.event_name == 'pull_request' && vars.APPROVAL_REASON" in workflow diff --git a/tests/unit/scripts/test_check_api_contract_symbols.py b/tests/unit/scripts/test_check_api_contract_symbols.py new file mode 100644 index 0000000000..5fada055fb --- /dev/null +++ b/tests/unit/scripts/test_check_api_contract_symbols.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import json +import sys +import textwrap +from pathlib import Path + +import pytest + +from scripts import check_api_contract_symbols as tool + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +INSTALLED_VERSION = "1.2.3" + + +@pytest.fixture +def fake_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + site = tmp_path / "site" + pkg = site / "fakepkg" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text( + textwrap.dedent( + """ + _EXPORTS = {"Widget": "fakepkg.widget", "make": "fakepkg.widget", "LIMIT": "fakepkg.widget"} + + def __getattr__(name): + if name in _EXPORTS: + import importlib + return getattr(importlib.import_module(_EXPORTS[name]), name) + raise AttributeError(name) + """ + ), + encoding="utf-8", + ) + (pkg / "widget.py").write_text( + textwrap.dedent( + """ + class Widget: + def __init__(self, size: int = 1) -> None: + self.size = size + + def make(a, b=2): + return a + b + + LIMIT = 5 + """ + ), + encoding="utf-8", + ) + (pkg / "other.py").write_text("class Widget:\n pass\n", encoding="utf-8") + dist = site / f"fakepkg-{INSTALLED_VERSION}.dist-info" + dist.mkdir() + (dist / "METADATA").write_text( + f"Metadata-Version: 2.1\nName: fakepkg\nVersion: {INSTALLED_VERSION}\n", encoding="utf-8" + ) + monkeypatch.setenv("PYTHONPATH", str(site)) + return tmp_path + + +def _inventory(path: Path, **overrides: object) -> Path: + symbols = [ + { + "symbol": "fakepkg.Widget", + "module": "fakepkg", + "name": "Widget", + "kind": "class", + "aliases": ["fakepkg.widget.Widget"], + "extras": [], + }, + { + "symbol": "fakepkg.make", + "module": "fakepkg", + "name": "make", + "kind": "function", + "aliases": [], + "extras": [], + }, + { + "symbol": "fakepkg.LIMIT", + "module": "fakepkg", + "name": "LIMIT", + "kind": "constant", + "aliases": [], + "extras": [], + }, + { + "symbol": "fakepkg.widget", + "module": "fakepkg.widget", + "name": None, + "kind": "module", + "aliases": [], + "extras": [], + }, + ] + data = { + "source": {"package": "fakepkg", "version": overrides.pop("version", INSTALLED_VERSION)}, + "symbols": symbols, + } + data.update(overrides) + target = path / "symbols.json" + target.write_text(json.dumps(data), encoding="utf-8") + return target + + +def _run(argv: list[str]) -> int: + return tool.main(argv) + + +def test_record_then_strict_check_passes(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env) + + assert _run(["record", "--python", sys.executable, "--symbols", str(symbols)]) == 0 + recorded = json.loads(symbols.read_text(encoding="utf-8")) + by_symbol = {s["symbol"]: s for s in recorded["symbols"]} + assert by_symbol["fakepkg.Widget"]["signature"] == "(size: int = 1) -> None" + assert by_symbol["fakepkg.make"]["signature"] == "(a, b=2)" + assert by_symbol["fakepkg.LIMIT"]["signature"] is None + assert symbols.read_text(encoding="utf-8").endswith("}\n") + + capsys.readouterr() + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols), "--strict"]) == 0 + report = json.loads(capsys.readouterr().out) + assert report["passed"] == 4 + assert report["failures"] == [] + + +def test_unrecorded_is_reported_but_only_fails_when_strict(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env) + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols)]) == 0 + report = json.loads(capsys.readouterr().out) + assert len(report["unrecorded"]) == 4 + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols), "--strict"]) == 1 + + +def test_import_failure_fails(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env) + data = json.loads(symbols.read_text(encoding="utf-8")) + data["symbols"].append( + { + "symbol": "fakepkg.Missing", + "module": "fakepkg", + "name": "Missing", + "kind": "class", + "aliases": [], + "extras": [], + } + ) + symbols.write_text(json.dumps(data), encoding="utf-8") + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + report = json.loads(capsys.readouterr().out) + assert any("fakepkg.Missing: import failure" in failure for failure in report["failures"]) + + +def test_alias_mismatch_fails(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env) + data = json.loads(symbols.read_text(encoding="utf-8")) + data["symbols"][0]["aliases"] = ["fakepkg.other.Widget"] + symbols.write_text(json.dumps(data), encoding="utf-8") + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + report = json.loads(capsys.readouterr().out) + assert any("alias mismatch" in failure for failure in report["failures"]) + + +def test_signature_drift_fails(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env) + assert _run(["record", "--python", sys.executable, "--symbols", str(symbols)]) == 0 + data = json.loads(symbols.read_text(encoding="utf-8")) + data["symbols"][1]["signature"] = "(a)" + symbols.write_text(json.dumps(data), encoding="utf-8") + + capsys.readouterr() + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + report = json.loads(capsys.readouterr().out) + assert any("signature drift" in failure for failure in report["failures"]) + + +def test_version_mismatch_fails(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env, version="9.9.9") + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + report = json.loads(capsys.readouterr().out) + assert any(failure.startswith("version:") for failure in report["failures"]) + assert _run(["record", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + + +def test_record_refuses_broken_symbols(fake_env: Path) -> None: + symbols = _inventory(fake_env) + data = json.loads(symbols.read_text(encoding="utf-8")) + data["symbols"][0]["aliases"] = ["fakepkg.other.Widget"] + symbols.write_text(json.dumps(data), encoding="utf-8") + + assert _run(["record", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + assert "signature" not in json.loads(symbols.read_text(encoding="utf-8"))["symbols"][1] + + +def test_editable_install_fails(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env) + dist = fake_env / "site" / f"fakepkg-{INSTALLED_VERSION}.dist-info" + (dist / "direct_url.json").write_text('{"url": "file:///src", "dir_info": {"editable": true}}', encoding="utf-8") + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + report = json.loads(capsys.readouterr().out) + assert any("direct_url.json" in failure for failure in report["failures"]) + assert _run(["record", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + + +def test_package_inside_repo_root_fails(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env) + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols), "--repo-root", str(fake_env)]) == 1 + report = json.loads(capsys.readouterr().out) + assert any("inside the repo" in failure for failure in report["failures"]) + + +def test_missing_interpreter_exits_2(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _inventory(fake_env) + + assert _run(["check", "--python", str(fake_env / "nope" / "python"), "--symbols", str(symbols)]) == 2 + assert "not found" in capsys.readouterr().err + + +def test_relative_python_is_made_absolute(fake_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + link = fake_env / "py" + link.symlink_to(sys.executable) + monkeypatch.chdir(fake_env) + + assert tool.resolve_python("py") == str(link) + assert tool.resolve_python("./py") == str(link) + + +def test_symbols_are_probed_in_separate_processes(fake_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + log = fake_env / "imports.log" + monkeypatch.setenv("IMPORT_LOG", str(log)) + (fake_env / "site" / "fakepkg" / "widget.py").write_text( + "import os\nwith open(os.environ['IMPORT_LOG'], 'a') as fh:\n fh.write('imported\\n')\n" + "class Widget: pass\ndef make(): pass\nLIMIT = 1\n", + encoding="utf-8", + ) + symbols = [ + {"symbol": "fakepkg.Widget", "module": "fakepkg.widget", "name": "Widget", "aliases": []}, + {"symbol": "fakepkg.make", "module": "fakepkg.widget", "name": "make", "aliases": []}, + ] + + probe = tool.run_probe(sys.executable, symbols, "fakepkg") + + assert log.read_text(encoding="utf-8").count("imported") == 2 + assert all(result["import_error"] is None for result in probe["results"].values()) + + +def test_invalid_dotted_name_is_rejected(fake_env: Path) -> None: + entry = {"package": "fakepkg", "module": "fakepkg", "name": "Widget; import os", "aliases": []} + report = tool.probe_one(sys.executable, str(fake_env), entry) + + assert "invalid attribute name" in report["result"]["import_error"] + + +def test_submodule_fallback(fake_env: Path) -> None: + entry = {"package": "fakepkg", "module": "fakepkg", "name": "other", "aliases": []} + report = tool.probe_one(sys.executable, str(fake_env), entry) + + assert report["result"]["import_error"] is None + + +def _known_failure_inventory(fake_env: Path, *, module: str, match: str) -> Path: + (fake_env / "site" / "fakepkg" / "cyclic.py").write_text( + "raise ImportError('cannot import name x from partially initialized module')\n", encoding="utf-8" + ) + symbols = _inventory(fake_env) + data = json.loads(symbols.read_text(encoding="utf-8")) + data["symbols"].append( + { + "symbol": "fakepkg.Cyclic", + "module": module, + "name": "Cyclic", + "kind": "class", + "aliases": [], + "extras": [], + "known_import_failure": {"reason": "cycle", "match": match}, + } + ) + symbols.write_text(json.dumps(data), encoding="utf-8") + return symbols + + +def test_known_failure_is_expected_and_not_recorded(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _known_failure_inventory(fake_env, module="fakepkg.cyclic", match="partially initialized module") + + assert _run(["record", "--python", sys.executable, "--symbols", str(symbols)]) == 0 + recorded = {s["symbol"]: s for s in json.loads(symbols.read_text(encoding="utf-8"))["symbols"]} + assert "signature" not in recorded["fakepkg.Cyclic"] + assert "signature" in recorded["fakepkg.make"] + + capsys.readouterr() + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols), "--strict"]) == 0 + report = json.loads(capsys.readouterr().out) + assert report["expected_failures"] == ["fakepkg.Cyclic"] + assert report["failures"] == [] + + +def test_known_failure_that_now_passes_fails(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _known_failure_inventory(fake_env, module="fakepkg.widget", match="partially initialized module") + (fake_env / "site" / "fakepkg" / "widget.py").write_text( + "class Widget:\n pass\n\nclass Cyclic:\n pass\n\ndef make(a, b=2):\n return a\n\nLIMIT = 5\n", + encoding="utf-8", + ) + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + report = json.loads(capsys.readouterr().out) + assert any("known failure now passes" in failure for failure in report["failures"]) + + +def test_known_failure_with_different_error_fails(fake_env: Path, capsys: pytest.CaptureFixture[str]) -> None: + symbols = _known_failure_inventory(fake_env, module="fakepkg.cyclic", match="some other error") + + assert _run(["check", "--python", sys.executable, "--symbols", str(symbols)]) == 1 + report = json.loads(capsys.readouterr().out) + assert any("different error" in failure for failure in report["failures"]) + assert _run(["record", "--python", sys.executable, "--symbols", str(symbols)]) == 1 diff --git a/tests/unit/scripts/test_check_makefile_inventory.py b/tests/unit/scripts/test_check_makefile_inventory.py index 60cbaccfea..ed58246933 100644 --- a/tests/unit/scripts/test_check_makefile_inventory.py +++ b/tests/unit/scripts/test_check_makefile_inventory.py @@ -3,7 +3,9 @@ from __future__ import annotations import importlib.util +import json import os +import re import shutil import subprocess import sys @@ -29,7 +31,7 @@ def _load_module() -> ModuleType: def _copy_make_contract(destination: Path) -> None: shutil.copy2(REPO_ROOT / "Makefile", destination / "Makefile") - shutil.copytree(REPO_ROOT / "make", destination / "make") + shutil.copytree(REPO_ROOT / "make", destination / "make", ignore=shutil.ignore_patterns("inventory.json")) def _run_make(root: Path, *arguments: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: @@ -43,13 +45,54 @@ def _run_make(root: Path, *arguments: str, env: dict[str, str] | None = None) -> ) -def test_repository_inventory_matches_manifest() -> None: +def test_check_builds_in_memory_and_never_writes_the_manifest(tmp_path: Path) -> None: module = _load_module() + _copy_make_contract(tmp_path) + manifest = tmp_path / module.MANIFEST_PATH + assert not manifest.exists() + + assert module.main(["--root", str(tmp_path)]) == 0 - assert module.compare_inventory(REPO_ROOT) == [] + assert not manifest.exists() assert module.validate_migration_proof(REPO_ROOT) == [] +def test_check_leaves_an_existing_manifest_untouched(tmp_path: Path) -> None: + module = _load_module() + _copy_make_contract(tmp_path) + manifest = tmp_path / module.MANIFEST_PATH + manifest.write_text("{}\n", encoding="utf-8") + + assert module.main(["--root", str(tmp_path)]) == 0 + + assert manifest.read_text(encoding="utf-8") == "{}\n" + + +@pytest.mark.skipif(sys.platform == "win32" or os.geteuid() == 0, reason="needs enforced POSIX directory permissions") +def test_check_succeeds_on_a_read_only_tree(tmp_path: Path) -> None: + module = _load_module() + _copy_make_contract(tmp_path) + directories = [tmp_path / "make", tmp_path] + for directory in directories: + directory.chmod(0o555) + try: + assert module.main(["--root", str(tmp_path)]) == 0 + assert not (tmp_path / module.MANIFEST_PATH).exists() + finally: + for directory in directories: + directory.chmod(0o755) + + +def test_write_mode_writes_the_manifest(tmp_path: Path) -> None: + module = _load_module() + _copy_make_contract(tmp_path) + + assert module.main(["--root", str(tmp_path), "--write"]) == 0 + + manifest = tmp_path / module.MANIFEST_PATH + assert json.loads(manifest.read_text(encoding="utf-8")) == module.build_inventory(tmp_path) + + def test_checked_monolith_baseline_has_expected_contract() -> None: module = _load_module() baseline = module._load_inventory(REPO_ROOT / module.BASELINE_PATH) @@ -59,7 +102,7 @@ def test_checked_monolith_baseline_has_expected_contract() -> None: assert baseline["default_goal"] == "test" -def test_public_target_removal_fails_closed(tmp_path: Path) -> None: +def test_public_target_removal_is_reflected_in_the_evaluated_inventory(tmp_path: Path) -> None: module = _load_module() _copy_make_contract(tmp_path) makefile = tmp_path / "Makefile" @@ -67,10 +110,12 @@ def test_public_target_removal_fails_closed(tmp_path: Path) -> None: assert "test-fast:\n" in text makefile.write_text(text.replace("test-fast:\n", "test-fast-removed:\n", 1), encoding="utf-8") - problems = module.compare_inventory(tmp_path) + inventory, problems = module.evaluate_inventory(tmp_path) - assert any("missing targets: test-fast" in problem for problem in problems) - assert any("unexpected targets: test-fast-removed" in problem for problem in problems) + assert problems == [] + assert inventory is not None + assert "test-fast" not in inventory["targets"] + assert "test-fast-removed" in inventory["targets"] def test_required_include_removal_fails_closed(tmp_path: Path) -> None: @@ -78,10 +123,11 @@ def test_required_include_removal_fails_closed(tmp_path: Path) -> None: _copy_make_contract(tmp_path) (tmp_path / "make" / "help.mk").unlink() - assert module.compare_inventory(tmp_path) == ["required Make include is missing: make/help.mk"] + assert module.evaluate_inventory(tmp_path) == (None, ["required Make include is missing: make/help.mk"]) + assert module.main(["--root", str(tmp_path)]) == 1 -def test_semantic_assignment_reorder_fails_and_changes_gnu_make_evaluation(tmp_path: Path) -> None: +def test_semantic_assignment_reorder_changes_gnu_make_evaluation_and_the_inventory(tmp_path: Path) -> None: module = _load_module() _copy_make_contract(tmp_path) platform_makefile = tmp_path / "make" / "platform-tests.mk" @@ -89,6 +135,7 @@ def test_semantic_assignment_reorder_fails_and_changes_gnu_make_evaluation(tmp_p reordered = "COMPOSE := $(CONTAINER_ENGINE) compose\nCONTAINER_ENGINE ?= docker" text = platform_makefile.read_text(encoding="utf-8") assert original in text + before_inventory, _ = module.evaluate_inventory(tmp_path) before = _run_make(tmp_path, "-pn", "test") assert before.returncode == 0 @@ -99,13 +146,13 @@ def test_semantic_assignment_reorder_fails_and_changes_gnu_make_evaluation(tmp_p assert after.returncode == 0 assert "COMPOSE := compose" in after.stdout.splitlines() - problems = module.compare_inventory(tmp_path) - assert "semantic_statements changed" in problems + after_inventory, problems = module.evaluate_inventory(tmp_path) + assert problems == [] + assert before_inventory is not None and after_inventory is not None + assert after_inventory["semantic_sha256"] != before_inventory["semantic_sha256"] -def test_writer_blesses_intentional_future_target_without_rewriting_migration_proof( - tmp_path: Path, -) -> None: +def test_writer_adds_future_target_without_rewriting_migration_proof(tmp_path: Path) -> None: module = _load_module() _copy_make_contract(tmp_path) maintenance = tmp_path / "make" / "worktree-maintenance.mk" @@ -119,11 +166,11 @@ def test_writer_blesses_intentional_future_target_without_rewriting_migration_pr original_baseline = baseline.read_bytes() original_proof = proof.read_bytes() - assert any("unexpected targets: future-contract-probe" in item for item in module.compare_inventory(tmp_path)) result = module.main(["--root", str(tmp_path), "--write"]) assert result == 0 - assert module.compare_inventory(tmp_path) == [] + manifest = json.loads((tmp_path / module.MANIFEST_PATH).read_text(encoding="utf-8")) + assert "future-contract-probe" in manifest["targets"] assert baseline.read_bytes() == original_baseline assert proof.read_bytes() == original_proof @@ -176,3 +223,46 @@ def test_absolute_symlink_makefile_preserves_module_resolution(tmp_path: Path) - assert result.returncode == 0, result.stderr assert "makefile-inventory-check" in result.stdout + + +AGENT_MAKE_INTERFACES = [ + "agent-write-preflight", + "ci-lint", + "ci-linux", + "comment-policy-check", + "compat-docs-check", + "duplicate-check-delta", + "duplicate-check-verbose", + "help", + "makefile-inventory-check", + "platform-manifest-check", + "pr-arm", + "pr-open", + "pr-preflight", + "pr-ready", + "skill-sync", + "test", + "test-fast", + "worktree-create", +] + + +def test_make_targets_agents_rely_on_stay_public() -> None: + module = _load_module() + public = set(module.build_inventory(REPO_ROOT)["public_targets"]) + + assert sorted(set(AGENT_MAKE_INTERFACES) - public) == [] + + +@pytest.mark.skipif( + not (REPO_ROOT / "AGENTS.md").is_file(), reason="agent instructions are not part of a curated release" +) +def test_every_make_target_named_in_agent_instructions_exists() -> None: + module = _load_module() + public = set(module.build_inventory(REPO_ROOT)["public_targets"]) + instructions = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8") + named = set(re.findall(r"`make ([a-z][a-z0-9_-]*)", instructions)) + named |= set(re.findall(r"^make ([a-z][a-z0-9_-]*)", instructions, flags=re.MULTILINE)) + + assert named + assert sorted(named - public) == [] diff --git a/tests/unit/scripts/test_check_project_references.py b/tests/unit/scripts/test_check_project_references.py new file mode 100644 index 0000000000..846a86192f --- /dev/null +++ b/tests/unit/scripts/test_check_project_references.py @@ -0,0 +1,82 @@ +"""Tests for the stale _project/ reference lint. + +The lint blocks NEW stale references (tracked files outside _project/ +mentioning a _project/ path that does not exist) while grandfathering +pre-existing violations in a baseline that cannot rot. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +pytestmark = [ + pytest.mark.unit, + pytest.mark.fast, +] + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _load_script(): + name = "check_project_references_under_test" + path = REPO_ROOT / "_project" / "scripts" / "check_project_references.py" + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +checker = _load_script() + + +class TestNormalize: + def test_strips_trailing_punctuation(self): + assert checker._normalize("_project/foo/bar.md`") == "_project/foo/bar.md" + assert checker._normalize("_project/foo/bar.md).") == "_project/foo/bar.md" + + def test_placeholders_ignored(self): + assert checker._is_placeholder("_project/foo.md") + assert checker._is_placeholder("_project/blind-spots/foo.md") + assert checker._is_placeholder("_project/specs/example.md") + assert checker._is_placeholder("_project/specs/" + "foo" + ".yaml") + assert checker._is_placeholder("_project/specs/" + "bar" + ".json") + assert not checker._is_placeholder("_project/decisions/" + "arch-pilot" + "-eval.md") + + def test_normalize_strips_autolink_bracket(self): + assert checker._normalize("_project/decisions/" + "arch-pilot" + "-eval.md>") == ( + "_project/decisions/arch-pilot" + "-eval.md" + ) + + +class TestBaselineRoundTrip: + def test_baseline_exists_and_gate_passes(self, capsys): + assert checker.BASELINE_PATH.exists() + assert checker.main([]) == 0 + assert "no new breakage" in capsys.readouterr().out + + def test_new_reference_detected_against_baseline(self, tmp_path, monkeypatch, capsys): + scanned = tmp_path / "scanned.md" + scanned.write_text("see _project/decisions/synthetic-new-1" + "234.md\n", encoding="utf-8") + monkeypatch.setattr(checker, "_tracked_files", lambda: ["scanned.md"]) + monkeypatch.setattr(checker, "REPO_ROOT", tmp_path) + monkeypatch.setattr(checker, "BASELINE_PATH", tmp_path / "baseline.txt") + (tmp_path / "baseline.txt").write_text("", encoding="utf-8") + assert checker.main([]) == 1 + assert "NEW stale" in capsys.readouterr().out + + def test_fixed_baseline_entry_forces_regen(self, tmp_path, monkeypatch, capsys): + (tmp_path / "baseline.txt").write_text( + "gone.md::_project/decisions/synthetic-gone-99" + "9.md\n", encoding="utf-8" + ) + monkeypatch.setattr(checker, "_tracked_files", list) + monkeypatch.setattr(checker, "REPO_ROOT", tmp_path) + monkeypatch.setattr(checker, "BASELINE_PATH", tmp_path / "baseline.txt") + assert checker.main([]) == 1 + assert "regenerate" in capsys.readouterr().out diff --git a/tests/unit/scripts/test_check_rerun_shard_retention.py b/tests/unit/scripts/test_check_rerun_shard_retention.py new file mode 100644 index 0000000000..a8789b54d9 --- /dev/null +++ b/tests/unit/scripts/test_check_rerun_shard_retention.py @@ -0,0 +1,86 @@ +"""Tests for the generated-rerun-shard retention check.""" + +from __future__ import annotations + +import importlib.util +import sys +from datetime import date +from pathlib import Path + +import pytest + +pytestmark = [ + pytest.mark.unit, + pytest.mark.fast, +] + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _load_script(): + name = "check_rerun_shard_retention_under_test" + path = REPO_ROOT / "scripts" / "check_rerun_shard_retention.py" + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +checker = _load_script() + + +class TestStemDates: + def test_parses_sweep_date(self): + assert checker.shard_sweep_date(Path("uat-tuned-followup-resume-cedardb-20260505.yaml")) == date(2026, 5, 5) + + def test_rejects_undated_stem(self): + assert checker.shard_sweep_date(Path("uat-smoke.yaml")) is None + + def test_rejects_impossible_date(self): + assert checker.shard_sweep_date(Path("shard-20261345.yaml")) is None + + +class TestExpiry: + def test_boundary_is_inclusive(self, tmp_path, monkeypatch): + shard = tmp_path / "sweep-20260101.yaml" + shard.write_text("name: x\n", encoding="utf-8") + monkeypatch.setattr(checker, "SHARD_DIR", tmp_path) + expired, _ = checker.find_expired(date(2026, 6, 30), 180) + assert [p.name for p, _, _ in expired] == ["sweep-20260101.yaml"] + + def test_undated_shards_fail(self, tmp_path, monkeypatch): + (tmp_path / "sweep-undated.yaml").write_text("name: x\n", encoding="utf-8") + monkeypatch.setattr(checker, "SHARD_DIR", tmp_path) + expired, undated = checker.find_expired(date(2026, 9, 25), 180) + assert expired == [] + assert [p.name for p in undated] == ["sweep-undated.yaml"] + + def test_current_shards_pass(self, tmp_path, monkeypatch): + (tmp_path / "sweep-20260901.yaml").write_text("name: x\n", encoding="utf-8") + monkeypatch.setattr(checker, "SHARD_DIR", tmp_path) + assert checker.find_expired(date(2026, 9, 25), 180) == ([], []) + + def test_main_reports_ok_when_current(self, tmp_path, monkeypatch, capsys): + # Stamp with the live date: main() reads the real clock, so a fixed + # date would eventually expire and fail this test. + stamp = date.today().strftime("%Y%m%d") + (tmp_path / f"sweep-{stamp}.yaml").write_text("name: x\n", encoding="utf-8") + monkeypatch.setattr(checker, "SHARD_DIR", tmp_path) + assert checker.main([]) == 0 + assert "OK" in capsys.readouterr().out + + def test_main_fails_with_archive_command_when_expired(self, tmp_path, monkeypatch, capsys): + (tmp_path / "sweep-20260101.yaml").write_text("name: x\n", encoding="utf-8") + monkeypatch.setattr(checker, "SHARD_DIR", tmp_path) + assert checker.main([]) == 1 + out = capsys.readouterr().out + assert "expired" in out + assert "mkdir -p" in out and "git mv" in out + + def test_main_fails_closed_when_shard_directory_missing(self, tmp_path, monkeypatch, capsys): + monkeypatch.setattr(checker, "SHARD_DIR", tmp_path / "renamed-away") + assert checker.main([]) == 2 + assert "not found" in capsys.readouterr().err diff --git a/tests/unit/scripts/test_check_sqlglot_repro_retirement.py b/tests/unit/scripts/test_check_sqlglot_repro_retirement.py new file mode 100644 index 0000000000..0257e85358 --- /dev/null +++ b/tests/unit/scripts/test_check_sqlglot_repro_retirement.py @@ -0,0 +1,112 @@ +"""Tests for the sqlglot repro-retirement upgrade trigger.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +pytestmark = [ + pytest.mark.unit, + pytest.mark.fast, +] + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _load_script(): + name = "check_sqlglot_repro_retirement_under_test" + path = REPO_ROOT / "scripts" / "check_sqlglot_repro_retirement.py" + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +checker = _load_script() + + +class TestVersionParsing: + def test_parses_locked_version(self): + text = 'name = "sqlglot"\nversion = "30.18.0"\n' + assert checker._LOCK_VERSION_RE.search(text).group(1) == "30.18.0" # type: ignore[union-attr] + + def test_live_lockfile_has_version(self): + assert checker.locked_sqlglot_version(None) is not None + + +class TestSummaryParsing: + SUMMARY = """=== Summary (FAIL = observation differs from expected; PASS = observation matches) + [FAIL] #1 duckdb-all-keyword (Tier B) + [PASS] #6 questdb-dialect-missing (Tier A) +""" + + def test_extracts_passing_only(self): + assert checker.newly_passing(self.SUMMARY) == ["#6 questdb-dialect-missing (Tier A)"] + + def test_empty_when_all_fail(self): + assert checker.newly_passing("=== Summary\n [FAIL] #1 x\n") == [] + + +class TestNoUpgradePath: + def test_same_version_exits_zero(self, monkeypatch, capsys): + monkeypatch.setattr(checker, "merge_base", lambda ref="origin/develop": "abc123") + monkeypatch.setattr(checker, "locked_sqlglot_version", lambda ref=None: "30.18.0") + assert checker.main([]) == 0 + assert "no upgrade" in capsys.readouterr().out + + def test_base_ref_goes_through_merge_base(self, monkeypatch, capsys): + seen: dict[str, str] = {} + + def fake_merge_base(ref="origin/develop"): + seen["ref"] = ref + return "base123" + + monkeypatch.setattr(checker, "merge_base", fake_merge_base) + monkeypatch.setattr(checker, "locked_sqlglot_version", lambda ref=None: "30.18.0") + assert checker.main(["--base-ref", "origin/release"]) == 0 + # A ref that differs from merge_base's default proves --base-ref is forwarded. + assert seen["ref"] == "origin/release" + + def test_crash_without_summary_fails_closed(self, monkeypatch, capsys): + monkeypatch.setattr(checker, "merge_base", lambda ref="origin/develop": "abc123") + monkeypatch.setattr( + checker, + "locked_sqlglot_version", + lambda ref=None: "30.17.0" if ref else "30.18.0", + ) + monkeypatch.setattr(checker, "run_repros", lambda: (2, "Traceback: boom\n")) + assert checker.main([]) == 1 + assert "crashed" in capsys.readouterr().err + + def test_upgrade_with_all_fail_exits_zero(self, monkeypatch, capsys): + monkeypatch.setattr(checker, "merge_base", lambda ref="origin/develop": "abc123") + monkeypatch.setattr( + checker, + "locked_sqlglot_version", + lambda ref=None: "30.17.0" if ref else "30.18.0", + ) + summary = "=== Summary\n [FAIL] #1 x\n" + monkeypatch.setattr(checker, "run_repros", lambda: (1, summary)) + assert checker.main([]) == 0 + assert "no retirement candidates" in capsys.readouterr().out + + def test_upgrade_with_passing_repro_reports_candidate_and_fails(self, monkeypatch, capsys): + monkeypatch.setattr(checker, "merge_base", lambda ref="origin/develop": "abc123") + monkeypatch.setattr( + checker, + "locked_sqlglot_version", + lambda ref=None: "30.17.0" if ref else "30.18.0", + ) + summary = "=== Summary\n [FAIL] #1 x\n [PASS] #6 questdb-dialect-missing (Tier A)\n" + monkeypatch.setattr(checker, "run_repros", lambda: (1, summary)) + assert checker.main([]) == 1 + out = capsys.readouterr().out + assert "RETIREMENT CANDIDATES" in out + assert "PASS #6 questdb-dialect-missing (Tier A)" in out + assert "PASS #1 x" not in out diff --git a/tests/unit/scripts/test_ci_unit_result.py b/tests/unit/scripts/test_ci_unit_result.py new file mode 100644 index 0000000000..2ff8d11182 --- /dev/null +++ b/tests/unit/scripts/test_ci_unit_result.py @@ -0,0 +1,98 @@ +"""Tests for scripts/ci_unit_result.py, the per-unit result aggregator.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from ci_unit_result import evaluate, parse_expectation + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "ci_unit_result.py" + + +def needs(**results: str) -> dict[str, dict[str, str]]: + return {name: {"result": result} for name, result in results.items()} + + +def test_untouched_unit_passes_when_all_jobs_skipped() -> None: + assert evaluate(needs(lint="skipped", test="skipped"), {"lint": False, "test": False}, []) == [] + + +def test_required_job_must_succeed() -> None: + problems = evaluate(needs(lint="success", test="success"), {"lint": True, "test": True}, []) + assert problems == [] + + +def test_required_job_skipped_is_a_failure() -> None: + """A skipped required job must never green the unit.""" + problems = evaluate(needs(lint="skipped"), {"lint": True}, []) + assert problems == ["lint=skipped (required for this change; expected success)"] + + +def test_unexpected_success_when_not_required_is_allowed() -> None: + assert evaluate(needs(lint="success"), {"lint": False}, []) == [] + + +def test_failure_and_cancelled_always_fail() -> None: + assert evaluate(needs(lint="failure"), {"lint": False}, []) != [] + assert evaluate(needs(lint="cancelled", other="success"), {"other": True}, []) != [] + + +def test_missing_expected_job_is_reported() -> None: + problems = evaluate(needs(lint="success"), {"lint": True, "gone": True}, []) + assert any(p.startswith("gone: not in needs") for p in problems) + + +def test_always_jobs_must_succeed_even_when_skipped() -> None: + assert evaluate(needs(classify="skipped"), {}, ["classify"]) == ["classify=skipped (must succeed on every run)"] + assert evaluate(needs(classify="success"), {}, ["classify"]) == [] + assert evaluate({}, {}, ["classify"]) == ["classify=missing (must succeed on every run)"] + + +def test_problem_list_is_deduplicated() -> None: + problems = evaluate(needs(lint="failure"), {"lint": True}, []) + assert len(problems) == len(set(problems)) + + +def test_parse_expectation() -> None: + assert parse_expectation("lint=true") == ("lint", True) + assert parse_expectation("lint=False") == ("lint", False) + with pytest.raises(Exception): + parse_expectation("lint=maybe") + + +def run(needs_json: object, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), "--unit", "core", "--needs", json.dumps(needs_json), *args], + capture_output=True, + text=True, + check=False, + ) + + +def test_cli_exit_codes() -> None: + ok = run( + needs(classify="success", lint="success", test="skipped"), + "--always", + "classify", + "--expect", + "lint=true", + "--expect", + "test=false", + ) + assert ok.returncode == 0, ok.stdout + bad = run(needs(classify="success", lint="skipped"), "--always", "classify", "--expect", "lint=true") + assert bad.returncode == 1 + assert "lint=skipped" in bad.stdout + assert run("not-an-object").returncode == 1 + assert ( + subprocess.run( + [sys.executable, str(SCRIPT), "--unit", "core", "--needs", "{"], capture_output=True, text=True + ).returncode + == 1 + ) diff --git a/tests/unit/scripts/test_ci_units.py b/tests/unit/scripts/test_ci_units.py new file mode 100644 index 0000000000..3d5772b8bc --- /dev/null +++ b/tests/unit/scripts/test_ci_units.py @@ -0,0 +1,162 @@ +"""Tests for scripts/ci_units.py and the .github/ci-units.yml ownership map.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from ci_units import UNITS, classify_units, load_unit_rules, write_github_output + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[3] +RULES_PATH = REPO_ROOT / ".github" / "ci-units.yml" + + +@pytest.fixture(scope="module") +def rules() -> dict[str, list[str]]: + return load_unit_rules(RULES_PATH) + + +def needed(paths: list[str], rules: dict[str, list[str]]) -> set[str]: + decision = classify_units(paths, rules) + return {unit for unit, on in decision["units"].items() if on} # type: ignore[union-attr] + + +@pytest.mark.parametrize( + ("paths", "expected"), + [ + (["benchbox/core/tpch/generator.py"], {"core"}), + (["tests/unit/core/test_x.py"], {"core"}), + (["landing/index.html"], {"landing"}), + (["landing/style.css"], {"landing"}), + (["website/src/pages/index.astro"], {"docs", "landing"}), + (["website/package-lock.json"], {"docs", "landing"}), + (["website/scripts/render.py"], {"core", "docs", "landing"}), + (["results-explorer/src/App.tsx"], {"explorer"}), + (["results-data/bundles/x.json"], {"explorer", "results-data"}), + (["docs/guides/intro.md"], {"docs"}), + (["docs/conf.py"], {"core", "docs"}), + (["scripts/check_windows_antipatterns.py"], {"tooling"}), + (["Makefile"], {"tooling"}), + (["AGENTS.md"], {"tooling"}), + (["_project/decisions/foo.md"], {"tooling"}), + ], +) +def test_single_unit_ownership(paths: list[str], expected: set[str], rules: dict[str, list[str]]) -> None: + assert needed(paths, rules) == expected + + +@pytest.mark.parametrize( + ("paths", "extra"), + [ + # Producer-owned contracts widen the consumer units. + (["benchbox/core/results/schema.py"], {"explorer", "results-data"}), + (["benchbox/cli/main.py"], {"docs"}), + (["benchbox/core/platform_registry.py"], {"docs", "landing"}), + (["pyproject.toml"], {"landing"}), + ], +) +def test_widened_triggers(paths: list[str], extra: set[str], rules: dict[str, list[str]]) -> None: + result = needed(paths, rules) + assert extra <= result + assert "core" in result + + +def test_independent_units_do_not_pull_core(rules: dict[str, list[str]]) -> None: + """Docs, landing, explorer, and results-data changes must not require core.""" + for path in ("docs/index.rst", "landing/script.js", "results-explorer/src/db.ts", "results-data/README.md"): + assert "core" not in needed([path], rules), path + + +def test_unowned_path_fails_closed_to_core(rules: dict[str, list[str]]) -> None: + decision = classify_units(["brand-new-top-level/thing.bin"], rules) + assert decision["units"]["core"] is True # type: ignore[index] + assert decision["unowned_paths"] == ["brand-new-top-level/thing.bin"] + + +def test_empty_change_set_runs_every_unit(rules: dict[str, list[str]]) -> None: + decision = classify_units([], rules) + assert decision["run_all"] is True + assert all(decision["units"][unit] for unit in UNITS) # type: ignore[index] + + +@pytest.mark.parametrize("path", [".github/workflows/ci.yml", ".github/ci-units.yml", "scripts/ci_units.py"]) +def test_self_protection_runs_every_unit(path: str, rules: dict[str, list[str]]) -> None: + decision = classify_units([path], rules) + assert decision["run_all"] is True + assert all(decision["units"][unit] for unit in UNITS) # type: ignore[index] + + +def test_code_tests_needed_covers_core_and_tooling(rules: dict[str, list[str]]) -> None: + assert classify_units(["scripts/x.py"], rules)["code_tests_needed"] is True + assert classify_units(["benchbox/x.py"], rules)["code_tests_needed"] is True + assert classify_units(["landing/index.html"], rules)["code_tests_needed"] is False + + +def test_rules_file_declares_every_unit_and_nothing_else(rules: dict[str, list[str]]) -> None: + assert set(rules) == {*UNITS, "all-units"} + + +def test_load_rejects_missing_or_unknown_keys(tmp_path: Path) -> None: + bad = tmp_path / "units.yml" + bad.write_text('core:\n - "a/**"\n', encoding="utf-8") + with pytest.raises(ValueError, match="missing unit keys"): + load_unit_rules(bad) + good = RULES_PATH.read_text(encoding="utf-8") + '\nextra-unit:\n - "x/**"\n' + bad.write_text(good, encoding="utf-8") + with pytest.raises(ValueError, match="unknown unit keys"): + load_unit_rules(bad) + + +def test_github_output_lines(tmp_path: Path, rules: dict[str, list[str]]) -> None: + out = tmp_path / "out" + write_github_output(out, classify_units(["landing/index.html"], rules)) + lines = dict(line.split("=", 1) for line in out.read_text(encoding="utf-8").splitlines()) + assert lines["unit-landing"] == "true" + assert lines["unit-core"] == "false" + assert lines["code-tests-needed"] == "false" + assert lines["run-all-units"] == "false" + + +def test_cli_reads_changed_file_and_writes_json(tmp_path: Path) -> None: + changed = tmp_path / "changed.txt" + changed.write_text("docs/index.rst\n", encoding="utf-8") + json_out = tmp_path / "decision.json" + subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "ci_units.py"), + "--changed-file", + str(changed), + "--json-out", + str(json_out), + ], + check=True, + cwd=REPO_ROOT, + ) + decision = json.loads(json_out.read_text(encoding="utf-8")) + assert decision["units"]["docs"] is True + assert decision["units"]["core"] is False + + +def test_cli_diff_failure_runs_every_unit(tmp_path: Path) -> None: + """A base ref that cannot be diffed must fail closed, not run nothing.""" + out = tmp_path / "out" + subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "ci_units.py"), + "--base-ref", + "definitely-not-a-ref-0000", + "--github-output", + str(out), + ], + check=True, + cwd=REPO_ROOT, + ) + lines = dict(line.split("=", 1) for line in out.read_text(encoding="utf-8").splitlines()) + assert all(lines[f"unit-{unit}"] == "true" for unit in UNITS) diff --git a/tests/unit/scripts/test_comment_cleanup_scope.py b/tests/unit/scripts/test_comment_cleanup_scope.py new file mode 100644 index 0000000000..bd1dbfa29c --- /dev/null +++ b/tests/unit/scripts/test_comment_cleanup_scope.py @@ -0,0 +1,1117 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import subprocess +import sys +from copy import deepcopy +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location("comment_cleanup_scope", ROOT / "scripts/check_comment_cleanup_scope.py") +assert SPEC and SPEC.loader +scope = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = scope +SPEC.loader.exec_module(scope) + + +@pytest.fixture +def policy() -> dict: + return { + "version": 1, + "maintained_roots": [{"id": "source", "selector": {"prefix": "src/"}, "kind": "source"}], + "ownership_rules": [ + { + "id": "source-owner", + "owner": "comment-cleanup-core-bootstrap", + "state": "blocked", + "blocking_disposition": "Await source-reader inventory.", + "priority": 0, + "selectors": [{"prefix": "src/"}], + } + ], + "external_entries": [], + "format_classes": [], + "payloads": [], + "derived_rules": [], + "consumer_edges": [], + "directives": [], + "notices": [], + "obligations": [], + "review_dispositions": [ + { + "id": f"R{number}", + "disposition": "accepted", + "original_verdict": "accepted", + "requirements": ["requirement"], + "rationale": "Fixture rationale.", + "acceptance_refs": ["Fixture acceptance reference."], + "owner": "comment-cleanup-scope-policy", + } + for number in range(1, 19) + ], + } + + +def test_owned_paths_reports_unclassified_as_blocked(policy: dict) -> None: + roots = scope.validate_roots(policy) + resolved, findings = scope.owned_paths(["src/a.py"], roots, []) + assert [finding.code for finding in findings] == ["SCOPE001"] + assert resolved == [ + { + "path": "src/a.py", + "kind": "source", + "owner": None, + "state": "blocked", + "blocking_disposition": "Ownership is not frozen for this maintained path.", + "rule": None, + } + ] + + +def test_high_priority_shared_override_selects_one_owner(policy: dict) -> None: + override = { + "id": "shared-test", + "owner": "comment-cleanup-cross-module-unit-tests", + "state": "blocked", + "blocking_disposition": "Serialize shared test changes.", + "priority": 1, + "selectors": [{"path": "src/shared.py"}], + } + rules = scope.validate_rules({**policy, "ownership_rules": [*policy["ownership_rules"], override]}) + resolved, findings = scope.owned_paths(["src/shared.py"], scope.validate_roots(policy), rules) + assert not findings + assert resolved[0]["owner"] == "comment-cleanup-cross-module-unit-tests" + assert resolved[0]["rule"] == "shared-test" + + +def test_equal_priority_rules_are_an_ownership_collision(policy: dict) -> None: + conflicting = deepcopy(policy["ownership_rules"][0]) + conflicting["id"] = "other-owner" + conflicting["owner"] = "comment-cleanup-core-catalogs" + rules = scope.validate_rules({**policy, "ownership_rules": [*policy["ownership_rules"], conflicting]}) + resolved, findings = scope.owned_paths(["src/a.py"], scope.validate_roots(policy), rules) + assert [finding.code for finding in findings] == ["SCOPE002"] + assert resolved[0]["state"] == "blocked" + assert resolved[0]["rule"] == ["other-owner", "source-owner"] + + +def test_more_specific_root_wins_over_repository_fallback(policy: dict) -> None: + policy["maintained_roots"].append({"id": "repository", "selector": {"prefix": ""}, "kind": "unclassified"}) + root = scope.selected_root("src/a.py", scope.validate_roots(policy)) + assert root["id"] == "source" + + +def test_equally_specific_roots_are_rejected(policy: dict) -> None: + policy["maintained_roots"].append({"id": "other-source", "selector": {"prefix": "src/"}, "kind": "other"}) + with pytest.raises(scope.PolicyError, match="maintained-root collision"): + scope.selected_root("src/a.py", scope.validate_roots(policy)) + + +def test_policy_requires_all_review_dispositions(policy: dict) -> None: + policy["review_dispositions"].pop() + with pytest.raises(scope.PolicyError, match="review dispositions"): + scope.validate_dispositions(policy) + + +def test_evidence_requires_immutable_base_paths(policy: dict) -> None: + policy["notices"] = [ + { + "path": "missing.py", + "blob_sha256": "a" * 64, + "byte_start": 0, + "byte_end": 1, + "retained_sha256": "a" * 64, + "governing_requirement": "required notice", + "source_identity": "upstream source", + "owner": "comment-cleanup-scope-policy", + "blocking_disposition": "Await provenance proof.", + } + ] + with pytest.raises(scope.PolicyError, match="not tracked"): + scope.validate_evidence(policy, ROOT, "0" * 40, {"src/a.py"}) + + +def test_consumer_edge_rejects_unknown_endpoint(policy: dict) -> None: + policy["consumer_edges"] = [ + { + "producer": "src/a.py", + "consumer": "unknown", + "owner": "comment-cleanup-core-bootstrap", + "state": "blocked", + "blocking_disposition": "Await consumer mapping.", + "proof": "source proof", + } + ] + with pytest.raises(scope.PolicyError, match="endpoint"): + scope.validate_payloads_and_edges(policy, {"src/a.py"}) + + +def test_duplicate_consumer_edge_is_rejected(policy: dict) -> None: + edge = { + "producer": "src/a.py", + "consumer": "src/consumer.py", + "owner": "comment-cleanup-core-bootstrap", + "state": "blocked", + "blocking_disposition": "Await consumer migration.", + "proof": "source proof", + } + policy["consumer_edges"] = [edge, deepcopy(edge)] + with pytest.raises(scope.PolicyError, match="duplicate consumer edge"): + scope.validate_payloads_and_edges(policy, {"src/a.py", "src/consumer.py"}) + + +def test_cyclic_consumer_edges_are_rejected(policy: dict) -> None: + policy["consumer_edges"] = [ + { + "producer": "src/a.py", + "consumer": "src/b.py", + "owner": "comment-cleanup-core-bootstrap", + "state": "blocked", + "blocking_disposition": "Await consumer migration.", + "proof": "source proof", + }, + { + "producer": "src/b.py", + "consumer": "src/a.py", + "owner": "comment-cleanup-core-bootstrap", + "state": "blocked", + "blocking_disposition": "Await consumer migration.", + "proof": "source proof", + }, + ] + with pytest.raises(scope.PolicyError, match="cyclic consumer edge"): + scope.validate_payloads_and_edges(policy, {"src/a.py", "src/b.py"}) + + +def test_blocked_consumer_prevents_ready_producer(policy: dict) -> None: + policy["ownership_rules"][0]["state"] = "ready" + policy["consumer_edges"] = [ + { + "producer": "src/a.py", + "consumer": "src/consumer.py", + "owner": "comment-cleanup-core-bootstrap", + "state": "blocked", + "blocking_disposition": "Await consumer migration.", + "proof": "source proof", + } + ] + resolved, _ = scope.owned_paths( + ["src/a.py", "src/consumer.py"], + scope.validate_roots(policy), + scope.validate_rules(policy), + ) + assert [finding.code for finding in scope.dependency_findings(policy, resolved)] == ["SCOPE003"] + + +def test_frozen_task_ids_rejects_unknown_owner(tmp_path: Path) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / ".git/info/exclude").write_text(".todo-batch/\n", encoding="utf-8") + (tmp_path / ".todo-batch").mkdir() + (tmp_path / ".todo-batch/tasks.txt").write_text("TODO: comment-cleanup-known\n", encoding="utf-8") + task_ids = scope.frozen_task_ids(tmp_path, ".todo-batch/tasks.txt") + with pytest.raises(scope.PolicyError, match="frozen task set"): + scope.check_owner("comment-cleanup-unknown", "owner", task_ids) + + +def test_notice_digest_must_match_immutable_base_bytes(tmp_path: Path, policy: dict) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "test@example.com"], check=True) + (tmp_path / "src").mkdir() + notice = b"required notice\n" + (tmp_path / "src/a.py").write_bytes(notice) + policy["notices"] = [ + { + "path": "src/a.py", + "blob_sha256": hashlib.sha256(notice).hexdigest(), + "byte_start": 0, + "byte_end": len(notice), + "retained_sha256": hashlib.sha256(notice).hexdigest(), + "governing_requirement": "fixture requirement", + "source_identity": "fixture source", + "owner": "comment-cleanup-core-bootstrap", + "blocking_disposition": "Retain required notice.", + } + ] + subprocess.run(["git", "-C", str(tmp_path), "add", "."], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "-qm", "fixture"], check=True) + base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + scope.validate_evidence(policy, tmp_path, base, {"src/a.py"}) + policy["notices"][0]["retained_sha256"] = "0" * 64 + with pytest.raises(scope.PolicyError, match="retained-byte digest"): + scope.validate_evidence(policy, tmp_path, base, {"src/a.py"}) + + +def test_obligation_requires_exactly_one_destination_reference(tmp_path: Path, policy: dict) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "test@example.com"], check=True) + (tmp_path / "src").mkdir() + (tmp_path / "src/a.py").write_text("# TODO: follow up\n", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "."], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "-qm", "fixture"], check=True) + base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + entry = { + "path": "src/a.py", + "token": "TODO", + "destination": "follow-up", + "tracker_reference": "", + "approved_destination": "", + "owner": "comment-cleanup-core-bootstrap", + "blocking_disposition": "Await approved follow-up.", + } + policy["obligations"] = [entry] + with pytest.raises(scope.PolicyError, match="exactly one"): + scope.validate_evidence(policy, tmp_path, base, {"src/a.py"}) + entry["tracker_reference"] = "comment-cleanup-follow-up" + scope.validate_evidence(policy, tmp_path, base, {"src/a.py"}) + entry["approved_destination"] = "approved destination" + with pytest.raises(scope.PolicyError, match="exactly one"): + scope.validate_evidence(policy, tmp_path, base, {"src/a.py"}) + + +def test_validator_writes_only_to_ignored_output(tmp_path: Path, policy: dict) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "test@example.com"], check=True) + (tmp_path / "src").mkdir() + (tmp_path / "src/a.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / "quality").mkdir() + (tmp_path / "quality/comment-cleanup-scope.json").write_text(json.dumps(policy), encoding="utf-8") + (tmp_path / ".git/info/exclude").write_text(".todo-batch/\n", encoding="utf-8") + (tmp_path / ".todo-batch").mkdir() + owners = {rule["owner"] for rule in policy["ownership_rules"]} + owners.update(item["owner"] for item in policy["review_dispositions"]) + (tmp_path / ".todo-batch/tasks.txt").write_text( + "".join(f"TODO: {owner}\n" for owner in sorted(owners)), encoding="utf-8" + ) + subprocess.run(["git", "-C", str(tmp_path), "add", "."], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "-qm", "fixture"], check=True) + base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + assert ( + scope.main( + [ + "--root", + str(tmp_path), + "--base", + base, + "--task-set", + ".todo-batch/tasks.txt", + "--output", + ".todo-batch/manifest.json", + ] + ) + == 0 + ) + assert (tmp_path / ".todo-batch/manifest.json").is_file() + assert ( + scope.main( + [ + "--root", + str(tmp_path), + "--base", + base, + "--task-set", + ".todo-batch/tasks.txt", + "--output", + "manifest.json", + ] + ) + == 2 + ) + + +def test_verified_source_slices_have_distinct_blocked_owners() -> None: + repository_policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + roots = scope.validate_roots(repository_policy) + rules = scope.validate_rules(repository_policy) + paths = [ + "benchbox/core/tpch/queries.py", + "benchbox/core/tpcds/dataframe_queries/queries.py", + "benchbox/core/tpcds/generator/runner.py", + "benchbox/core/tpcdi/generator/data.py", + "benchbox/core/tpchavoc/dataframe_queries/queries.py", + "benchbox/core/ssb/queries.py", + "benchbox/core/clickbench/queries.py", + "benchbox/core/read_primitives/dataframe_queries.py", + ] + resolved, findings = scope.owned_paths(paths, roots, rules) + assert not findings + assert all(record["state"] == "blocked" for record in resolved) + assert {record["owner"] for record in resolved} == { + "comment-cleanup-tpch", + "comment-cleanup-tpcds-dataframe", + "comment-cleanup-tpcds-generation", + "comment-cleanup-tpcdi-generation", + "comment-cleanup-tpchavoc-dataframe", + "comment-cleanup-ssb", + "comment-cleanup-clickbench", + "comment-cleanup-read-primitives", + } + + +def test_verified_source_slice_paths_remain_blocked_when_unmapped() -> None: + repository_policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + roots = scope.validate_roots(repository_policy) + rules = scope.validate_rules(repository_policy) + resolved, findings = scope.owned_paths(["benchbox/core/tpchavoc_variants/new.py"], roots, rules) + assert [finding.code for finding in findings] == ["SCOPE001"] + assert resolved[0]["owner"] is None + assert resolved[0]["state"] == "blocked" + + +def test_verified_source_slice_rule_ids_are_unique() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + rules = scope.validate_rules(policy) + ids = [rule["id"] for rule in rules if rule["id"].endswith("-source-slice")] + assert len(ids) >= 20 + assert len(ids) == len(set(ids)) + + +def test_review_dispositions_preserve_original_verdict_and_rationale() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + dispositions = policy["review_dispositions"] + assert len(dispositions) == 18 + assert {item["id"] for item in dispositions} == {f"R{number}" for number in range(1, 19)} + for item in dispositions: + assert item["original_verdict"] == item["disposition"] + assert item["rationale"] + assert item["acceptance_refs"] + + +def test_every_tracked_eula_and_notice_file_has_a_notice_entry() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + listed = {entry["path"] for entry in policy["notices"]} + tracked = scope.git(ROOT, "ls-files", "-z", "--", "_binaries", "_sources").decode().split("\0") + notice_files = { + path for path in tracked if path and Path(path).name in {"EULA.txt", "NOTICE.txt"} and (ROOT / path).exists() + } + assert notice_files + assert notice_files <= listed + + +def test_committed_payloads_and_edges_validate_at_immutable_head() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + head = scope.git(ROOT, "rev-parse", "HEAD").decode().strip() + scope.validate_payloads_and_edges(policy, set(scope.tracked_paths(ROOT, head))) + + +def test_committed_consumer_edges_name_tracked_paths_and_the_docstring_readers() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + tracked = set(scope.git(ROOT, "ls-files", "-z").decode().split("\0")) + edges = policy["consumer_edges"] + assert edges + for edge in edges: + assert edge["producer"] in tracked and edge["consumer"] in tracked + pairs = {(edge["producer"], edge["consumer"]) for edge in edges} + assert ("benchbox/mcp/tools/visualization.py", "tests/unit/mcp/test_surface_defect_regressions.py") in pairs + assert ("benchbox/core/tpch/dataframe_queries.py", "benchbox/core/query_catalog.py") in pairs + assert ("benchbox/core/benchmark_result_validation.py", "docs/reference/python-api/base.rst") in pairs + + +def test_duration_policy_has_exactly_one_exact_path_rule_owned_by_shared_infrastructure() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + rules = [rule for rule in policy["ownership_rules"] if {"path": "tests/duration_policy.py"} in rule["selectors"]] + assert [rule["owner"] for rule in rules] == ["comment-cleanup-shared-infrastructure"] + + +def test_duration_policy_disposition_names_every_importer() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + rule = next(rule for rule in policy["ownership_rules"] if rule["id"] == "root-test-tier-policy") + importers = { + line.split(":", 1)[0] + for line in scope.git( + ROOT, "grep", "-n", "-E", r"(from|import) +tests(\.| +import +)duration_policy", "--", "*.py" + ) + .decode() + .splitlines() + } - {"tests/unit/scripts/test_comment_cleanup_scope.py"} + assert importers + missing = {path for path in importers if path not in rule["blocking_disposition"]} + assert not missing, f"the disposition omits importers: {sorted(missing)}" + + +def test_the_unit_test_umbrella_owns_nothing_and_its_three_children_split_the_tree() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + umbrella = "comment-cleanup-cross-module-unit-tests" + assert not [rule["id"] for rule in policy["ownership_rules"] if rule["owner"] == umbrella] + assert not [edge["consumer"] for edge in policy["consumer_edges"] if edge["owner"] == umbrella] + rules = {rule["id"]: rule for rule in policy["ownership_rules"]} + core, platforms, rest = ( + rules[name] for name in ("unit-tests-core", "unit-tests-platforms", "unit-tests-cli-scripts-rest") + ) + assert {rule["owner"] for rule in (core, platforms, rest)} == { + "comment-cleanup-unit-tests-core", + "comment-cleanup-unit-tests-platforms", + "comment-cleanup-unit-tests-cli-scripts-rest", + } + assert core["priority"] > platforms["priority"] > rest["priority"] + assert {"prefix": "tests/unit/"} in rest["selectors"] and {"prefix": "tests/unit/platforms/"} in platforms[ + "selectors" + ] + + +def test_the_checkers_own_files_and_the_autodoc_edges_have_named_owners() -> None: + policy = scope.load_policy(ROOT / "quality/comment-cleanup-scope.json") + rules = {rule["id"]: rule for rule in policy["ownership_rules"]} + tooling = rules["comment-policy-tooling"] + assert tooling["owner"] == "comment-cleanup-checker" and tooling["priority"] > 20 + assert {"path": "scripts/check_comment_policy.py"} in tooling["selectors"] + assert {"path": "scripts/run_comment_policy.py"} in tooling["selectors"] + registry = rules["comment-policy-registry"] + assert registry["owner"] == "comment-cleanup-exception-register" + assert registry["selectors"] == [{"path": "quality/comment-policy.json"}] + docs_edges = [edge for edge in policy["consumer_edges"] if edge["consumer"].startswith("docs/")] + assert docs_edges and {edge["owner"] for edge in docs_edges} == {"comment-cleanup-contracts-gate"} + + +def _derived_rule(priority: int = 20) -> dict: + return { + "id": "test-import-owner", + "method": "python-imports", + "state": "blocked", + "blocking_disposition": "Derived from imports.", + "priority": priority, + "selectors": [{"prefix": "tests/"}], + } + + +def _resolved(path: str, owner: str | None) -> dict: + return {"path": path, "kind": "x", "owner": owner, "state": "blocked", "blocking_disposition": "b", "rule": "r"} + + +def _git_repo_with(tmp_path: Path, files: dict[str, str]) -> tuple[Path, str]: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + for name, content in files.items(): + target = tmp_path / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "--", *files], check=True) + subprocess.run( + ["git", "-C", str(tmp_path), "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "x"], + check=True, + ) + base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"]).decode().strip() + return tmp_path, base + + +def test_derived_rule_assigns_single_import_owner_and_falls_back_otherwise(tmp_path: Path) -> None: + root, base = _git_repo_with( + tmp_path, + { + "benchbox/__init__.py": "", + "benchbox/a.py": "", + "benchbox/b.py": "", + "tests/test_a.py": "from benchbox.a import thing\n", + "tests/test_ab.py": "import benchbox.a\nimport benchbox.b\n", + "tests/test_facade.py": "from benchbox import thing\n", + "tests/test_none.py": "import os\n", + "tests/broken.py": "def (:\n", + }, + ) + resolved = [ + _resolved("benchbox/a.py", "comment-cleanup-a"), + _resolved("benchbox/b.py", "comment-cleanup-b"), + _resolved("benchbox/__init__.py", "comment-cleanup-boot"), + *( + _resolved(f"tests/{n}.py", "comment-cleanup-fallback") + for n in ("test_a", "test_ab", "test_facade", "test_none", "broken") + ), + ] + scope.apply_derived_rules(resolved, [_derived_rule()], root, base, {}) + owners = {record["path"]: record["owner"] for record in resolved} + assert owners["tests/test_a.py"] == "comment-cleanup-a" + assert owners["tests/test_ab.py"] == "comment-cleanup-fallback" + assert owners["tests/test_facade.py"] == "comment-cleanup-fallback" + assert owners["tests/test_none.py"] == "comment-cleanup-fallback" + assert owners["tests/broken.py"] == "comment-cleanup-fallback" + + +def test_derived_rule_does_not_override_a_higher_priority_rule(tmp_path: Path) -> None: + root, base = _git_repo_with(tmp_path, {"benchbox/a.py": "", "tests/test_a.py": "import benchbox.a\n"}) + resolved = [_resolved("benchbox/a.py", "comment-cleanup-a"), _resolved("tests/test_a.py", "comment-cleanup-exact")] + scope.apply_derived_rules(resolved, [_derived_rule()], root, base, {"tests/test_a.py": 30}) + assert resolved[1]["owner"] == "comment-cleanup-exact" + + +def test_derived_rule_rejects_unknown_method(policy: dict) -> None: + policy["derived_rules"] = [{**_derived_rule(), "method": "guess-from-names"}] + with pytest.raises(scope.PolicyError): + scope.validate_derived_rules(policy) + + +def test_derived_rule_skips_ownership_collisions(tmp_path: Path) -> None: + root, base = _git_repo_with(tmp_path, {"benchbox/a.py": "", "tests/t.py": "import benchbox.a\n"}) + collision = {**_resolved("tests/t.py", None), "rule": ["rule-a", "rule-b"], "competing_owners": ["a", "b"]} + resolved = [_resolved("benchbox/a.py", "comment-cleanup-a"), collision] + scope.apply_derived_rules(resolved, [_derived_rule()], root, base, {}) + assert resolved[1]["owner"] is None + assert resolved[1]["rule"] == ["rule-a", "rule-b"] + + +def test_derived_rule_counts_module_literals_and_rejects_unowned_modules(tmp_path: Path) -> None: + root, base = _git_repo_with( + tmp_path, + { + "benchbox/a.py": "", + "benchbox/b.py": "", + "benchbox/loose.py": "", + "tests/test_patch.py": 'import benchbox.a\npatch("benchbox.b.Thing")\n', + "tests/test_dynamic.py": 'importlib.import_module("benchbox.a")\n', + "tests/test_unowned.py": "import benchbox.a\nimport benchbox.loose\n", + }, + ) + resolved = [ + _resolved("benchbox/a.py", "comment-cleanup-a"), + _resolved("benchbox/b.py", "comment-cleanup-b"), + _resolved("benchbox/loose.py", None), + *( + _resolved(f"tests/{n}.py", "comment-cleanup-fallback") + for n in ("test_patch", "test_dynamic", "test_unowned") + ), + ] + scope.apply_derived_rules(resolved, [_derived_rule()], root, base, {}) + owners = {record["path"]: record["owner"] for record in resolved} + assert owners["tests/test_patch.py"] == "comment-cleanup-fallback" + assert owners["tests/test_dynamic.py"] == "comment-cleanup-a" + assert owners["tests/test_unowned.py"] == "comment-cleanup-fallback" + + +def test_notice_owner_claims_an_unowned_path_and_reports_conflicts() -> None: + notices = [ + {"path": "LICENSE", "owner": "comment-cleanup-scope-policy", "blocking_disposition": "Retain exact bytes."}, + {"path": "NOTICE", "owner": "comment-cleanup-scope-policy", "blocking_disposition": "Retain exact bytes."}, + ] + resolved = [_resolved("LICENSE", None), _resolved("NOTICE", "comment-cleanup-other")] + resolved[0]["rule"] = None + findings = scope.apply_notice_owners(resolved, notices) + assert resolved[0]["owner"] == "comment-cleanup-scope-policy" + assert resolved[0]["rule"] == "notice" + assert resolved[1]["owner"] == "comment-cleanup-other" + assert [finding.code for finding in findings] == ["SCOPE005"] + + +def test_doc_carriers_classify_docstring_readers_and_writers() -> None: + source = b""" +import inspect + +def f(): + parser = P(description=__doc__) + text = inspect.getsource(f) + a = getattr(f, "__doc__", "") + b = f.__doc__ + f.__doc__ = "x" + setattr(f, "__doc__", "y") + globals()["g"].__doc__ = "z" +""" + found = {(role, form) for _, role, form, _target in scope.doc_carriers(source)} + assert found == { + ("reader", "module-docstring-read"), + ("reader", "getsource"), + ("reader", "getattr"), + ("reader", "attribute-read"), + ("writer", "attribute-assignment"), + ("writer", "setattr"), + } + assert scope.doc_carriers(b"def (:\n") == [] + + +def test_unregistered_docstring_writer_is_a_finding(policy: dict) -> None: + carriers = [ + {"path": "src/a.py", "line": 3, "role": "writer", "form": "attribute-assignment", "owner": "o"}, + {"path": "src/b.py", "line": 4, "role": "reader", "form": "getsource", "owner": "o"}, + ] + findings = scope.carrier_findings(carriers, policy) + assert [(finding.code, finding.subject) for finding in findings] == [("SCOPE006", "src/a.py")] + policy["payloads"] = [ + { + "id": "a", + "path": "src/a.py", + "carrier": "x", + "owner": "comment-cleanup-x", + "state": "blocked", + "blocking_disposition": "d", + } + ] + assert scope.carrier_findings(carriers, policy) == [] + + +def test_exact_rule_must_outrank_the_derived_rule() -> None: + rule = {"id": "exact", "priority": 20, "selectors": [{"path": "tests/unit/test_x.py"}]} + derived = [{"id": "d", "priority": 20, "selectors": [{"prefix": "tests/unit/"}]}] + with pytest.raises(scope.PolicyError): + scope.validate_rule_priorities([rule], derived) + scope.validate_rule_priorities([{**rule, "priority": 30}], derived) + scope.validate_rule_priorities([{**rule, "selectors": [{"prefix": "tests/unit/"}], "priority": 5}], derived) + + +def _directive(**overrides: object) -> dict: + entry = { + "path": "src/a.py", + "token": "# noqa: F401", + "count": 1, + "consumer": "Ruff", + "necessity": "Imported for side effects.", + "alternative": "Call an explicit function.", + "owner": "comment-cleanup-core-bootstrap", + "removal_trigger": "Explicit function exists.", + } + entry.update(overrides) + return entry + + +def test_directive_token_and_count_must_match_the_base_file(tmp_path: Path, policy: dict) -> None: + root, base = _git_repo_with(tmp_path, {"src/a.py": "import x # noqa: F401\nimport y # noqa: F401\n"}) + policy["directives"] = [_directive(count=2)] + scope.validate_evidence(policy, root, base, {"src/a.py"}) + policy["directives"] = [_directive(count=1)] + with pytest.raises(scope.PolicyError, match="counts 1"): + scope.validate_evidence(policy, root, base, {"src/a.py"}) + policy["directives"] = [_directive(token="# noqa: N815", count=1)] + with pytest.raises(scope.PolicyError, match="not present"): + scope.validate_evidence(policy, root, base, {"src/a.py"}) + policy["directives"] = [_directive(count=0)] + with pytest.raises(scope.PolicyError, match="integer"): + scope.validate_evidence(policy, root, base, {"src/a.py"}) + + +def test_obligation_token_must_be_present_at_the_base(tmp_path: Path, policy: dict) -> None: + root, base = _git_repo_with(tmp_path, {"src/a.py": "# TODO: follow up\n"}) + entry = { + "path": "src/a.py", + "token": "TODO: elsewhere", + "destination": "follow-up", + "tracker_reference": "existing-item", + "approved_destination": "", + "owner": "comment-cleanup-core-bootstrap", + "blocking_disposition": "Await follow-up.", + } + policy["obligations"] = [entry] + with pytest.raises(scope.PolicyError, match="not present"): + scope.validate_evidence(policy, root, base, {"src/a.py"}) + entry["token"] = "TODO: follow up" + scope.validate_evidence(policy, root, base, {"src/a.py"}) + + +def test_directive_and_obligation_owners_must_match_the_path_owner(policy: dict) -> None: + policy["directives"] = [_directive(owner="comment-cleanup-other")] + policy["obligations"] = [ + { + "path": "src/a.py", + "token": "TODO", + "destination": "d", + "tracker_reference": "t", + "approved_destination": "", + "owner": "comment-cleanup-core-bootstrap", + "blocking_disposition": "b", + } + ] + resolved = [_resolved("src/a.py", "comment-cleanup-core-bootstrap")] + findings = scope.evidence_owner_findings(policy, resolved) + assert [(finding.code, finding.subject) for finding in findings] == [("SCOPE007", "src/a.py")] + assert scope.evidence_owner_findings(policy, [_resolved("src/a.py", None)]) == [] + + +def test_doc_carriers_record_the_writer_target_and_implicit_decorator_readers() -> None: + source = b""" +@click.command() +def with_doc(): + "Implicit help." + +@click.command(help="Explicit.") +def explicit(): + "Docstring." + +@mcp.tool() +async def tool_with_doc(): + "Implicit description." + +@click.group() +def no_doc(): + pass + +impl.__doc__ = "x" +globals()["g"].__doc__ = "y" +setattr(other, "__doc__", "z") +text = inspect.cleandoc(value) +""" + found = scope.doc_carriers(source) + assert [(role, form, target) for _, role, form, target in found if role == "writer"] == [ + ("writer", "attribute-assignment", "impl"), + ("writer", "attribute-assignment", "globals()"), + ("writer", "setattr", "other"), + ] + decorators = [line for line, _role, form, _target in found if form == "decorator-docstring"] + assert len(decorators) == 2 + assert any(form == "cleandoc" for _line, _role, form, _target in found) + + +def test_docstring_writer_must_be_named_by_a_payload(policy: dict) -> None: + carriers = [ + {"path": "src/a.py", "line": 3, "role": "writer", "form": "attribute-assignment", "target": "impl"}, + {"path": "src/a.py", "line": 9, "role": "writer", "form": "attribute-assignment", "target": "other"}, + {"path": "src/a.py", "line": 12, "role": "writer", "form": "attribute-assignment", "target": None}, + ] + policy["payloads"] = [ + { + "id": "a", + "path": "src/a.py", + "carrier": "_impl.__doc__ and impl.__doc__", + "owner": "comment-cleanup-x", + "state": "blocked", + "blocking_disposition": "d", + } + ] + findings = scope.carrier_findings(carriers, policy) + assert [(finding.code, "other" in finding.detail) for finding in findings] == [("SCOPE006", True)] + policy["payloads"][0]["carrier"] = "_other.__doc__" + assert len(scope.carrier_findings(carriers, policy)) == 2 + + +def test_python_comment_markers_count_comments_only_and_unregistered_counts_subtract(policy: dict) -> None: + source = b"""x = "# noqa: E501 TODO in a string" +y = 1 # noqa: E501 +z = 2 # type: ignore[attr-defined] TODO later +# FIXME remove +""" + assert scope.python_comment_markers(source) == (2, 1) + assert scope.python_comment_markers(b"def (:\n") == (0, 0) + markers = [{"path": "a.py", "directives": 5, "todos": 3}] + policy["directives"] = [{"path": "a.py", "count": 2}, {"path": "ci.yml", "count": 4}] + policy["obligations"] = [{"path": "a.py"}, {"path": "nightly.yml"}] + assert scope.unregistered_markers(markers, policy) == (3, 2) + + +@pytest.mark.parametrize( + "comment,counted", + [ + ("# TODO: link the issue", True), + ("# TODO(name): link the issue", True), + ("# FIXME remove", True), + ("#TODO later", True), + ("x = 1 # noqa: E501 TODO: later", True), + ("# see the renderer-consolidation TODO", False), + ("# TODO) only for a divergence", False), + ("# Confirmed (TODO w5): only the cells", False), + ("# see TODO/main/planning/item.yaml", False), + ("# the TODO's w4 stays pure", False), + ("# Per the tuning-keys TODO: do not add new aliases", False), + ], +) +def test_todo_counter_counts_marker_comments_and_not_prose_mentions(comment: str, counted: bool) -> None: + assert scope.python_comment_markers(f"{comment}\n".encode())[1] == int(counted) + + +def test_facade_imports_are_ambiguous_but_submodule_imports_resolve() -> None: + owners = { + "benchbox/__init__.py": "comment-cleanup-boot", + "benchbox/a.py": "comment-cleanup-a", + "benchbox/b/__init__.py": "comment-cleanup-b", + } + assert scope.import_owner(b"from benchbox.a import thing\n", owners) == "comment-cleanup-a" + assert scope.import_owner(b"from benchbox.a import thing\nfrom benchbox import Exported\n", owners) is None + assert scope.import_owner(b"import benchbox\nfrom benchbox.a import thing\n", owners) is None + assert scope.import_owner(b"from benchbox import a\n", owners) == "comment-cleanup-a" + + +def test_malformed_policy_exits_with_a_configuration_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + root, base = _git_repo_with(tmp_path, {"src/a.py": "x = 1\n"}) + (root / "quality").mkdir() + (root / "quality/comment-cleanup-scope.json").write_text("{}", encoding="utf-8") + (root / ".gitignore").write_text(".todo-batch/\n", encoding="utf-8") + (root / ".todo-batch").mkdir() + (root / ".todo-batch/tasks.txt").write_text("comment-cleanup-x\n", encoding="utf-8") + + def broken(path: Path) -> dict: + raise KeyError("ownership_rules") + + monkeypatch.setattr(scope, "load_policy", broken) + code = scope.main(["--root", str(root), "--base", base, "--task-set", ".todo-batch/tasks.txt"]) + assert code == 2 + assert "malformed policy" in capsys.readouterr().err + + +def _external(**overrides: object) -> dict: + entry = { + "selector": {"prefix": "vendor/"}, + "owner": "comment-cleanup-external-ownership", + "provenance": "Vendor kit.", + "governing_requirement": "Owner decision.", + "blocking_disposition": "Excluded.", + } + entry.update(overrides) + return entry + + +def test_external_entry_excludes_unowned_paths_only(policy: dict) -> None: + policy["external_entries"] = [_external()] + entries = scope.validate_external_entries(policy, {"vendor/a.c", "src/a.py"}) + resolved = [ + _resolved("vendor/a.c", None), + _resolved("vendor/b.c", "comment-cleanup-owned"), + _resolved("src/a.py", None), + ] + for record in resolved: + record["rule"] = None if record["owner"] is None else "r" + scope.apply_external_entries(resolved, entries) + assert (resolved[0]["owner"], resolved[0]["state"]) == ("comment-cleanup-external-ownership", "excluded") + assert resolved[0]["blocking_disposition"] == "Excluded." + assert resolved[1]["owner"] == "comment-cleanup-owned" + assert resolved[2]["owner"] is None + + +def test_external_entry_must_match_a_tracked_path(policy: dict) -> None: + policy["external_entries"] = [_external(selector={"prefix": "missing/"})] + with pytest.raises(scope.PolicyError, match="matches no tracked path"): + scope.validate_external_entries(policy, {"src/a.py"}) + policy["external_entries"] = [_external(selector={"glob": "*"})] + with pytest.raises(scope.PolicyError, match="invalid selector"): + scope.validate_external_entries(policy, {"src/a.py"}) + + +def _format_class(**overrides: object) -> dict: + entry = { + "id": "c", + "verifier": "strict-json", + "extensions": [".json"], + "selectors": [{"prefix": "a/"}], + "owner": "comment-cleanup-final-enforcement", + "blocking_disposition": "d", + } + entry.update(overrides) + return entry + + +def test_blocked_format_class_needs_a_blocking_verifier_and_the_reverse(policy: dict) -> None: + policy["format_classes"] = [_format_class(state="blocked")] + with pytest.raises(scope.PolicyError, match="blocking verifier"): + scope.validate_format_classes(policy) + policy["format_classes"] = [_format_class(verifier="any-content")] + with pytest.raises(scope.PolicyError, match="blocking verifier"): + scope.validate_format_classes(policy) + policy["format_classes"] = [_format_class(verifier="any-content", state="blocked")] + assert scope.validate_format_classes(policy) + policy["format_classes"] = [_format_class(state="done")] + with pytest.raises(scope.PolicyError, match="unknown state"): + scope.validate_format_classes(policy) + + +def test_blocked_classes_claim_only_what_the_clean_classes_left_unowned(tmp_path: Path, policy: dict) -> None: + root, base = _git_repo_with( + tmp_path, + { + "a/prose.md": "plain prose\n", + "a/sample.md": "text\n\n```sh\nls\n```\n", + "a/ref.md": "[todo]: target\n", + "a/.keep": "", + "a/tool.py": "x = 1\n", + }, + ) + policy["format_classes"] = [ + _format_class(id="prose", verifier="markdown-prose", extensions=[".md"]), + _format_class(id="empty", verifier="empty-file", extensions=[".keep"]), + _format_class( + id="review", + verifier="markdown-needs-review", + extensions=[".md"], + owner="comment-cleanup-documentation-samples", + state="blocked", + ), + _format_class( + id="helpers", + verifier="any-content", + extensions=[".py"], + owner="comment-cleanup-project-tooling", + state="blocked", + ), + ] + resolved = [_resolved(path, None) for path in ("a/prose.md", "a/sample.md", "a/ref.md", "a/.keep", "a/tool.py")] + for record in resolved: + record["rule"] = None + scope.apply_format_classes(resolved, scope.validate_format_classes(policy), root, base) + states = {record["path"]: (record["owner"], record["state"]) for record in resolved} + assert states["a/prose.md"] == ("comment-cleanup-final-enforcement", "comment-free") + assert states["a/.keep"] == ("comment-cleanup-final-enforcement", "comment-free") + assert states["a/sample.md"] == ("comment-cleanup-documentation-samples", "blocked") + assert states["a/ref.md"] == ("comment-cleanup-documentation-samples", "blocked") + assert states["a/tool.py"] == ("comment-cleanup-project-tooling", "blocked") + + +def test_empty_file_verifier_accepts_only_zero_bytes() -> None: + assert scope.verify_empty_file("a/.gitkeep", b"") + assert not scope.verify_empty_file("a/.gitkeep", b"\n") + + +@pytest.mark.parametrize( + "path,blob,expected", + [ + ("a.json", b'{"a": 1}', True), + ("a.json", b'{"a": 1} // note', False), + ("a.json", b"{\n // note\n}", False), + ("a.jsonl", b'{"a": 1}\n\n{"b": 2}\n', True), + ("a.jsonl", b'{"a": 1}\nnot json\n', False), + ], +) +def test_strict_json_verifier(path: str, blob: bytes, expected: bool) -> None: + assert scope.verify_strict_json(path, blob) is expected + + +def test_png_and_markdown_verifiers() -> None: + assert scope.verify_png_signature("a.png", b"\x89PNG\r\n\x1a\nrest") + assert not scope.verify_png_signature("a.png", b"GIF89a") + assert scope.verify_markdown_prose("a.md", b"# Title\n\nPlain prose.\n") + assert not scope.verify_markdown_prose("a.md", b"text\n\n```python\n# note\n```\n") + assert not scope.verify_markdown_prose("a.md", b"text text\n") + assert scope.verify_markdown_prose("a.md", b"---\ntitle: x\n---\nbody\n") + assert not scope.verify_markdown_prose("a.md", b"---\n# comment\ntitle: x\n---\nbody\n") + assert not scope.verify_markdown_prose("a.md", b"\xff\xfe") + + +def test_format_class_marks_only_verified_unowned_files(tmp_path: Path, policy: dict) -> None: + root, base = _git_repo_with( + tmp_path, + { + "data/ok.json": '{"a": 1}', + "data/bad.json": '{"a": 1} // c', + "data/code.py": "x = 1\n", + "other/ok.json": "{}", + }, + ) + policy["format_classes"] = [ + { + "id": "json-data", + "verifier": "strict-json", + "extensions": [".json"], + "selectors": [{"prefix": "data/"}], + "owner": "comment-cleanup-final-enforcement", + "blocking_disposition": "Parses as strict JSON.", + } + ] + classes = scope.validate_format_classes(policy) + resolved = [_resolved(path, None) for path in ("data/ok.json", "data/bad.json", "data/code.py", "other/ok.json")] + for record in resolved: + record["rule"] = None + scope.apply_format_classes(resolved, classes, root, base) + states = {record["path"]: (record["owner"], record["state"]) for record in resolved} + assert states["data/ok.json"] == ("comment-cleanup-final-enforcement", "comment-free") + assert states["data/bad.json"][0] is None + assert states["data/code.py"][0] is None + assert states["other/ok.json"][0] is None + + +def test_format_class_rejects_unknown_verifier_and_bad_extension(policy: dict) -> None: + entry = { + "id": "x", + "verifier": "guess", + "extensions": [".json"], + "selectors": [{"prefix": "a/"}], + "owner": "comment-cleanup-final-enforcement", + "blocking_disposition": "d", + } + policy["format_classes"] = [entry] + with pytest.raises(scope.PolicyError, match="unknown verifier"): + scope.validate_format_classes(policy) + entry.update(verifier="strict-json", extensions=["json"]) + with pytest.raises(scope.PolicyError, match="start with a dot"): + scope.validate_format_classes(policy) + + +@pytest.mark.parametrize( + "path,blob,expected", + [ + ("a.json", b'{"_comment": ["note"], "a": 1}', False), + ("a.json", b'{"nested": {"$comment": "note"}}', False), + ("a.json", b'{"//": "note"}', False), + ("a.json", b'{"__comment": "note"}', False), + ("a.json", b'{"items": [{"comment": "note"}]}', False), + ("a.json", b'{"a": 1, "a": 2}', False), + ("a.json", b'{"a": NaN}', False), + ("a.json", b'\xef\xbb\xbf{"a": 1}', False), + ("a.json", b"", False), + ("a.jsonl", b"\n\n", False), + ], +) +def test_strict_json_verifier_rejects_comment_conventions_and_ambiguity(path: str, blob: bytes, expected: bool) -> None: + assert scope.verify_strict_json(path, blob) is expected + + +@pytest.mark.parametrize( + "blob", + [ + b"text\n\n indented code\n", + b"text\n\n\tindented code\n", + b"---\ntitle: x # note\n---\nbody\n", + b"---\ntitle: x\n", + b"+++\ntitle = 'x'\n+++\nbody\n", + b"[//]: # (hidden)\n", + b"text {/* hidden */}\n", + b"% myst comment\n", + b"> ```\n> code\n> ```\n", + b"- item\n ```\n code\n ```\n", + b"1. ```sh\n", + b"~~~\ncode\n~~~\n", + b"[//]: <> (hidden)\n", + b"[comment]: # (hidden)\n", + b"[//]:# (hidden)\n", + b"{% comment %}hidden{% endcomment %}\n", + b"{# hidden #}\n", + b"
code
\n", + b"\n", + b" % myst comment\n", + b"[todo]: ../planning/item.yaml\n", + b'[a]: "hidden title"\n', + b"- [a]: target\n", + b"> [a]: target\n", + ], +) +def test_markdown_prose_verifier_rejects_every_comment_or_code_form(blob: bytes) -> None: + assert not scope.verify_markdown_prose("a.md", blob) + + +def test_markdown_prose_verifier_keeps_visible_links_and_footnotes() -> None: + blob = b"See [the guide](guide.md) and the note[^1].\n\nRef: see [a]: b in running text.\n\n[^1]: A visible note.\n" + assert scope.verify_markdown_prose("a.md", blob) + + +def test_sql_verifier_requires_the_absence_of_comment_markers() -> None: + assert scope.verify_sql_without_comment_markers("a.sql", b"SELECT 1 FROM t WHERE a = 'x';\n") + for blob in (b"SELECT 1; -- note\n", b"/* note */ SELECT 1;", b"SELECT 1; # note", b"\xff"): + assert not scope.verify_sql_without_comment_markers("a.sql", blob) + + +def test_external_and_format_classes_leave_collisions_and_notices_alone(tmp_path: Path, policy: dict) -> None: + root, base = _git_repo_with(tmp_path, {"vendor/a.json": "{}", "vendor/b.json": "{}"}) + policy["external_entries"] = [_external()] + policy["format_classes"] = [ + { + "id": "json-data", + "verifier": "strict-json", + "extensions": [".json"], + "selectors": [{"prefix": "vendor/"}], + "owner": "comment-cleanup-final-enforcement", + "blocking_disposition": "Strict JSON.", + } + ] + notice = {"path": "vendor/a.json", "owner": "comment-cleanup-scope-policy", "blocking_disposition": "Retain."} + resolved = [_resolved("vendor/a.json", None), _resolved("vendor/b.json", None)] + resolved[0]["rule"] = None + resolved[1].update(rule=["rule-a", "rule-b"], competing_owners=["a", "b"]) + scope.apply_notice_owners(resolved, [notice]) + scope.apply_external_entries(resolved, scope.validate_external_entries(policy, {"vendor/a.json", "vendor/b.json"})) + scope.apply_format_classes(resolved, scope.validate_format_classes(policy), root, base) + assert (resolved[0]["owner"], resolved[0]["state"]) == ("comment-cleanup-scope-policy", "blocked") + assert resolved[1]["owner"] is None and resolved[1]["rule"] == ["rule-a", "rule-b"] + + +def test_overlapping_external_prefixes_are_rejected(policy: dict) -> None: + policy["external_entries"] = [_external(), _external(selector={"prefix": "vendor/sub/"})] + with pytest.raises(scope.PolicyError, match="overlaps"): + scope.validate_external_entries(policy, {"vendor/sub/a.c"}) diff --git a/tests/unit/scripts/test_comment_parity.py b/tests/unit/scripts/test_comment_parity.py new file mode 100644 index 0000000000..3edf93b136 --- /dev/null +++ b/tests/unit/scripts/test_comment_parity.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import comment_parity as parity +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +def compare(base: str, head: str) -> parity.FileReport: + return parity.compare_python("a.py", base.encode(), head.encode()) + + +@pytest.mark.parametrize( + "base,head,comments,docstrings,passes", + [ + ("x = 1 # note\n", "x = 1\n", 1, 0, 0), + ("# note\nx = 1\n", "x = 1\n", 1, 0, 0), + ('def f():\n """doc"""\n return 1\n', "def f():\n return 1\n", 0, 1, 0), + ('"""module"""\nimport os\n', "import os\n", 0, 1, 0), + ('class A:\n """doc"""\n x = 1\n', "class A:\n x = 1\n", 0, 1, 0), + ('def f():\n """doc"""\n', "def f():\n pass\n", 0, 1, 1), + ('class A:\n """doc"""\n', "class A:\n pass\n", 0, 1, 1), + ('def f():\n """doc"""\n pass\n', "def f():\n pass\n", 0, 1, 0), + ('def f():\n """doc"""\n', 'def f():\n """doc"""\n', 0, 0, 0), + ("def f(): return 1\n", "def f(): return 1\n", 0, 0, 0), + ], +) +def test_removing_only_comments_and_leading_docstrings_is_parity( + base: str, head: str, comments: int, docstrings: int, passes: int +) -> None: + report = compare(base, head) + assert report.status == "ok", report.problems + assert (report.comments_removed, report.docstrings_removed, report.pass_added) == (comments, docstrings, passes) + + +@pytest.mark.parametrize( + "base,head", + [ + ("x = 1\n", "x = 2\n"), + ('query = "SELECT 1"\n', 'query = "SELECT 2"\n'), + ('def f():\n """doc"""\n', "def f():\n ...\n"), + ('def f():\n """doc"""\n return 1\n', "def f():\n pass\n return 1\n"), + ('def f():\n x = 1\n "not a docstring"\n return x\n', "def f():\n x = 1\n return x\n"), + ("def f(x): # type: (int) -> int\n return x\n", "def f(x):\n return x\n"), + ("import os # type: ignore\n", "import os\n"), + ("def f():\n return 1\n", "def g():\n return 1\n"), + ('def f():\n """doc"""\n return 1\n', 'def f():\n "not the same docstring"\n return 1\n'), + ("a = 1\nb = 2\n", "b = 2\na = 1\n"), + ], +) +def test_any_other_change_is_drift(base: str, head: str) -> None: + report = compare(base, head) + assert report.status == "drift" + assert report.problems + + +@pytest.mark.parametrize( + "base,head,wording", + [ + ("x = 1\n", "x = 1 # new\n", "comment"), + ("x = 1\n", "# new\nx = 1\n", "comment"), + ("def f():\n return 1\n", 'def f():\n """new"""\n return 1\n', "docstring"), + ('def f():\n """old"""\n return 1 # old\n', 'def f():\n """new"""\n return 1\n', "docstring"), + ("x = 1 # old\n", "x = 1 # other\n", "comment"), + ], +) +def test_adding_or_rewording_text_is_drift_even_when_the_syntax_tree_matches( + base: str, head: str, wording: str +) -> None: + report = compare(base, head) + assert report.status == "drift" + assert any(wording in problem for problem in report.problems) + + +@pytest.mark.parametrize( + "base,head", + [ + ("#!/usr/bin/env python3\nx = 1\n", "x = 1\n"), + ("#!/usr/bin/env python3\nx = 1\n", "#!/usr/bin/python\nx = 1\n"), + ("# -*- coding: latin-1 -*-\nx = 1\n", "x = 1\n"), + ("#!/usr/bin/env python3\n# coding: utf-8\nx = 1\n", "#!/usr/bin/env python3\nx = 1\n"), + ], +) +def test_removing_or_changing_a_shebang_or_encoding_declaration_is_drift(base: str, head: str) -> None: + report = compare(base, head) + assert report.status == "drift" + assert any("shebang or encoding" in problem for problem in report.problems) + + +def test_keeping_a_shebang_and_encoding_declaration_while_removing_other_comments_is_parity() -> None: + base = "#!/usr/bin/env python3\n# coding: utf-8\nx = 1 # note\n# trailing\n" + report = compare(base, "#!/usr/bin/env python3\n# coding: utf-8\nx = 1\n") + assert report.status == "ok" and report.comments_removed == 2 + + +def test_a_removal_cannot_pay_for_an_addition_in_the_same_file() -> None: + report = compare("x = 1 # old\ny = 2\n", "x = 1\ny = 2 # new\n") + assert report.status == "drift" and report.comments_removed == 1 + + +@pytest.mark.parametrize( + "base,head", + [ + ("# a\nx = 1\ny = 2\n", "x = 1\n# a\ny = 2\n"), + ("x = 1 # a\ny = 2\n", "x = 1\ny = 2 # a\n"), + ("x = 1 # a\ny = 2\n", "# a\nx = 1\ny = 2\n"), + ( + "def f():\n return 1\n\n\n# a\ndef g():\n return 2\n", + "def f():\n return 1\n\n\ndef g():\n # a\n return 2\n", + ), + ], +) +def test_a_comment_moved_to_another_position_is_drift(base: str, head: str) -> None: + report = compare(base, head) + assert report.status == "drift" + assert any("at that position" in problem for problem in report.problems) + + +def test_a_retained_comment_stays_bound_when_neighbouring_text_is_removed() -> None: + base = '"""m"""\n# keep\n\n\ndef f():\n """d""" # drop\n\n\n# keep too\nx = 1 # drop\n' + report = compare(base, "# keep\n\n\ndef f():\n pass\n\n\n# keep too\nx = 1\n") + assert report.status == "ok" and report.comments_removed == 2 + + +@pytest.mark.parametrize("source", ["def broken(:\n", "x = (\n", "\tx = 1\n y = 2\n"]) +def test_unparseable_source_is_an_error_not_a_pass(source: str) -> None: + assert compare("x = 1\n", source).status == "error" + assert compare(source, "x = 1\n").status == "error" + + +def test_comparison_does_not_depend_on_line_numbers_or_blank_lines() -> None: + report = compare('"""m"""\n\n\n\ndef f():\n\n """d"""\n\n return 1\n', "def f():\n return 1\n") + assert report.status == "ok" and report.docstrings_removed == 2 + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "commit.gpgsign=false", + "-c", + "user.name=T", + "-c", + "user.email=t@e.invalid", + *args, + ], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + git(tmp_path, "init", "-q", "--initial-branch=trunk") + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg/a.py").write_text( + '"""module"""\n\n\ndef f():\n """doc"""\n return 1 # note\n', encoding="utf-8" + ) + (tmp_path / "pkg/b.py").write_text("x = 1 # keep\n", encoding="utf-8") + (tmp_path / "notes.md").write_text("text\n", encoding="utf-8") + git(tmp_path, "add", "-A") + git(tmp_path, "commit", "-qm", "base") + return tmp_path + + +def test_cli_accepts_a_deletion_only_change_in_the_worktree_and_reports_totals( + repo: Path, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + (repo / "pkg/a.py").write_text("def f():\n return 1\n", encoding="utf-8") + base = git(repo, "rev-parse", "HEAD") + out = tmp_path / "report.json" + assert parity.main(["--root", str(repo), "--base", base, "--json-out", str(out)]) == 0 + assert "1 files, 1 ok, 0 drift" in capsys.readouterr().out + report = json.loads(out.read_text(encoding="utf-8")) + assert report == [ + { + "path": "pkg/a.py", + "status": "ok", + "comments_removed": 1, + "docstrings_removed": 2, + "pass_added": 0, + "problems": [], + } + ] + + +def test_cli_compares_a_committed_head_and_restricts_to_path_prefixes( + repo: Path, capsys: pytest.CaptureFixture[str] +) -> None: + base = git(repo, "rev-parse", "HEAD") + (repo / "pkg/a.py").write_text("def f():\n return 2\n", encoding="utf-8") + (repo / "pkg/b.py").write_text("x = 1\n", encoding="utf-8") + git(repo, "commit", "-aqm", "change") + assert parity.main(["--root", str(repo), "--base", base, "--head", "HEAD"]) == 1 + assert "pkg/a.py: drift" in capsys.readouterr().out + assert parity.main(["--root", str(repo), "--base", base, "--head", "HEAD", "--path", "pkg/b.py"]) == 0 + + +def test_cli_rejects_added_and_deleted_files_and_unverified_languages( + repo: Path, capsys: pytest.CaptureFixture[str] +) -> None: + base = git(repo, "rev-parse", "HEAD") + (repo / "pkg/new.py").write_text("x = 1\n", encoding="utf-8") + (repo / "pkg/b.py").unlink() + (repo / "notes.md").write_text("edited\n", encoding="utf-8") + assert parity.main(["--root", str(repo), "--base", base]) == 1 + out = capsys.readouterr().out + assert "pkg/new.py: drift: file added" in out + assert "pkg/b.py: drift: file deleted" in out + assert "notes.md: unverified: no comparator for .md" in out + (repo / "pkg/new.py").unlink() + git(repo, "checkout", "pkg/b.py") + assert parity.main(["--root", str(repo), "--base", base]) == 1 + assert parity.main(["--root", str(repo), "--base", base, "--unverified-ok", "md"]) == 0 + assert "1 skipped" in capsys.readouterr().out + + +def test_cli_rejects_a_file_mode_change_in_the_worktree_and_in_a_commit( + repo: Path, capsys: pytest.CaptureFixture[str] +) -> None: + base = git(repo, "rev-parse", "HEAD") + (repo / "pkg/b.py").chmod(0o755) + (repo / "notes.md").chmod(0o755) + assert parity.main(["--root", str(repo), "--base", base, "--unverified-ok", "md"]) == 1 + out = capsys.readouterr().out + assert "pkg/b.py: drift: file mode or type changed from 100644 to 100755" in out + assert "notes.md: drift: file mode or type changed" in out + git(repo, "-c", "core.fileMode=true", "commit", "-aqm", "mode") + assert parity.main(["--root", str(repo), "--base", base, "--head", "HEAD", "--path", "pkg/b.py"]) == 1 + assert "100644 to 100755" in capsys.readouterr().out + + +def test_cli_rejects_a_file_replaced_by_a_symbolic_link(repo: Path, capsys: pytest.CaptureFixture[str]) -> None: + base = git(repo, "rev-parse", "HEAD") + (repo / "pkg/b.py").unlink() + (repo / "pkg/b.py").symlink_to("a.py") + assert parity.main(["--root", str(repo), "--base", base, "--path", "pkg/b.py"]) == 1 + assert "100644 to 120000" in capsys.readouterr().out + + +def test_cli_reports_a_git_failure_with_status_two(repo: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert parity.main(["--root", str(repo), "--base", "f" * 40]) == 2 + assert "comment-parity:" in capsys.readouterr().err diff --git a/tests/unit/scripts/test_comment_policy.py b/tests/unit/scripts/test_comment_policy.py new file mode 100644 index 0000000000..a0a034d65d --- /dev/null +++ b/tests/unit/scripts/test_comment_policy.py @@ -0,0 +1,1327 @@ +from __future__ import annotations + +import ast +import importlib.util +import json +import os +import subprocess +import sys +from copy import deepcopy +from datetime import date, timedelta +from pathlib import Path +from typing import Any + +import check_comment_policy +import pytest +import run_comment_policy as policy_runner +import yaml +from check_comment_policy import allowed, check_ratchet, introduced, load_policy, main, scan_sources, source_paths +from comment_syntax import Finding, javascript_requests, python_findings, scan, sql_comments +from run_comment_policy import TRUSTED_FILES, parser_environment, resolve_base + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + +ROOT = Path(__file__).resolve().parents[3] +EMPTY_POLICY = {"version": 1, "external": [], "completed": [], "exceptions": []} + + +def policy(**changes: object) -> dict: + return {**deepcopy(EMPTY_POLICY), **changes} + + +@pytest.mark.parametrize( + "source,kind,symbol", + [ + ('"module prose"\n', "docstring", ""), + ('class C:\n "class prose"\n', "docstring", "C"), + ('async def f():\n "function prose"\n', "docstring", "f"), + ('def f():\n x = 1\n "inert prose"\n', "docstring", "f"), + ('def f():\n "a" "b"\n', "docstring", "f"), + ('f.__doc__ = "prose"\n', "runtime-docstring", ""), + ('__doc__ = "prose"\n', "runtime-docstring", ""), + ('f.__doc__: str = "prose"\n', "runtime-docstring", ""), + ('setattr(f, "__doc__", "prose")\n', "runtime-docstring", ""), + ('f.__dict__["__doc__"] = "prose"\n', "runtime-docstring", ""), + ("def f():\n return 1 # explanation\n", "comment", "f"), + ], +) +def test_python_prohibited_forms(source: str, kind: str, symbol: str) -> None: + findings = python_findings("a.py", source) + assert [(f.kind, f.symbol) for f in findings] == [(kind, symbol)] + + +def test_python_strings_help_and_reads_are_data() -> None: + assert not python_findings("a.py", 'help = "# useful help"\nx = f.__doc__\n') + + +def test_runtime_assignment_payload_is_part_of_identity() -> None: + before = python_findings("a.py", 'f.__doc__ = "before"') + after = python_findings("a.py", 'f.__doc__ = "after"') + assert introduced(after, before, []) == after + + +@pytest.mark.parametrize("source", ["def broken(", '"unterminated', "if True:\n"]) +def test_python_syntax_failure_is_visible(source: str) -> None: + assert scan("a.py", source, "python")[0].kind == "coverage-error" + + +@pytest.mark.parametrize( + "source,expected", + [ + ("SELECT '-- payload', \"-- name\", `#name`, [--name], $$--data$$; -- comment", ["-- comment"]), + ("SELECT $tag$/*payload*/$tag$; /* outer /* inner */ end */", ["/* outer /* inner */ end */"]), + ("SELECT 1 # comment", ["# comment"]), + ("SELECT 'it''s -- data';", []), + ], +) +def test_sql_literals_and_nested_comments(source: str, expected: list[str]) -> None: + assert [text for _, text in sql_comments(source, "tsql")] == expected + + +@pytest.mark.parametrize("source", ["SELECT 'unterminated", "SELECT $$unterminated", "/* unterminated"]) +def test_sql_unterminated_constructs_fail(source: str) -> None: + assert scan("a.sql", source, "sql")[0].kind == "coverage-error" + + +@pytest.mark.parametrize( + "lang,source,text", + [ + ("bash", "cat <<'EOF'\n# payload\nEOF\necho x#data\n# explanation\n", "# explanation"), + ("yaml", "value: |\n # payload\n# explanation\n", "# explanation"), + ("toml", 'value = "# payload"\n# explanation\n', "# explanation"), + ("css", 'a { content: "/*payload*/"; } /* explanation */', "/* explanation */"), + ("html", '

value

', ""), + ("make", "name := value\n# explanation\n", "# explanation"), + ("docker", "FROM python:3.11\n# explanation\n", "# explanation"), + ], +) +def test_lexers_preserve_data_and_find_comments(lang: str, source: str, text: str) -> None: + findings = scan("a", source, lang) + assert [(f.kind, f.text) for f in findings] == [("comment", text)] + + +def test_native_request_and_payload_response_contract(monkeypatch: pytest.MonkeyPatch) -> None: + source = '' + requests = javascript_requests("a.html", source, "html") + assert len(requests) == 1 + + def native_run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess: + assert command[0] == "node" + assert json.loads(str(kwargs["input"])) == requests + rows = [ + {"line": 1, "kind": "comment", "text": "// explanation", "symbol": "f"}, + {"line": 1, "kind": "payload", "text": "# executable prose", "symbol": "f", "language": "python"}, + ] + return subprocess.CompletedProcess(command, 0, json.dumps(dict.fromkeys(requests, rows))) + + monkeypatch.setattr("check_comment_policy.subprocess.run", native_run) + findings = scan_sources(ROOT, {"a.html": source.encode()}, policy()) + assert [(f.kind, f.text, f.symbol) for f in findings] == [ + ("comment", "// explanation", "script:0:f"), + ("comment", "# executable prose", "script:0:f:payload:"), + ] + + +def test_yaml_run_and_notebook_cells() -> None: + findings = scan("ci.yml", "run: |\n echo ok\n # explanation\n", "yaml") + assert [(f.kind, f.text) for f in findings] == [("comment", "# explanation")] + source = json.dumps({"cells": [{"id": "stable", "cell_type": "code", "source": ["# explanation\n"]}]}) + assert scan("a.ipynb", source, "notebook")[0].symbol == "cell:stable:" + + +def test_executable_examples_and_unknown_languages() -> None: + assert scan("docs/a.md", "```python\n# explanation\n```\n", "examples")[0].line == 2 + assert scan("docs/a.md", "```unknown-runtime\ncode\n```\n", "examples")[0].kind == "coverage-error" + assert not scan("docs/a.md", "```text\n# prose\n```\n", "examples") + + +def exception(text: str = "# noqa: F401", **changes: str) -> dict: + return { + "path": "a.py", + "symbol": "", + "text": text, + "kind": "directive", + "consumer": "pyproject.toml", + "necessity": "The import is an intentionally exported compatibility name.", + "alternative": "Replacing the public export would break callers.", + "owner": "maintainers", + "removal": "Remove when the export is retired.", + "expires": (date.today() + timedelta(days=90)).isoformat(), + **changes, + } + + +@pytest.mark.parametrize("text", ["# noqa: F401 - explanation", "# noqa", "# type: ignore", "# why this is needed"]) +def test_directive_grammar_rejects_narrative_and_blanket_suppressions(text: str) -> None: + with pytest.raises(ValueError): + load_policy(json.dumps(policy(exceptions=[exception(text)])).encode()) + + +def test_valid_directive_needs_exact_registry_identity() -> None: + registered = load_policy(json.dumps(policy(exceptions=[exception()])).encode()) + finding = Finding("a.py", 20, "comment", "# noqa: F401") + assert allowed(finding, registered, "") + assert not allowed(finding, policy(), "") + assert not allowed(Finding("other.py", 20, "comment", finding.text), registered, "") + assert not allowed(Finding("a.py", 20, "comment", finding.text, "different"), registered, "") + + +def test_expired_or_explanatory_exception_fails() -> None: + with pytest.raises(ValueError): + load_policy(json.dumps(policy(exceptions=[exception(expires="2000-01-01")])).encode()) + with pytest.raises(ValueError): + load_policy(json.dumps(policy(exceptions=[exception(kind="explanation")])).encode()) + + +@pytest.mark.parametrize( + "line,text,accepted", + [ + (1, "#!/usr/bin/env python3", True), + (2, "#!/usr/bin/env python3", False), + (1, "#!/usr/bin/env python3 # explanation", False), + (1, "# coding: utf-8", False), + (1, "# coding: latin-1", True), + (3, "# coding: latin-1", False), + ], +) +def test_structural_directive_positions(line: int, text: str, accepted: bool) -> None: + assert allowed(Finding("a.py", line, "comment", text), policy(), "\n") is accepted + + +def test_delta_compares_exact_multisets_and_completed_scopes() -> None: + old = Finding("a.py", 1, "comment", "# old", "f") + moved = Finding("a.py", 100, "comment", "# old", "f") + replacement = Finding("a.py", 1, "comment", "# new", "f") + assert not introduced([moved], [old], []) + assert introduced([old, old], [old], []) == [old] + assert introduced([replacement], [old], []) == [replacement] + assert introduced([moved], [old], ["a.py"]) == [moved] + assert introduced([Finding("a.py", 1, "comment", "# old", "g")], [old], []) + + +def test_policy_cannot_weaken_completed_or_external_scopes() -> None: + with pytest.raises(ValueError): + check_ratchet(policy(), policy(completed=["a.py"])) + entry = {"path": "benchbox/", "owner": "fake", "provenance": "a.md"} + with pytest.raises(ValueError): + check_ratchet(policy(external=[entry]), policy()) + + +def git_repo(tmp_path: Path, source: str) -> str: + (tmp_path / "quality").mkdir() + (tmp_path / "quality/comment-policy.json").write_text(json.dumps(policy()), encoding="utf-8") + (tmp_path / "a.py").write_text(source, encoding="utf-8") + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "add", "a.py", "quality/comment-policy.json"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ], + check=True, + ) + return subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + + +def test_transition_worktree_and_staged_content(tmp_path: Path) -> None: + base = git_repo(tmp_path, "# old\nx = 1\n") + args = ["--root", str(tmp_path), "--mode", "transition", "--base", base] + assert main(args) == 0 + (tmp_path / "a.py").write_text("# new\nx = 1\n", encoding="utf-8") + assert main(args) == 1 + assert main([*args, "--staged"]) == 0 + subprocess.run(["git", "-C", str(tmp_path), "add", "a.py"], check=True) + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") + assert main([*args, "--staged"]) == 1 + + +def test_enforcement_value_is_validated_and_defaults_to_blocking() -> None: + assert load_policy(json.dumps(policy()).encode()).get("enforcement", "blocking") == "blocking" + assert load_policy(json.dumps(policy(enforcement="advisory")).encode())["enforcement"] == "advisory" + with pytest.raises(ValueError, match="advisory or blocking"): + load_policy(json.dumps(policy(enforcement="warn")).encode()) + assert load_policy((ROOT / "quality/comment-policy.json").read_bytes())["enforcement"] in {"advisory", "blocking"} + for malformed in ([], {}, 1, None): + with pytest.raises(ValueError, match="advisory or blocking"): + load_policy(json.dumps(policy(enforcement=malformed)).encode()) + + +def test_enforcement_can_be_tightened_but_never_relaxed() -> None: + check_ratchet(policy(enforcement="blocking"), policy(enforcement="advisory")) + check_ratchet(policy(enforcement="advisory"), policy(enforcement="advisory")) + check_ratchet(policy(enforcement="blocking"), policy()) + with pytest.raises(ValueError, match="relaxed"): + check_ratchet(policy(enforcement="advisory"), policy(enforcement="blocking")) + with pytest.raises(ValueError, match="relaxed"): + check_ratchet(policy(enforcement="advisory"), policy()) + + +def test_advisory_enforcement_reports_findings_without_failing_the_comparison( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + base = git_repo(tmp_path, "x = 1\n") + args = ["--root", str(tmp_path), "--mode", "transition", "--base", base] + (tmp_path / "a.py").write_text("# new\nx = 1\n", encoding="utf-8") + assert main(args) == 1 + (tmp_path / "quality/comment-policy.json").write_text(json.dumps(policy(enforcement="advisory")), encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "quality/comment-policy.json"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=F", + "-c", + "user.email=f@e.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "advisory", + ], + check=True, + ) + advisory_base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + capsys.readouterr() + advisory = ["--root", str(tmp_path), "--mode", "transition", "--base", advisory_base] + assert main(advisory) == 0 + out = capsys.readouterr().out + assert "a.py:1: CP comment" in out + assert "enforcement is advisory" in out + assert main(["--root", str(tmp_path), "--mode", "strict"]) == 1 + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", "f" * 40]) == 2 + + +def test_advisory_annotations_escape_path_and_text(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_ACTIONS", "true") + printed: list[str] = [] + monkeypatch.setattr("builtins.print", lambda *args, **_: printed.append(" ".join(map(str, args)))) + finding = Finding("dir,x::y%z.py", 7, "comment", "# 100%\r::set-output name=a::b") + assert check_comment_policy.exit_status("transition", policy(enforcement="advisory"), [finding]) == 0 + annotation = next(line for line in printed if line.startswith("::warning")) + assert annotation.startswith("::warning file=dir%2Cx%3A%3Ay%25z.py,line=7::") + assert "\r" not in annotation and "set-output" not in annotation + + +def commit_policy(tmp_path: Path, enforcement: str) -> str: + (tmp_path / "quality/comment-policy.json").write_text(json.dumps(policy(enforcement=enforcement)), encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "-A", "quality/comment-policy.json"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=F", + "-c", + "user.email=f@e.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + enforcement, + "--allow-empty", + ], + check=True, + ) + return subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + + +def test_advisory_reports_unanalyzable_input_but_still_returns_zero( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + git_repo(tmp_path, "x = 1\n") + base = commit_policy(tmp_path, "advisory") + (tmp_path / "a.py").write_text("def broken(\n", encoding="utf-8") + capsys.readouterr() + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", base]) == 0 + out = capsys.readouterr().out + assert "CP coverage-error" in out + assert "(1 are inputs the checker could not analyze)" in out + + +def test_flipping_to_blocking_does_not_block_itself_but_the_next_change_is(tmp_path: Path) -> None: + git_repo(tmp_path, "x = 1\n") + advisory_base = commit_policy(tmp_path, "advisory") + (tmp_path / "quality/comment-policy.json").write_text(json.dumps(policy(enforcement="blocking")), encoding="utf-8") + (tmp_path / "a.py").write_text("# new\nx = 1\n", encoding="utf-8") + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", advisory_base]) == 0 + subprocess.run(["git", "-C", str(tmp_path), "add", "a.py", "quality/comment-policy.json"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=F", + "-c", + "user.email=f@e.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "flip", + ], + check=True, + ) + blocking_base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + (tmp_path / "a.py").write_text("# new\n# another\nx = 1\n", encoding="utf-8") + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", blocking_base]) == 1 + assert main(["--root", str(tmp_path), "--mode", "report"]) == 0 + + +def test_hostile_paths_cannot_start_a_workflow_command_in_the_output( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("GITHUB_ACTIONS", "true") + git_repo(tmp_path, "x = 1\n") + base = commit_policy(tmp_path, "advisory") + hostile = "::add-mask::secret.py" + (tmp_path / hostile).write_text("# new\n", encoding="utf-8") + (tmp_path / "##[stop-commands]hidden.py").write_text("# ##[add-mask]comment-policy\n", encoding="utf-8") + newline_name = "one.py\n::stop-commands::token\ntwo.py" + try: + (tmp_path / newline_name).write_text("# new\n", encoding="utf-8") + except OSError: + newline_name = "" + capsys.readouterr() + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", base]) == 0 + output = capsys.readouterr().out + lines = output.splitlines() + assert "##[" not in output + assert not [line for line in lines if line.lstrip().startswith("::") and not line.startswith("::warning ")] + assert not any(line.lstrip().startswith(("::add-mask", "::stop-commands")) for line in lines) + if newline_name: + assert not any(line.strip() == "::stop-commands::token" for line in lines) + + +def test_legacy_command_markers_are_neutralized_in_text_and_annotations() -> None: + for value in ("# ##[add-mask]comment-policy", "scripts/##[stop-commands]hidden.py"): + assert "##[" not in check_comment_policy.plain_text(value) + assert "##[" not in policy_runner.plain_text(value) + assert "##[" not in check_comment_policy.annotation_text(value) + assert "##[" not in check_comment_policy.annotation_text(value, property_value=True) + assert policy_runner.plain_text("::stop-commands::x").startswith("\\::") + assert policy_runner.plain_text("line one\n::add-mask::x") == "line one\\x0a::add-mask::x" + + +@pytest.mark.parametrize("fails", [False, True]) +def test_native_test_output_runs_between_stop_and_resume_tokens( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], fails: bool, tmp_path: Path +) -> None: + monkeypatch.setenv("GITHUB_ACTIONS", "true") + seen: list[str] = [] + + def native_run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess: + before = capsys.readouterr().out.splitlines() + assert len(before) == 1 and before[0].startswith("::stop-commands::") + seen.append(before[0]) + assert kwargs["stderr"] is subprocess.STDOUT + print("child output") + if fails: + raise subprocess.CalledProcessError(1, command) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(policy_runner.subprocess, "run", native_run) + if fails: + with pytest.raises(subprocess.CalledProcessError): + policy_runner.run_native_tests(tmp_path, tmp_path, {}) + else: + policy_runner.run_native_tests(tmp_path, tmp_path, {}) + after = capsys.readouterr().out.splitlines() + token = seen[0].removeprefix("::stop-commands::") + assert len(token) == 32 + assert after == ["child output", f"::{token}::"] + + +def test_comment_policy_job_sets_up_every_tool_before_the_candidate_checkout() -> None: + workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + steps = workflow["jobs"]["comment-policy"]["steps"] + uses = [str(step.get("uses", "")).split("@")[0] for step in steps] + checkout = uses.index("actions/checkout") + for tool in ("actions/setup-python", "astral-sh/setup-uv", "actions/setup-node"): + assert uses.index(tool) < checkout, f"{tool} must run before the candidate checkout" + assert checkout == max(index for index, name in enumerate(uses) if name) and steps[checkout + 1]["name"].startswith( + "Enforce" + ) + + +def test_comment_policy_job_does_not_let_setup_uv_scan_the_candidate_checkout() -> None: + workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + steps = workflow["jobs"]["comment-policy"]["steps"] + uv = next(step for step in steps if str(step.get("uses", "")).startswith("astral-sh/setup-uv@")) + assert uv["with"] == { + "enable-cache": False, + "working-directory": "${{ runner.temp }}", + "ignore-empty-workdir": True, + } + + +def test_native_test_output_is_not_wrapped_outside_github_actions( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.setattr( + policy_runner.subprocess, "run", lambda command, **kwargs: subprocess.CompletedProcess(command, 0) + ) + policy_runner.run_native_tests(tmp_path, tmp_path, {}) + assert capsys.readouterr().out == "" + + +def test_candidate_cannot_relax_blocking_enforcement_in_a_comparison(tmp_path: Path) -> None: + (tmp_path / "quality").mkdir() + (tmp_path / "quality/comment-policy.json").write_text(json.dumps(policy(enforcement="blocking")), encoding="utf-8") + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "add", "a.py", "quality/comment-policy.json"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=F", + "-c", + "user.email=f@e.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "blocking", + ], + check=True, + ) + base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + (tmp_path / "quality/comment-policy.json").write_text(json.dumps(policy(enforcement="advisory")), encoding="utf-8") + (tmp_path / "a.py").write_text("# new\nx = 1\n", encoding="utf-8") + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", base]) == 2 + + +def test_new_exception_cannot_self_authorize_source(tmp_path: Path) -> None: + base = git_repo(tmp_path, "x = 1\n") + (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") + (tmp_path / "quality/comment-policy.json").write_text( + json.dumps(policy(exceptions=[exception()])), encoding="utf-8" + ) + (tmp_path / "a.py").write_text("x = 1 # noqa: F401\n", encoding="utf-8") + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", base]) == 1 + + +def test_missing_base_and_unpinned_bootstrap_fail(tmp_path: Path) -> None: + base = git_repo(tmp_path, "x = 1\n") + assert main(["--root", str(tmp_path), "--mode", "transition"]) == 2 + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", "f" * 40]) == 2 + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", base, "--bootstrap"]) == 2 + + +def test_bootstrap_base_is_refused_when_a_policy_registry_exists( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pin = plain_repo(tmp_path, "x = 1\n") + (tmp_path / "quality").mkdir() + (tmp_path / "quality/comment-policy.json").write_text("{}", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "quality/comment-policy.json"], check=True) + with_registry = commit_change(tmp_path, "x = 2\n") + monkeypatch.setattr("check_comment_policy.BOOTSTRAP_BASE", pin) + monkeypatch.setattr("run_comment_policy.BOOTSTRAP_BASE", pin) + assert not check_comment_policy.bootstrap_base_allowed(tmp_path, with_registry) + assert not policy_runner.bootstrap_base_allowed(tmp_path, with_registry) + + +def git_run(root: Path, *args: str) -> str: + return subprocess.run( + [ + "git", + "-C", + str(root), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + *args, + ], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def merge_commit_repo(tmp_path: Path) -> tuple[str, str, str]: + event_base = plain_repo(tmp_path, "x = 1\n") + git_run(tmp_path, "checkout", "-q", "-b", "pr") + (tmp_path / "pr.py").write_text("y = 1\n", encoding="utf-8") + git_run(tmp_path, "add", "pr.py") + git_run(tmp_path, "commit", "-qm", "pr") + pr_head = git_run(tmp_path, "rev-parse", "HEAD") + git_run(tmp_path, "checkout", "-q", "-B", "target", event_base) + (tmp_path / "target.py").write_text("z = 1\n", encoding="utf-8") + git_run(tmp_path, "add", "target.py") + git_run(tmp_path, "commit", "-qm", "target moved on") + target_tip = git_run(tmp_path, "rev-parse", "HEAD") + git_run(tmp_path, "checkout", "-q", "--detach", target_tip) + git_run(tmp_path, "merge", "--no-ff", "-qm", "merge ref", pr_head) + return event_base, target_tip, pr_head + + +def test_ci_comparison_base_is_the_merge_target_not_the_stale_event_base( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + event_base, target_tip, pr_head = merge_commit_repo(tmp_path) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_NAME", "merge_group") + assert policy_runner.comparison_base(tmp_path, event_base) == event_base + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + assert policy_runner.comparison_base(tmp_path, event_base) == target_tip + with pytest.raises(ValueError, match="not an ancestor"): + policy_runner.comparison_base(tmp_path, pr_head) + git_run(tmp_path, "checkout", "-q", "--detach", target_tip) + assert policy_runner.comparison_base(tmp_path, event_base) == event_base + + +def test_local_runs_keep_the_requested_base_even_on_a_merge_commit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + event_base, _target_tip, _pr_head = merge_commit_repo(tmp_path) + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + assert policy_runner.comparison_base(tmp_path, event_base) == event_base + + +def test_checker_command_compares_against_the_given_base(tmp_path: Path) -> None: + command = policy_runner.checker_command(Path("python"), tmp_path, tmp_path, "c" * 40, bootstrap=True, staged=False) + assert command[command.index("--base") + 1] == "c" * 40 + assert "--bootstrap" in command + assert "--staged" not in command + + +def test_local_default_base_is_the_branch_point_when_develop_moved_on( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + branch_point = git_repo(tmp_path, "x = 1\n") + subprocess.run(["git", "-C", str(tmp_path), "branch", "-q", "develop-tip"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "checkout", "-q", "develop-tip"], check=True) + develop_tip = commit_change(tmp_path, "x = 2\n") + subprocess.run(["git", "-C", str(tmp_path), "update-ref", "refs/remotes/origin/develop", develop_tip], check=True) + subprocess.run(["git", "-C", str(tmp_path), "checkout", "-q", "-B", "feature", branch_point], check=True) + feature_head = commit_change(tmp_path, "x = 3\n") + assert resolve_base(tmp_path, None) == branch_point + assert resolve_base(tmp_path, feature_head) == feature_head + with pytest.raises(subprocess.CalledProcessError): + resolve_base(tmp_path, develop_tip) + + +def commit_change(tmp_path: Path, source: str) -> str: + (tmp_path / "a.py").write_text(source, encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "a.py"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "next", + ], + check=True, + ) + return subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + + +def plain_repo(tmp_path: Path, source: str) -> str: + (tmp_path / "a.py").write_text(source, encoding="utf-8") + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + return commit_change(tmp_path, source) + + +def test_bootstrap_base_allows_only_descendants_of_the_initial_rollout_commit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pin = plain_repo(tmp_path, "x = 1\n") + descendant = commit_change(tmp_path, "x = 2\n") + monkeypatch.setattr("check_comment_policy.BOOTSTRAP_BASE", pin) + monkeypatch.setattr("run_comment_policy.BOOTSTRAP_BASE", pin) + assert check_comment_policy.bootstrap_base_allowed(tmp_path, pin) + assert check_comment_policy.bootstrap_base_allowed(tmp_path, descendant) + assert policy_runner.bootstrap_base_allowed(tmp_path, descendant) + assert not check_comment_policy.bootstrap_base_allowed(tmp_path, "f" * 40) + assert not policy_runner.bootstrap_base_allowed(tmp_path, "f" * 40) + monkeypatch.setattr("check_comment_policy.BOOTSTRAP_BASE", descendant) + assert not check_comment_policy.bootstrap_base_allowed(tmp_path, pin) + + +def test_bootstrap_base_is_refused_once_the_trusted_launcher_exists( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pin = plain_repo(tmp_path, "x = 1\n") + (tmp_path / "scripts").mkdir() + (tmp_path / "scripts/run_comment_policy.py").write_text("", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "scripts/run_comment_policy.py"], check=True) + with_launcher = commit_change(tmp_path, "x = 2\n") + monkeypatch.setattr("check_comment_policy.BOOTSTRAP_BASE", pin) + monkeypatch.setattr("run_comment_policy.BOOTSTRAP_BASE", pin) + assert check_comment_policy.bootstrap_base_allowed(tmp_path, pin) + assert not check_comment_policy.bootstrap_base_allowed(tmp_path, with_launcher) + assert not policy_runner.bootstrap_base_allowed(tmp_path, with_launcher) + + +@pytest.mark.parametrize( + "event_name,event", + [ + ("pull_request", {"pull_request": {"base": {"sha": "a" * 40}}}), + ("merge_group", {"merge_group": {"base_sha": "a" * 40}}), + ], +) +def test_ci_event_rejects_base_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, event_name: str, event: dict +) -> None: + event_file = tmp_path / "event.json" + event_file.write_text(json.dumps(event), encoding="utf-8") + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_NAME", event_name) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + with pytest.raises(ValueError, match="platform event"): + resolve_base(tmp_path, "b" * 40) + + +def test_ci_policy_is_always_required_and_has_local_equivalent() -> None: + workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + job = workflow["jobs"]["comment-policy"] + assert "if" not in job + step = next(step for step in job["steps"] if step.get("name") == "Enforce comment and docstring policy") + assert 'git show "${BASE_REF}:scripts/run_comment_policy.py"' in step["run"] + assert 'python -I "$RUNNER_TEMP/comment-policy-runner.py" --native-tests' in step["run"] + assert ': "${BASE_REF:?' in step["run"] + listing = step["run"].split('installed="$(git ls-tree -r --name-only "$BASE_REF" --', 1)[1].split(')"', 1)[0] + assert set(listing.replace("\\", " ").split()) == {*TRUSTED_FILES, "quality/comment-policy.json"} + assert step["run"].index('installed="$(') < step["run"].index("git cat-file -e") + assert 'elif [ -z "$installed" ] && git merge-base --is-ancestor' in step["run"] + assert "git merge-base --is-ancestor ed5c263c513ba65499f4918d3a7de607f280c65b" in step["run"] + assert "pull_request.base.sha" in step["env"]["BASE_REF"] + assert "merge_group" not in step["env"]["BASE_REF"] + tooling = workflow["jobs"]["tooling"] + assert "comment-policy" in tooling["needs"] + assert any("--always comment-policy" in step.get("run", "") for step in tooling["steps"]) + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + assert 'failed="$$failed comment-policy"' in makefile + + +@pytest.mark.parametrize( + "source,expected", + [ + ("SELECT ARRAY[1 /* explanation */];", ["/* explanation */"]), + ("SELECT payload #>> '{a}' FROM t;", []), + ("SELECT * FROM #temporary_table;", []), + ], +) +def test_sql_array_and_operator_contexts(source: str, expected: list[str]) -> None: + assert [text for _, text in sql_comments(source)] == expected + + +@pytest.mark.parametrize("source", ['sql = "VALUES (1) -- explanation"', 'sql = f"SELECT {column} -- explanation"']) +def test_sql_literals_and_fstrings_in_python(source: str) -> None: + assert [f.text for f in python_findings("a.py", source)] == ["-- explanation"] + + +def test_changed_unsupported_source_cannot_cancel_coverage_failure() -> None: + finding = Finding("a.tpl", 1, "coverage-error", "unsupported") + assert introduced([finding], [finding], []) == [finding] + + +@pytest.mark.parametrize( + "path", + [".gitignore", ".gitattributes", ".dockerignore", ".importlinter", "skill-sync.conf", "scripts/a.zsh", "scripts/a"], +) +def test_extensionless_and_config_source_are_included(path: str) -> None: + assert scan_sources(ROOT, {path: b"# explanation\n"}, policy()) + + +def test_executable_python_heredoc() -> None: + source = "python3 - <<'PY'\n# explanation\nPY\n" + findings = scan("ci.sh", source, "bash") + assert [(f.kind, f.text) for f in findings] == [("comment", "# explanation")] + + +def test_jsonc_and_json_script_are_data() -> None: + assert not scan_sources(ROOT, {"a.jsonc": b'{"enabled":true}'}, policy()) + assert not scan_sources(ROOT, {"a.html": b''}, policy()) + + +def test_removing_exception_revokes_permission(tmp_path: Path) -> None: + base = git_repo(tmp_path, "x = 1 # noqa: F401\n") + (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") + registry = tmp_path / "quality/comment-policy.json" + registry.write_text(json.dumps(policy(exceptions=[exception()])), encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "quality/comment-policy.json", "pyproject.toml"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture permission", + ], + check=True, + ) + base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + registry.write_text(json.dumps(policy()), encoding="utf-8") + assert main(["--root", str(tmp_path), "--mode", "transition", "--base", base]) == 1 + + +@pytest.mark.parametrize("source", ['__doc__: str = "prose"', '__doc__ += "prose"', 'f"inert {value}"']) +def test_other_python_prose_forms(source: str) -> None: + assert python_findings("a.py", source) + + +def test_python_sql_context_avoids_docstrings_and_fstring_fragments() -> None: + assert [f.kind for f in python_findings("a.py", '"Show plan evolution -- details"')] == ["docstring"] + assert not python_findings("a.py", "sql = f\"SELECT * FROM t WHERE x = '{value}'\"") + assert [f.text for f in python_findings("a.py", 'conn.execute("PRAGMA foreign_keys=ON; -- explanation")')] == [ + "-- explanation" + ] + + +@pytest.mark.parametrize( + "source", ["SELECT [1 /* explanation */, 2];", "SELECT 1 FROM t WHERE x = 1 AND [--flag] = 1;"] +) +def test_ambiguous_sql_brackets_require_dialect(source: str) -> None: + assert scan("a.sql", source, "sql")[0].kind == "coverage-error" + assert [text for _, text in sql_comments(source, "duckdb")] or not sql_comments(source, "tsql") + + +@pytest.mark.parametrize( + "path", + [ + "results-explorer/a.mts", + "docker/a.java", + "benchbox/a.properties", + ".github/CODEOWNERS", + "scripts/a.zsh", + "scripts/new-format.xyz", + ], +) +def test_closed_inventory_discovers_maintained_sources(tmp_path: Path, path: str) -> None: + git_repo(tmp_path, "x=1") + target = tmp_path / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("# explanation", encoding="utf-8") + assert path in source_paths(tmp_path, False) + subprocess.run(["git", "-C", str(tmp_path), "add", path], check=True) + assert path in source_paths(tmp_path, True) + + +def test_zsh_shebang_is_minimum_directive() -> None: + assert [f.kind for f in scan("a.zsh", "#!/bin/zsh\n# explanation\n", "bash")] == ["comment", "comment"] + + +@pytest.mark.parametrize( + "source", ["printf python <<'EOF'\necho ok\nEOF\n", "python script.py <<'EOF'\n# input data\nEOF\n"] +) +def test_heredoc_arguments_and_script_input_are_data(source: str) -> None: + assert not scan("a.sh", source, "bash") + + +def test_multiple_heredocs_and_continued_header() -> None: + source = "python - \\\n <<'A' <<'B'\n# unused input\nA\n# explanation\nB\n" + assert [f.text for f in scan("a.sh", source, "bash")] == ["# explanation"] + + +def test_piped_heredoc_is_executable() -> None: + source = "cat <<'JS' | node\n// explanation\nJS\n" + assert javascript_requests("a.sh", source, "bash") + + +@pytest.mark.parametrize( + "source", + [ + "write_sql: 'SELECT 1 -- explanation'", + "cleanup_sql: 'DELETE FROM t -- explanation'", + "platform_overrides:\n duckdb: 'SELECT 1 -- explanation'", + ], +) +def test_repository_sql_carriers_are_routed(source: str) -> None: + assert [f.text for f in scan("a.yaml", source, "yaml")] == ["-- explanation"] + + +def test_github_script_routes_native_request() -> None: + source = "steps:\n- uses: actions/github-script@sha\n with:\n script: |\n // explanation\n" + assert list(javascript_requests("ci.yaml", source, "yaml").values()) == ["// explanation\n"] + + +def test_myst_metadata_and_nested_examples() -> None: + source = "```{tags}\npython\n```\n```{toctree}\nindex\n```\n````{note}\n```python\n# explanation\n```\n````\n" + assert [(f.line, f.text) for f in scan("docs/a.md", source, "examples")] == [(9, "# explanation")] + source = " ```python\n # explanation\n ```\n" + assert [(f.line, f.text) for f in scan("docs/a.md", source, "examples")] == [(2, "# explanation")] + + +def test_rst_nested_code_is_dedented() -> None: + source = " .. code-block:: python\n :linenos:\n\n # explanation\n x=1\n" + assert [(f.line, f.text) for f in scan("docs/a.rst", source, "examples")] == [(4, "# explanation")] + + +def test_exception_budget_and_completed_external_overlap() -> None: + registered = load_policy(json.dumps(policy(exceptions=[exception()])).encode()) + assert len(scan_sources(ROOT, {"a.py": b"x=1 # noqa: F401\ny=2 # noqa: F401\n"}, registered)) == 1 + with pytest.raises(ValueError, match="overlap"): + load_policy( + json.dumps( + policy( + completed=["vendor/"], + external=[{"path": "vendor/a.py", "owner": "upstream", "provenance": "README.md"}], + ) + ).encode() + ) + + +def test_fixture_exception_binds_payload_kind_text_and_count() -> None: + entry = exception("-- explanation", kind="fixture", payload="SELECT 1 -- explanation", finding_kind="comment") + registered = load_policy(json.dumps(policy(exceptions=[entry])).encode()) + finding = Finding("a.py", 1, "comment", "-- explanation", payload="SELECT 1 -- explanation") + assert allowed(finding, registered, "") + assert not allowed( + Finding("a.py", 1, "comment", "-- explanation", payload="SELECT 2 -- explanation"), registered, "" + ) + + +def test_report_handles_provenance_excluded_vendor_binary(tmp_path: Path) -> None: + git_repo(tmp_path, "# legacy\n") + path = "_project/scripts/vendor/package.whl" + binary = tmp_path / path + binary.parent.mkdir(parents=True) + binary.write_bytes(b"PK\x03\x04\xff") + (tmp_path / "quality/comment-policy.json").write_text( + json.dumps(policy(external=[{"path": path, "owner": "upstream", "provenance": "a.py"}])), encoding="utf-8" + ) + assert path in source_paths(tmp_path, False) + assert main(["--root", str(tmp_path), "--mode", "report"]) == 0 + + +def test_parser_environment_installs_only_trusted_material(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + for name in ("comment-policy-package.json", "comment-policy-package-lock.json"): + (tmp_path / name).write_text("{}", encoding="utf-8") + commands = [] + monkeypatch.setenv("PYTHONPATH", "candidate") + monkeypatch.setenv("NODE_OPTIONS", "--require=candidate") + monkeypatch.setenv("COMMENT_POLICY_TYPESCRIPT", "candidate") + + def install(command: list[str], **kwargs: object) -> subprocess.CompletedProcess: + commands.append((command, kwargs)) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr("run_comment_policy.subprocess.run", install) + python, env = parser_environment(tmp_path) + assert str(python).startswith(str(tmp_path)) + assert "PYTHONPATH" not in env and "NODE_OPTIONS" not in env + assert env["COMMENT_POLICY_TYPESCRIPT"] == str(tmp_path / "node_modules/typescript") + assert "--require-hashes" in commands[1][0] + assert "--ignore-scripts" in commands[2][0] + assert str(tmp_path / "comment-policy-requirements.txt") in commands[1][0] + assert commands[2][1]["cwd"] == tmp_path + + +def test_comment_policy_trust_roots_require_soundness_review() -> None: + routes = (ROOT / ".github/soundness-paths.txt").read_text().splitlines() + for path in (*TRUSTED_FILES, "scripts/run_comment_policy.py", "quality/comment-policy.json"): + assert "file\t" + path in routes + + +@pytest.mark.parametrize("prose,native_failure", [(True, False), (True, True), (False, False), (False, True)]) +def test_candidate_native_execution_cannot_replace_checker( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, prose: bool, native_failure: bool +) -> None: + git_repo(tmp_path, "x = 1\n") + for name in TRUSTED_FILES: + target = tmp_path / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("", encoding="utf-8") + (tmp_path / "scripts/comment_policy_entry.py").write_bytes((ROOT / "scripts/comment_policy_entry.py").read_bytes()) + (tmp_path / "scripts/check_comment_policy.py").write_text( + "import sys\nfrom pathlib import Path\n" + "root = Path(sys.argv[sys.argv.index('--root') + 1])\n" + "raise SystemExit(int('# explanation' in (root / 'scripts/bad.py').read_text()))\n", + encoding="utf-8", + ) + subprocess.run(["git", "-C", str(tmp_path), "add", "scripts", "quality"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "Checker fixture", + ], + check=True, + ) + base = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + (tmp_path / "scripts/bad.py").write_text("# explanation\n" if prose else "x = 1\n", encoding="utf-8") + order = [] + real_run = subprocess.run + + def execute(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess: + if command[0] == "node": + order.append("native") + directory = kwargs["cwd"] + assert isinstance(directory, Path) + (directory / "check_comment_policy.py").write_text("raise SystemExit(0)\n", encoding="utf-8") + if native_failure: + raise subprocess.CalledProcessError(1, command) + return subprocess.CompletedProcess(command, 0) + if command[0] == sys.executable and any(str(value).endswith("comment_policy_entry.py") for value in command): + order.append("checker") + return real_run(command, **kwargs) + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.delenv("GITHUB_EVENT_NAME", raising=False) + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + monkeypatch.setattr(policy_runner, "parser_environment", lambda trusted: (Path(sys.executable), dict(os.environ))) + monkeypatch.setattr(policy_runner.subprocess, "run", execute) + assert policy_runner.main(["--base", base, "--native-tests"]) == (1 if prose else 2 if native_failure else 0) + assert order == (["checker"] if prose else ["checker", "native"]) + assert (tmp_path / "scripts/bad.py").read_text() == ("# explanation\n" if prose else "x = 1\n") + + +def test_new_files_do_not_inherit_vendor_directory_exclusion() -> None: + registered = policy(external=[{"path": "vendor/", "owner": "upstream", "provenance": "README.md"}]) + registered["external_members"] = {"vendor/old.py"} + findings = scan_sources(ROOT, {"vendor/old.py": b"# upstream\n", "vendor/new.py": b"# explanation\n"}, registered) + assert [(f.path, f.text) for f in findings] == [("vendor/new.py", "# explanation")] + + +def test_invalid_source_encoding_is_inventory_debt_in_report(tmp_path: Path) -> None: + git_repo(tmp_path, "x=1") + (tmp_path / "a.py").write_bytes(b"\xff") + assert main(["--root", str(tmp_path), "--mode", "report"]) == 0 + assert main(["--root", str(tmp_path), "--mode", "strict"]) == 1 + + +@pytest.mark.parametrize( + "source", + [ + 'exec("# explanation")', + 'eval("1 # explanation")', + 'compile("# explanation", "generated", "exec")', + 'code = "# explanation"\nexec(code)', + 'code = "# " + "explanation"\nexec(code)', + 'import builtins as bi\nbi.exec("# explanation")', + 'from builtins import exec as run\nrun("# explanation")', + 'exec(compile("# explanation", "generated", "exec"))', + 'import subprocess\nsubprocess.run(["python3", "-c", "# explanation"])', + 'import subprocess, sys\nsubprocess.run([sys.executable, "-c", "# explanation"])', + "import subprocess\nsubprocess.run(\"python3 -c '# explanation'\", shell=True)", + 'import subprocess\nsubprocess.run(args=["python3", "-c", "# explanation"])', + 'import subprocess\nsubprocess.run(args="echo ok # explanation", shell=True)', + ], +) +def test_python_executable_strings_reach_scanner(source: str) -> None: + assert [f.text for f in python_findings("a.py", source)] == ["# explanation"] + + +@pytest.mark.parametrize( + "source", ["exec(source)", 'code = code + "text"\nexec(code)', 'code = "before"\ncode = "after"\nexec(code)'] +) +def test_python_unresolved_execution_fails_visibly(source: str) -> None: + assert python_findings("a.py", source)[0].kind == "payload-error" + + +@pytest.mark.parametrize( + "source", + [ + 'def exec(value):\n return value\nexec("# data")', + 'def f(exec):\n return exec("# data")', + 'text = "# data"', + ], +) +def test_local_execution_names_and_ordinary_strings_are_data(source: str) -> None: + assert not python_findings("a.py", source) + + +@pytest.mark.parametrize( + "source", ['python3 -c "# explanation"', "eval 'echo ok # explanation'", "bash -lc 'echo ok # explanation'"] +) +def test_shell_executable_arguments_are_routed(source: str) -> None: + assert [f.text for f in scan("a.sh", source, "bash")] == ["# explanation"] + + +@pytest.mark.parametrize("source", ["# explanation", "echo ok # explanation", "echo ok # explanation\n"]) +def test_shell_comments_at_end_of_input(source: str) -> None: + findings = scan("a.sh", source, "bash") + assert [(finding.line, finding.text) for finding in findings] == [(1, "# explanation")] + assert not scan("a.sh", 'printf "%s" "# data"', "bash") + + +def test_shell_dynamic_execution_requires_adapter() -> None: + assert scan("a.sh", 'python -c "$code"', "bash")[0].kind == "coverage-error" + assert not scan("a.sh", 'printf "%s" "eval # input data"', "bash") + + +@pytest.mark.parametrize("module_name", ["check_deps", "scan_imports"]) +def test_markdown_parser_dependency_is_backed_by_import_sites(module_name: str, tmp_path: Path) -> None: + spec = importlib.util.spec_from_file_location( + module_name, ROOT / "_project/scripts/dependency_audit" / f"{module_name}.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + collect = getattr(module, "_collect_python_imports", None) or module.collect_python_imports + uses = getattr(module, "_package_uses", None) or module.package_uses + script = tmp_path / "consumer.py" + script.write_text("from markdown_it import MarkdownIt\n", encoding="utf-8") + assert uses("markdown-it-py", collect(tmp_path, ["consumer.py"])) == ["consumer.py:1"] + script.write_text('data = "from markdown_it import MarkdownIt"\n', encoding="utf-8") + assert not uses("markdown-it-py", collect(tmp_path, ["consumer.py"])) + assert not uses("markdown-it-py", {}) + + +def test_opaque_fixture_permission_changes_with_contributing_consumer_code() -> None: + source = "code = input()\nexec(code)" + finding = python_findings("a.py", source)[0] + registered = policy( + exceptions=[exception(finding.text, kind="fixture", payload=finding.payload, finding_kind="payload-error")] + ) + assert allowed(finding, registered, source) + changed = python_findings("a.py", source + '\ncode_input = "# new explanation"')[0] + assert changed.text == finding.text + assert changed.payload != finding.payload + assert not allowed(changed, registered, source) + + +def test_native_payload_batches_include_nested_shell_javascript(monkeypatch: pytest.MonkeyPatch) -> None: + source = 'import {execSync} from "node:child_process"; execSync("node -e \'// explanation\'");' + batches = [] + + def native_run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess: + requests = json.loads(str(kwargs["input"])) + batches.append(requests) + row = ( + {"kind": "payload", "language": "bash", "line": 1, "symbol": "", "text": "node -e '// explanation'"} + if len(batches) == 1 + else {"kind": "comment", "line": 1, "text": "// explanation", "symbol": ""} + ) + return subprocess.CompletedProcess(command, 0, json.dumps({key: [row] for key in requests})) + + monkeypatch.setattr("check_comment_policy.subprocess.run", native_run) + findings = scan_sources(ROOT, {"a.ts": source.encode()}, policy()) + assert [(f.kind, f.text) for f in findings] == [("comment", "// explanation")] + assert len(batches) == 2 + assert list(batches[1].values()) == ["// explanation"] + + +@pytest.mark.parametrize("node_type", [ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef]) +def test_consumer_digest_ignores_absent_empty_type_parameters(node_type: type[ast.AST]) -> None: + from comment_syntax import python_consumer_digest + + node: Any = node_type() + vars(node)["_fields"] = tuple(name for name in node_type._fields if name != "type_params") + tree = ast.Module(body=[node], type_ignores=[]) + before = python_consumer_digest(tree) + vars(node)["_fields"] = (*node._fields, "type_params") + node.type_params = [] + assert python_consumer_digest(tree) == before + node.type_params = [ast.Name(id="T", ctx=ast.Load())] + assert python_consumer_digest(tree) != before + + +def test_consumer_digest_has_stable_python_version_encoding() -> None: + from comment_syntax import python_consumer_digest + + tree = ast.parse("def consume(value):\n return value + 1\n") + assert python_consumer_digest(tree) == "sha256:c6aaea25c78f8f6fdbf83d97c11fe1be21e859156b678d34cfbcc79f8c261c69" + + +ASTRO_SOURCE = """--- +// frontmatter explanation +const title = "x"; +--- + +
{/* expression explanation */}{title}
+ + +""" + + +def test_astro_sources_are_a_registered_maintained_language() -> None: + from comment_syntax import language + + assert language("website/src/pages/index.astro") == "astro" + assert language("website/src/data.unregistered") == "unsupported" + assert "website/" in json.loads((ROOT / "quality/comment-policy.json").read_text(encoding="utf-8"))["completed"] + + +def test_astro_template_comments_are_found_without_flagging_embedded_blocks() -> None: + source = "
{/* expression */}
\n\n\n" + findings = scan("a.astro", source, "astro", {}) + assert [(f.line, f.kind, f.text) for f in findings if f.kind == "comment" and f.text.startswith("") + ] + + +ASTRO_STRING_MARKER_SOURCES = [ + 'x\n', + "x\n", + '

{""}

\n', + "

{``}

\n", + "---\nconst s = '';\n---\n

x

\n", +] + + +def test_astro_html_comment_markers_inside_strings_are_data() -> None: + for source in ASTRO_STRING_MARKER_SOURCES: + assert not [f for f in scan("a.astro", source, "astro", {}) if f.text.startswith(")}\n

{''}

\n" + findings = scan("a.astro", source, "astro", {}) + assert [(f.line, f.text) for f in findings] == [(1, "")] + + +def test_astro_frontmatter_ends_at_the_first_top_level_fence() -> None: + source = "---\nconst text = `\n---\nstill code\n`;\nconst other = '---';\n---\n

{text}

\n" + requests = javascript_requests("a.astro", source, "astro") + assert list(requests.values()) == ["const text = `\n---\nstill code\n`;\nconst other = '---';\n"] + assert not scan("a.astro", source, "astro", {k: [] for k in requests}) + + +def test_astro_frontmatter_fence_after_comment_and_interpolation() -> None: + source = "---\n// ---\nconst a = `${'}'}`;\n/*\n---\n*/\n---\n

x

\n" + requests = javascript_requests("a.astro", source, "astro") + assert list(requests.values()) == ["// ---\nconst a = `${'}'}`;\n/*\n---\n*/\n"] + + +def test_astro_unterminated_frontmatter_is_a_coverage_error() -> None: + findings = scan("a.astro", "---\nconst a = 1;\n

x

\n", "astro", {}) + assert [f.kind for f in findings] == ["coverage-error"] + + +def test_astro_frontmatter_script_and_style_are_scanned_as_their_own_languages() -> None: + requests = javascript_requests("a.astro", ASTRO_SOURCE, "astro") + assert len(requests) == 3 + comments = ["// frontmatter explanation", "// script explanation", "/* expression explanation */"] + lines = [1, 1, 2] + rows = { + key: [{"kind": "comment", "line": line, "text": text, "symbol": ""}] + for key, text, line in zip(requests, comments, lines, strict=True) + } + assert list(requests.values()) == [ + '// frontmatter explanation\nconst title = "x";\n', + '\n // script explanation\n console.log("");\n', + "[\n/* expression explanation */\n];", + ] + findings = scan("a.astro", ASTRO_SOURCE, "astro", rows) + assert sorted((f.line, f.text) for f in findings) == [ + (2, "// frontmatter explanation"), + (5, ""), + (6, "/* expression explanation */"), + (8, "/* style explanation */"), + (11, "// script explanation"), + ] + + +def test_astro_without_frontmatter_has_no_javascript_requests() -> None: + assert javascript_requests("a.astro", "

text

\n", "astro") == {} diff --git a/tests/unit/scripts/test_comment_syntax_js.cjs b/tests/unit/scripts/test_comment_syntax_js.cjs new file mode 100644 index 0000000000..88a79abf7e --- /dev/null +++ b/tests/unit/scripts/test_comment_syntax_js.cjs @@ -0,0 +1,75 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {spawnSync} = require('node:child_process'); +const path = require('node:path'); +const {scan} = require('../../../scripts/comment_syntax_js.cjs'); + +const contexts = [ + ['a.ts', 'const x = /https?:\\/\\//; const y = "// data"; // explanation', '// explanation'], + ['a.ts', 'const x = `// data ${1 /* explanation */}`;', '/* explanation */'], + ['a.tsx', 'const x =
https://host # data{/* explanation */}
;', '/* explanation */'], + ['a.tsx', 'const x =
\n// data\n
; // explanation', '// explanation'], + ['a.ts', 'const x = 1; /* explanation */', '/* explanation */'], +]; +for (const [name, source, text] of contexts) { + test(`native syntax: ${name}: ${source}`, () => { + assert.deepEqual(scan(name, source).map(f => [f.kind, f.text]), [['comment', text]]); + }); +} +test('qualified symbols distinguish moved comments', () => { + const before = scan('a.ts', 'function a(){ // same\n}\nfunction b(){}'); + const after = scan('a.ts', 'function a(){}\nfunction b(){ // same\n}'); + assert.equal(before[0].symbol, 'a'); + assert.equal(after[0].symbol, 'b'); + assert.equal(scan('a.ts', 'class C { m(){ /* text */ } }')[0].symbol, 'C.m'); +}); +test('process Python payloads resolve local templates and joined arrays', () => { + const source = 'const script = ["# explanation", "print(1)"].join("\\n"); spawnSync("uv", ["run", "--", "python", "-c", script]);'; + const result = scan('a.mjs', source); + assert.equal(result[0].language, 'python'); + assert.equal(result[0].text, '# explanation\nprint(1)'); + const template = 'const script = `# explanation\nprint(${JSON.stringify(name)})`; spawnSync("python3", ["-c", script]);'; + assert.equal(scan('a.mjs', template)[0].text, '# explanation\nprint("__expression__")'); +}); +test('unknown process source fails coverage instead of hiding templates', () => { + const source = 'function run(source){ spawnSync("uv", ["run", "--", "python", "-c", source]); }'; + assert.equal(scan('a.mjs', source)[0].kind, 'coverage-error'); +}); +test('SQL and nested Node source reach their language adapters', () => { + const sql = scan('a.ts', 'const statement = "SELECT 1 -- explanation"; conn.query(statement);'); + assert.equal(sql[0].kind, 'payload'); + assert.equal(sql[0].language, 'sql'); + assert.equal(sql[0].text, 'SELECT 1 -- explanation'); + const js = scan('a.mjs', 'spawnSync("node", ["-e", "let x=1; // explanation"]);'); + assert.equal(js[0].kind, 'comment'); + assert.equal(js[0].text, '// explanation'); +}); +test('stdio request contract and parse errors', () => { + const adapter = path.resolve(__dirname, '../../../scripts/comment_syntax_js.cjs'); + const result = spawnSync("node", [adapter], {input: JSON.stringify({'a.ts': '// text'}), encoding: 'utf8'}); + assert.equal(result.status, 0); + assert.equal(JSON.parse(result.stdout)['a.ts'][0].text, '// text'); + assert.equal(scan('a.ts', 'function broken(')[0].kind, 'coverage-error'); +}); +test('builtin JavaScript compilation checks function parameters and bodies', () => { + for (const source of ['eval("// explanation")', 'new Function("// explanation")', 'Function("value /* explanation */", "return value")']) { + assert.equal(scan('a.ts', source)[0].kind, 'comment'); + } + assert.equal(scan('a.ts', 'eval(source)')[0].kind, 'coverage-error'); + assert.equal(scan('a.ts', 'function eval(value){ return value; } eval("// data");').length, 0); +}); +test('known child_process exec bindings route shell source, local exec stays data', () => { + const sources = [ + 'import {execSync} from "node:child_process"; execSync("python -c \'# explanation\'");', + 'import {exec as run} from "child_process"; run("echo ok # explanation");', + 'const cp = require("node:child_process"); cp.execSync("echo ok # explanation");', + 'const {execSync: run} = require("node:child_process"); run("echo ok # explanation");', + ]; + for (const source of sources) { + const result = scan('a.mjs', source); + assert.equal(result[0].language, 'bash'); + assert.equal(result[0].kind, 'payload'); + } + assert.equal(scan('a.ts', 'function exec(value){ return value; } exec("Q1",20);').length, 0); + assert.equal(scan('a.ts', 'const match = /text/.exec("// data");').length, 0); +}); diff --git a/tests/unit/scripts/test_corpus_cohort_depth.py b/tests/unit/scripts/test_corpus_cohort_depth.py index 59e76ed79d..024329c4b3 100644 --- a/tests/unit/scripts/test_corpus_cohort_depth.py +++ b/tests/unit/scripts/test_corpus_cohort_depth.py @@ -410,3 +410,15 @@ def test_timestamp_less_bundle_does_not_fail_depth_exit(tmp_path: Path, capsys: assert "oldest=2026-08-01" in captured assert "3 bundles" in captured assert "All 1 cohort(s) meet" in captured + + +def test_override_companions_are_not_read_as_bundles(tmp_path: Path) -> None: + """A ``.override.json`` beside a bundle is a companion, not a result.""" + validator = _load_validator() + (tmp_path / "x_sf1_duckdb_sql_20261001_000000_aaaa.json").write_text("{}") + (tmp_path / "x_sf1_duckdb_sql_20261001_000000_aaaa.override.json").write_text("{}") + (tmp_path / "x_sf1_duckdb_sql_20261001_000000_aaaa.manifest.json").write_text("{}") + + names = [path.name for path in validator.discover_bundles(tmp_path)] + + assert names == ["x_sf1_duckdb_sql_20261001_000000_aaaa.json"] diff --git a/tests/unit/scripts/test_heavy_tier_needed.py b/tests/unit/scripts/test_heavy_tier_needed.py new file mode 100644 index 0000000000..b05c336648 --- /dev/null +++ b/tests/unit/scripts/test_heavy_tier_needed.py @@ -0,0 +1,257 @@ +"""Exercise both soundness policies without sharing imports or manifest state.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts import heavy_tier_needed as heavy + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[3] +POLICY_FILES = ( + "_project/scripts/auto_merge_soundness_paths.py", + "_project/scripts/soundness_paths.py", + ".github/soundness-paths.txt", +) + + +@pytest.fixture +def policy_sources() -> dict[str, str]: + return {path: (REPO_ROOT / path).read_text(encoding="utf-8") for path in POLICY_FILES} + + +def _decision(paths: list[str]) -> dict: + return {"needs_code_ci": True, "packaging_needed": False, "changed_paths": paths} + + +def _classify(paths: list[str], base: dict[str, str], pr: dict[str, str]) -> dict: + return heavy.heavy_needed( + _decision(paths), + "pull_request", + "base", + REPO_ROOT, + read_base=lambda *_: base, + read_pr=lambda *_: pr, + ) + + +def test_real_base_policy_does_not_expand_an_ordinary_code_change( + tmp_path: Path, policy_sources: dict[str, str] +) -> None: + repo, base_head, _pr_head = _snapshot_repo(tmp_path, policy_sources) + assert subprocess.check_output(["git", "-C", str(repo), "remote"], text=True) == "" + result = heavy.heavy_needed(_decision(["benchbox/core/platform_registry.py"]), "pull_request", base_head, repo) + assert result["heavy_needed"] is False, result["reason"] + + +@pytest.mark.parametrize( + "base_rule,pr_rule,expected", [(True, False, True), (False, True, True), (False, False, False)] +) +def test_policy_union_keeps_base_removals_and_pr_additions( + policy_sources: dict[str, str], base_rule: bool, pr_rule: bool, expected: bool +) -> None: + manifest = policy_sources[POLICY_FILES[2]].replace("file\tscripts/pr_arm.py\n", "") + base = {**policy_sources, POLICY_FILES[2]: manifest + ("file\tscripts/pr_arm.py\n" if base_rule else "")} + pr = {**policy_sources, POLICY_FILES[2]: manifest + ("file\tscripts/pr_arm.py\n" if pr_rule else "")} + result = _classify(["scripts/pr_arm.py"], base, pr) + assert result["heavy_needed"] is expected, result["reason"] + + +def test_ambient_imports_and_manifest_cannot_replace_either_policy( + policy_sources: dict[str, str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "soundness_paths.py").write_text("raise RuntimeError('ambient module used')\n", encoding="utf-8") + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + monkeypatch.setenv("SOUNDNESS_PATH_MANIFEST", str(tmp_path / "absent")) + monkeypatch.setitem(sys.modules, "soundness_paths", object()) + assert _classify(["scripts/pr_arm.py"], policy_sources, policy_sources)["heavy_needed"] is True + result = _classify(["ordinary.py"], policy_sources, policy_sources) + assert result["heavy_needed"] is False, result["reason"] + + +def test_installed_startup_hook_cannot_replace_the_snapshot_helper( + policy_sources: dict[str, str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + environment = tmp_path / "interpreter" + subprocess.run(["uv", "venv", "--python", sys.executable, str(environment)], check=True, capture_output=True) + if os.name == "nt": + interpreter = environment / "Scripts" / "python.exe" + site_packages = environment / "Lib" / "site-packages" + else: + interpreter = environment / "bin" / "python" + site_packages = ( + environment / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages" + ) + original = policy_sources[POLICY_FILES[1]] + hostile = original.replace("return any(is_soundness_path(path) for path in paths)", "return False") + assert hostile != original + (site_packages / "soundness_paths.py").write_text(hostile, encoding="utf-8") + (site_packages / "policy-startup.pth").write_text("import soundness_paths\n", encoding="utf-8") + pr = {**policy_sources, POLICY_FILES[2]: policy_sources[POLICY_FILES[2]].replace("file\tscripts/pr_arm.py\n", "")} + monkeypatch.setattr(heavy.sys, "executable", str(interpreter)) + result = _classify(["scripts/pr_arm.py"], policy_sources, pr) + assert result["heavy_needed"] is True, result["reason"] + assert "soundness path touched" in result["reason"] + + +@pytest.mark.parametrize("present", [(0, 1), (0, 2)]) +@pytest.mark.parametrize("bad_copy", ["base", "pr"]) +def test_partial_modern_snapshot_cannot_emit_a_valid_false_verdict( + policy_sources: dict[str, str], present: tuple[int, ...], bad_copy: str +) -> None: + partial = {POLICY_FILES[index]: policy_sources[POLICY_FILES[index]] for index in present} + partial[POLICY_FILES[0]] = "print('soundness_path=false')\n" + result = _classify( + ["ordinary.py"], + partial if bad_copy == "base" else policy_sources, + partial if bad_copy == "pr" else policy_sources, + ) + assert result["heavy_needed"] is True + assert "invalid file set" in result["reason"] + + +def _snapshot_repo(tmp_path: Path, sources: dict[str, str] | None = None) -> tuple[Path, str, str]: + repo = tmp_path / "policy-repo" + repo.mkdir() + + def git(*args: str) -> str: + return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip() + + git("init", "-b", "main") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.invalid") + git("config", "commit.gpgsign", "false") + heads = [] + for label in ("base", "pr"): + for relative in POLICY_FILES: + path = repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(sources[relative] if sources is not None else f"{label}:{relative}\n", encoding="utf-8") + git("add", "--", *POLICY_FILES) + git("commit", "--allow-empty", "-m", label) + heads.append(git("rev-parse", "HEAD")) + git("branch", "policy-base", heads[0]) + return repo, heads[0], heads[1] + + +def test_base_reader_pins_all_files_before_a_ref_moves(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + repo, _, pr_head = _snapshot_repo(tmp_path) + for relative in POLICY_FILES: + (repo / relative).write_text("dirty worktree contents\n", encoding="utf-8") + original = subprocess.check_output + + def read_and_move(cmd: list[str], *, text: bool, stderr: int) -> str: + assert text is True + result = original(cmd, text=True, stderr=stderr) + if "rev-parse" in cmd: + subprocess.run(["git", "-C", str(repo), "update-ref", "refs/heads/policy-base", pr_head], check=True) + return result + + monkeypatch.setattr(heavy.subprocess, "check_output", read_and_move) + sources = heavy._read_base_copy("policy-base", repo) + assert sources == {relative: f"base:{relative}\n" for relative in POLICY_FILES} + + +def test_base_reader_ignores_git_replacement_trees(tmp_path: Path) -> None: + repo, base_head, pr_head = _snapshot_repo(tmp_path) + subprocess.run(["git", "-C", str(repo), "replace", base_head, pr_head], check=True) + sources = heavy._read_base_copy("policy-base", repo) + assert sources == {relative: f"base:{relative}\n" for relative in POLICY_FILES} + + +@pytest.mark.parametrize("bad_copy", ["base", "pr"]) +@pytest.mark.parametrize( + "defect", + ["missing-wrapper", "missing-helper", "missing-manifest", "invalid-manifest", "bad-output", "execution-error"], +) +def test_invalid_policy_copies_fail_closed(policy_sources: dict[str, str], bad_copy: str, defect: str) -> None: + invalid = dict(policy_sources) + if defect.startswith("missing-"): + invalid.pop(POLICY_FILES[{"missing-wrapper": 0, "missing-helper": 1, "missing-manifest": 2}[defect]]) + elif defect == "invalid-manifest": + invalid[POLICY_FILES[2]] = "not a rule\n" + elif defect == "bad-output": + invalid[POLICY_FILES[0]] = "print('soundness_path=false\\nsoundness_path=true')\n" + else: + invalid[POLICY_FILES[0]] = "raise RuntimeError('broken policy')\n" + result = _classify( + ["ordinary.py"], + invalid if bad_copy == "base" else policy_sources, + invalid if bad_copy == "pr" else policy_sources, + ) + assert result["heavy_needed"] is True + assert "failed closed" in result["reason"] + + +def test_standalone_legacy_predicate_still_runs() -> None: + legacy = { + POLICY_FILES[ + 0 + ]: "import sys\nprint('soundness_path=' + str('protected.py' in sys.stdin.read().splitlines()).lower())\n" + } + assert _classify(["protected.py"], legacy, legacy)["heavy_needed"] is True + result = _classify(["ordinary.py"], legacy, legacy) + assert result["heavy_needed"] is False, result["reason"] + + +def test_unreadable_base_ref_fails_closed() -> None: + result = heavy.heavy_needed(_decision(["ordinary.py"]), "pull_request", "refs/heads/not-present", REPO_ROOT) + assert result["heavy_needed"] is True + assert "failed closed" in result["reason"] + + +@pytest.mark.parametrize( + "raw", + [ + "null", + "[]", + "not json", + "{}", + '{"needs_code_ci": null, "packaging_needed": false, "changed_paths": []}', + '{"needs_code_ci": 0, "packaging_needed": false, "changed_paths": []}', + '{"needs_code_ci": false, "packaging_needed": null, "changed_paths": []}', + '{"needs_code_ci": false, "changed_paths": []}', + '{"needs_code_ci": false, "packaging_needed": false}', + '{"needs_code_ci": false, "packaging_needed": false, "changed_paths": [42]}', + '{"needs_code_ci": false, "packaging_needed": false, "changed_paths": [""]}', + ], +) +def test_invalid_cli_decisions_publish_the_safe_direction(tmp_path: Path, raw: str) -> None: + decision = tmp_path / "decision.json" + decision.write_text(raw, encoding="utf-8") + output = tmp_path / "github-output" + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts/heavy_tier_needed.py"), + "--decision-in", + str(decision), + "--github-output", + str(output), + ], + env=os.environ.copy(), + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert output.read_text().strip() == "heavy-needed=true" + + +def test_event_and_packaging_carve_outs_preserve_the_full_tier() -> None: + assert heavy.heavy_needed(_decision([]), "merge_group", "absent", REPO_ROOT)["heavy_needed"] is True + decision = {**_decision([]), "packaging_needed": True} + assert heavy.heavy_needed(decision, "pull_request", "absent", REPO_ROOT)["heavy_needed"] is True + assert ( + heavy.heavy_needed({**_decision([]), "needs_code_ci": False}, "pull_request", "absent", REPO_ROOT)[ + "heavy_needed" + ] + is False + ) diff --git a/tests/unit/scripts/test_local_validation.py b/tests/unit/scripts/test_local_validation.py index e330861c7f..7412486b01 100644 --- a/tests/unit/scripts/test_local_validation.py +++ b/tests/unit/scripts/test_local_validation.py @@ -1,12 +1,8 @@ -"""Tests for local validation singleflight: bounded lock waits and receipt reuse.""" - from __future__ import annotations -import hashlib +import errno import importlib.util -import json import os -import subprocess import sys import threading import time @@ -28,378 +24,6 @@ spec.loader.exec_module(lv) -def _init_repo(path: Path) -> Path: - path.mkdir(parents=True, exist_ok=True) - for args in ( - ["init"], - ["config", "user.email", "test@example.com"], - ["config", "user.name", "Test"], - ["config", "commit.gpgsign", "false"], - ): - subprocess.run(["git", *args], cwd=path, check=True, capture_output=True) - (path / "tracked.txt").write_text("v1") - subprocess.run(["git", "add", "."], cwd=path, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=path, check=True, capture_output=True) - subprocess.run(["git", "update-ref", "refs/remotes/origin/develop", "HEAD"], cwd=path, check=True) - return path - - -@pytest.fixture() -def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - monkeypatch.setenv("BENCHBOX_VALIDATION_RECEIPTS_DIR", str(tmp_path / "receipts")) - return _init_repo(tmp_path / "repo") - - -def _counter_gate(marker: Path) -> list[str]: - # NOTE: the marker must live outside the repo: a gate that writes into the - # tree changes the validated input, and re-execution is then correct. - return [sys.executable, "-c", f"open({str(marker)!r}, 'a').write('x')"] - - -def _count(marker: Path) -> int: - return len(marker.read_text(encoding="utf-8")) if marker.exists() else 0 - - -def _valid_member_batch(repo: Path) -> dict[str, object]: - base = subprocess.run( - ["git", "rev-parse", "origin/develop"], cwd=repo, check=True, capture_output=True, text=True - ).stdout.strip() - (repo / "tracked.txt").write_text("member change", encoding="utf-8") - subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "member change"], cwd=repo, check=True, capture_output=True) - head = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True - ).stdout.strip() - paths = ["tracked.txt"] - scope_hash = hashlib.sha256(json.dumps(paths, separators=(",", ":")).encode()).hexdigest() - return { - "batch_id": "batch-1", - "member": "member-a", - "role": "member", - "source_base": base, - "source_head": head, - "accepted_head": head, - "scope_hash": scope_hash, - "config_hash": lv._batch_config_hash(repo), - "changed_paths": paths, - } - - -def test_identical_request_reuses_receipt(repo: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 1 - assert "REUSED" in capsys.readouterr().out - - -def test_changed_untracked_file_invalidates(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - (repo / "new.txt").write_text("untracked") - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_changed_head_invalidates(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - (repo / "tracked.txt").write_text("v2") - subprocess.run(["git", "commit", "-am", "v2"], cwd=repo, check=True, capture_output=True) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_changed_base_invalidates(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - subprocess.run(["git", "commit", "--allow-empty", "-m", "base"], cwd=repo, check=True, capture_output=True) - subprocess.run(["git", "update-ref", "refs/remotes/origin/develop", "HEAD"], cwd=repo, check=True) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_failed_run_leaves_no_receipt(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "failmark.txt" - gate = [sys.executable, "-c", f"open({str(marker)!r}, 'a').write('x'); raise SystemExit(3)"] - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 3 - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 3 - assert len(marker.read_text(encoding="utf-8")) == 2 - - -def test_different_gate_does_not_reuse(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g1", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g2", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_batch_block_changes_identity(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - member = {"batch_id": "b", "member": "A", "role": "member"} - assert lv.run_gate("g", gate, member, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, member, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 3 - - -def test_partial_batch_metadata_executes_without_receipt(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - partial = {"batch_id": "b", "member": "A", "role": "member"} - assert lv.run_gate("g", gate, partial, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, partial, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - assert list(lv.store_dir(repo).rglob("*.json")) == [] - - -def test_false_member_head_executes_without_receipt(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - batch = _valid_member_batch(repo) - batch["source_head"] = "0" * 40 - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - assert list(lv.store_dir(repo).rglob("*.json")) == [] - - -def test_self_consistent_but_false_member_scope_executes_without_receipt(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - batch = _valid_member_batch(repo) - batch["changed_paths"] = ["not-the-diff.txt"] - batch["scope_hash"] = hashlib.sha256(json.dumps(batch["changed_paths"], separators=(",", ":")).encode()).hexdigest() - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - assert list(lv.store_dir(repo).rglob("*.json")) == [] - - -def test_concurrent_identical_requests_execute_once(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "slow.txt" - gate = [sys.executable, "-c", f"import time; time.sleep(2); open({str(marker)!r}, 'a').write('x')"] - store = lv.store_dir(repo) - results = [] - threads = [ - threading.Thread(target=lambda: results.append(lv.run_gate("g", gate, None, 30.0, repo, store))) - for _ in range(2) - ] - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=60) - assert results == [0, 0] - assert len(marker.read_text(encoding="utf-8")) == 1 - - -def test_two_worktrees_coalesce_identical_request(repo: Path, tmp_path: Path) -> None: - second = tmp_path / "repo-second" - subprocess.run( - ["git", "worktree", "add", "--detach", str(second), "HEAD"], cwd=repo, check=True, capture_output=True - ) - try: - marker = tmp_path / "worktree-count.txt" - gate = [sys.executable, "-c", f"import time; time.sleep(1); open({str(marker)!r}, 'a').write('x')"] - results: list[int] = [] - threads = [ - threading.Thread( - target=lambda path=path: results.append(lv.run_gate("g", gate, None, 30.0, path, lv.store_dir(path))) - ) - for path in (repo, second) - ] - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=60) - assert results == [0, 0] - assert _count(marker) == 1 - finally: - subprocess.run(["git", "worktree", "remove", "--force", str(second)], cwd=repo, check=True, capture_output=True) - - -def test_tree_change_while_waiting_invalidates_old_receipt(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - identity = lv.content_identity(repo, gate) - receipt = lv.receipt_path(store, "g", identity, None) - holder_fd = lv.wait_for_lock(receipt.with_name(receipt.stem + ".lock"), 5.0) - result: list[int] = [] - thread = threading.Thread(target=lambda: result.append(lv.run_gate("g", gate, None, 30.0, repo, store))) - thread.start() - time.sleep(0.4) - (repo / "tracked.txt").write_text("changed while waiting", encoding="utf-8") - os.close(holder_fd) - thread.join(timeout=60) - assert result == [0] - assert _count(marker) == 2 - - -def test_batch_change_while_waiting_cannot_reuse_stale_receipt(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - batch = _valid_member_batch(repo) - assert lv.run_gate("g", gate, batch, 5.0, repo, store) == 0 - identity = lv.content_identity(repo, gate) - receipt = lv.receipt_path(store, "g", identity, batch) - holder_fd = lv.wait_for_lock(receipt.with_name(receipt.stem + ".lock"), 5.0) - result: list[int] = [] - thread = threading.Thread(target=lambda: result.append(lv.run_gate("g", gate, batch, 30.0, repo, store))) - thread.start() - time.sleep(0.4) - _commit_file(repo, "batch-change.txt", "changed") - os.close(holder_fd) - thread.join(timeout=60) - assert result == [0] - assert _count(marker) == 2 - - -def test_batch_change_after_success_does_not_store_receipt( - repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - batch = _valid_member_batch(repo) - original = lv._validate_prepared_batch - calls = 0 - - def batch_that_changes(path: Path, prepared: dict | None) -> tuple[bool, str]: - nonlocal calls - calls += 1 - valid, reason = original(path, prepared) - if calls >= 3 and valid: - return False, "batch changed after execution" - return valid, reason - - monkeypatch.setattr(lv, "_validate_prepared_batch", batch_that_changes) - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert list(lv.store_dir(repo).rglob("*.json")) == [] - - -def test_show_reports_receipt_status(repo: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - gate = _counter_gate(tmp_path / "count.txt") - assert lv.show_gate("g", None, repo, lv.store_dir(repo), gate) == 0 - assert "receipt=absent" in capsys.readouterr().out - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.show_gate("g", None, repo, lv.store_dir(repo), gate) == 0 - assert "receipt=present" in capsys.readouterr().out - assert lv.show_gate("g", None, repo, lv.store_dir(repo), gate + ["--extra"]) == 0 - assert "receipt=absent" in capsys.readouterr().out - - -def test_missing_base_forces_execution_without_receipt(repo: Path, tmp_path: Path) -> None: - subprocess.run(["git", "update-ref", "-d", "refs/remotes/origin/develop"], cwd=repo, check=True) - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 1 - assert list((tmp_path / "receipts").rglob("*.json")) == [] - - -def test_revalidates_inputs_after_waiting_before_reuse( - repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - original_wait_for_lock = lv.wait_for_lock - - def wait_then_change(lock_path: Path, timeout_seconds: float) -> int: - fd = original_wait_for_lock(lock_path, timeout_seconds) - (repo / "tracked.txt").write_text("changed while waiting") - return fd - - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - monkeypatch.setattr(lv, "wait_for_lock", wait_then_change) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert _count(marker) == 2 - current_identity = lv.content_identity(repo, gate) - assert lv.receipt_path(store, "g", current_identity, None).exists() - - -def test_rekeys_before_execution_under_changed_input_lock( - repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - - old_identity = lv.content_identity(repo, gate) - old_lock = lv.receipt_path(store, "g", old_identity, None).with_suffix(".lock") - original_wait_for_lock = lv.wait_for_lock - new_lock_path: Path | None = None - new_lock_waiting = threading.Event() - release_new_lock = threading.Event() - - def wait_then_change(lock_path: Path, timeout_seconds: float) -> int: - nonlocal new_lock_path - if lock_path == new_lock_path: - new_lock_waiting.set() - assert release_new_lock.wait(5.0) - return original_wait_for_lock(lock_path, timeout_seconds) - fd = original_wait_for_lock(lock_path, timeout_seconds) - if lock_path == old_lock: - (repo / "tracked.txt").write_text("changed while waiting") - new_identity = lv.content_identity(repo, gate) - new_lock_path = lv.receipt_path(store, "g", new_identity, None).with_suffix(".lock") - return fd - - monkeypatch.setattr(lv, "wait_for_lock", wait_then_change) - results: list[int] = [] - runner = threading.Thread(target=lambda: results.append(lv.run_gate("g", gate, None, 5.0, repo, store))) - runner.start() - try: - assert new_lock_waiting.wait(5.0) - assert _count(marker) == 1 - finally: - release_new_lock.set() - runner.join(timeout=10.0) - - assert not runner.is_alive() - assert results == [0] - assert new_lock_path is not None and new_lock_path != old_lock - assert _count(marker) == 2 - current_identity = lv.content_identity(repo, gate) - assert lv.receipt_path(store, "g", current_identity, None).exists() - - -def test_drift_during_successful_run_does_not_write_receipt(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = [ - sys.executable, - "-c", - f"open({str(marker)!r}, 'a').write('x'); open({str(repo / 'tracked.txt')!r}, 'w').write('v2')", - ] - store = lv.store_dir(repo) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert list(store.rglob("*.json")) == [] - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert _count(marker) == 2 - - -def test_mismatched_receipt_is_not_reused(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - receipt = next(store.rglob("*.json")) - data = json.loads(receipt.read_text(encoding="utf-8")) - data["batch"] = {"batch_id": "wrong", "member": "A", "role": "member"} - receipt.write_text(json.dumps(data), encoding="utf-8") - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert _count(marker) == 2 - - def test_wait_for_lock_closes_fd_on_interrupt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: lock = tmp_path / "test.lock" opened: list[int] = [] @@ -494,408 +118,6 @@ def test_clear_inactive_lock_keeps_path_for_competing_openers(tmp_path: Path) -> os.close(holder_fd) -def test_receipt_file_records_gate_and_exit(repo: Path, tmp_path: Path) -> None: - assert lv.run_gate("g", _counter_gate(tmp_path / "count.txt"), None, 5.0, repo, lv.store_dir(repo)) == 0 - receipts = list(lv.store_dir(repo).rglob("*.json")) - assert len(receipts) == 1 - data = json.loads(receipts[0].read_text(encoding="utf-8")) - assert data["gate"] == "g" and data["exit"] == 0 and data["identity"]["head"] - - -def test_accounting_report_distinguishes_execution_reuse_failure_and_skip(repo: Path, tmp_path: Path) -> None: - store = lv.store_dir(repo) - gate = _counter_gate(tmp_path / "count.txt") - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - failed = [sys.executable, "-c", "raise SystemExit(4)"] - assert lv.run_gate("failed", failed, None, 5.0, repo, store) == 4 - assert lv._record_skip("skipped", None, repo, store) == 0 - report = lv._accounting_report(store) - assert report["hosted_required_certification"] is False - assert report["counts"] == {"executed": 1, "reused": 1, "failed": 1, "skipped": 1} - executed = next(event for event in report["events"] if event["status"] == "executed") - reused = next(event for event in report["events"] if event["status"] == "reused") - assert executed["identity_key"] and executed["command_executions"] == 1 - assert reused["identity_key"] == executed["identity_key"] and reused["command_executions"] == 0 - assert all("hosted_required_certification" in event for event in report["events"]) - - -def test_cancelled_command_is_accounted(repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - marker = tmp_path / "cancelled.txt" - gate = _counter_gate(marker) - original_run = lv.subprocess.run - - def interrupt_gate(args, *run_args, **run_kwargs): - if list(args) == gate: - raise KeyboardInterrupt - return original_run(args, *run_args, **run_kwargs) - - monkeypatch.setattr(lv.subprocess, "run", interrupt_gate) - with pytest.raises(KeyboardInterrupt): - lv.run_gate("cancelled", gate, None, 5.0, repo, lv.store_dir(repo)) - report = lv._accounting_report(lv.store_dir(repo), "cancelled") - assert report["counts"] == {"cancelled": 1} - assert report["events"][0]["command_executions"] == 1 - assert report["events"][0]["exit_code"] == 130 - - -def test_accounting_rejects_corrupt_records_and_groups_exact_batch(repo: Path, tmp_path: Path) -> None: - store = lv.store_dir(repo) - assert lv.run_gate("g", _counter_gate(tmp_path / "count.txt"), None, 5.0, repo, store) == 0 - events = store / "events.jsonl" - valid = json.loads(events.read_text(encoding="utf-8").splitlines()[0]) - events.write_text( - events.read_text(encoding="utf-8") - + "not-json\n" - + json.dumps({**valid, "batch": {"batch_id": "caller-secret"}}) - + "\n", - encoding="utf-8", - ) - report = lv._accounting_report(store) - assert report["counts"] == {"executed": 1} - assert report["invalid_records"] == 2 - assert "caller-secret" not in json.dumps(report) - assert "unbatched" in report["groups"] - - -def test_skip_rejects_invalid_batch_without_persisting_caller_data(repo: Path) -> None: - secret = "caller-secret" - assert lv._record_skip("skipped", {"role": "member", "batch_id": secret}, repo, lv.store_dir(repo)) == 2 - report = lv._accounting_report(lv.store_dir(repo)) - assert report["events"] == [] - assert secret not in json.dumps(report) - - -def test_batch_receipt_requires_clean_checkout(repo: Path, tmp_path: Path) -> None: - batch = _valid_member_batch(repo) - (repo / "untracked.txt").write_text("dirty", encoding="utf-8") - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_ordered_success_records_exact_stage_commands_and_tools(repo: Path) -> None: - command = [sys.executable, "-c", "pass"] - assert ( - lv.run_ordered_path( - focused_gate="focused", - focused_command=command, - preflight_gate="required", - preflight_command=command + ["#required"], - batch=None, - lock_wait_seconds=5.0, - repo=repo, - store=lv.store_dir(repo), - ) - == 0 - ) - event = next( - event for event in lv._accounting_report(lv.store_dir(repo))["events"] if event["event_kind"] == "ordered" - ) - assert [stage["argv"] for stage in event["stages"]] == [command, command + ["#required"]] - assert all(stage["tool_versions"] for stage in event["stages"]) - - -@pytest.mark.parametrize( - "field,value", - [ - ("schema", 1), - ("gate", "other"), - ("exit", 7), - ("identity", {}), - ("batch", {"wrong": True}), - ("delivery", {}), - ], -) -def test_corrupt_or_mismatched_receipt_executes_again(repo: Path, tmp_path: Path, field: str, value: object) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - receipt = next(store.rglob("*.json")) - data = json.loads(receipt.read_text(encoding="utf-8")) - data[field] = value - receipt.write_text(json.dumps(data), encoding="utf-8") - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert _count(marker) == 2 - - -def test_renamed_receipt_is_not_reused(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - receipt = next(store.rglob("*.json")) - receipt.rename(store / "legacy-receipt.json") - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert _count(marker) == 2 - - -def test_changed_command_invalidates_receipt(repo: Path, tmp_path: Path) -> None: - marker_a = tmp_path / "a.txt" - marker_b = tmp_path / "b.txt" - gate_a = [sys.executable, "-c", f"open({str(marker_a)!r}, 'a').write('x')"] - gate_b = [sys.executable, "-c", f"open({str(marker_b)!r}, 'a').write('x')"] - assert lv.run_gate("g", gate_a, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate_b, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker_a) == 1 and _count(marker_b) == 1 - - -def test_changed_environment_invalidates_receipt(repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - monkeypatch.setenv("BENCHBOX_TEST_LOCK_DIR", str(tmp_path / "other-lock")) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_unlisted_behavior_environment_invalidates_without_persisting_value( - repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - secret = "do-not-persist-this-value" - monkeypatch.setenv("BENCHBOX_GATE_MODE", secret) - store = lv.store_dir(repo) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - receipts = list(store.rglob("*.json")) - assert len(receipts) == 1 - receipt_text = receipts[0].read_text(encoding="utf-8") - assert secret not in receipt_text - monkeypatch.setenv("BENCHBOX_GATE_MODE", "changed") - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert _count(marker) == 2 - - -def test_changed_tool_identity_invalidates_receipt(repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - original = lv._tool_identity - monkeypatch.setattr( - lv, - "_tool_identity", - lambda executable, repo=None: {"path": original(executable, repo)["path"], "version": "changed"}, - ) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_tracked_edit_preserving_status_invalidates(repo: Path, tmp_path: Path) -> None: - """Same porcelain status text, different content: status alone must not hit.""" - tracked = Path(str(repo)) / "tracked.txt" - tracked.write_text("work1") - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 1 - before = subprocess.run( - ["git", "status", "--porcelain=v1", "--untracked-files=all"], - cwd=repo, - check=True, - capture_output=True, - text=True, - ).stdout - tracked.write_text("work2") - after = subprocess.run( - ["git", "status", "--porcelain=v1", "--untracked-files=all"], - cwd=repo, - check=True, - capture_output=True, - text=True, - ).stdout - assert after == before - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_active_ignored_skill_mirror_is_part_of_receipt_identity(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - - active = repo / ".agents/skills/todo/SKILL.md" - active.parent.mkdir(parents=True) - active.write_text("materialized-v1", encoding="utf-8") - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - - active.write_text("materialized-v2", encoding="utf-8") - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 3 - - -def test_different_gates_proceed_in_parallel(repo: Path, tmp_path: Path) -> None: - """Per-receipt locks: unrelated gates must not serialize on one lock.""" - marker = tmp_path / "slow.txt" - gate = [sys.executable, "-c", f"import time; time.sleep(2); open({str(marker)!r}, 'a').write('x')"] - store = lv.store_dir(repo) - results = [] - threads = [ - threading.Thread( - target=lambda gate_name=gate_name: results.append(lv.run_gate(gate_name, gate, None, 30.0, repo, store)) - ) - for gate_name in ("g1", "g2") - ] - started = time.monotonic() - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=60) - elapsed = time.monotonic() - started - assert results == [0, 0] - assert len(marker.read_text(encoding="utf-8")) == 2 - assert elapsed < 3.5 - - -def test_tree_change_during_execution_does_not_store_stale_receipt( - repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - original = lv.content_identity - calls = 0 - - def identity_that_changes_tree(path: Path, argv: list[str]) -> dict: - nonlocal calls - calls += 1 - identity = original(path, argv) - if calls == 2: - (path / "during-run.txt").write_text("changed", encoding="utf-8") - return identity - - monkeypatch.setattr(lv, "content_identity", identity_that_changes_tree) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert list(lv.store_dir(repo).rglob("*.json")) == [] - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def test_changed_validation_config_invalidates(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - (repo / "Makefile").write_text("validation config", encoding="utf-8") - assert lv.run_gate("g", gate, None, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - -def _commit_file(repo: Path, name: str, content: str) -> str: - (repo / name).write_text(content, encoding="utf-8") - subprocess.run(["git", "add", name], cwd=repo, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", f"add {name}"], cwd=repo, check=True, capture_output=True) - return subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True - ).stdout.strip() - - -def _tree(repo: Path, revision: str = "HEAD") -> str: - return subprocess.run( - ["git", "rev-parse", f"{revision}^{{tree}}"], cwd=repo, check=True, capture_output=True, text=True - ).stdout.strip() - - -def _member_record(repo: Path, member_id: str, source_base: str, member_head: str) -> dict[str, object]: - paths = lv._git_diff_paths(repo, source_base, member_head) - return { - "id": member_id, - "source_base": source_base, - "source_head": member_head, - "accepted_head": member_head, - "scope_hash": hashlib.sha256(json.dumps(paths, separators=(",", ":")).encode()).hexdigest(), - "config_hash": lv._batch_config_hash_at_commit(repo, member_head), - "changed_paths": paths, - } - - -def _valid_integrator_batch(repo: Path, predecessor_head: str, member_identity: list[dict]) -> dict[str, object]: - head = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True - ).stdout.strip() - return { - "batch_id": "batch-1", - "role": "integrator", - "integration_head": head, - "integration_tree": _tree(repo), - "predecessor_head": predecessor_head, - "predecessor_tree": _tree(repo, predecessor_head), - "member_identity": member_identity, - } - - -def test_batch_contracts_separate_roles_and_bind_combined_tree_predecessor_member(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - member_batch = _valid_member_batch(repo) - assert lv.run_gate("g", gate, member_batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, member_batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 1 - - member_one = _commit_file(repo, "member-one.txt", "one") - member_two = _commit_file(repo, "member-two.txt", "two") - integration_head = _commit_file(repo, "integration.txt", "combined") - source_base = subprocess.run( - ["git", "rev-parse", "origin/develop"], cwd=repo, check=True, capture_output=True, text=True - ).stdout.strip() - member_identity = [_member_record(repo, "member-a", source_base, member_one)] - integrator_batch = _valid_integrator_batch(repo, member_two, member_identity) - assert integrator_batch["integration_head"] == integration_head - assert lv.run_gate("g", gate, integrator_batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, integrator_batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - - integrator_batch["integration_tree"] = "0" * 40 - assert lv.run_gate("g", gate, integrator_batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 3 - integrator_batch["integration_tree"] = _tree(repo) - integrator_batch["predecessor_head"] = member_one - integrator_batch["predecessor_tree"] = _tree(repo, member_one) - assert lv.run_gate("g", gate, integrator_batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 4 - integrator_batch["member_identity"] = [dict(member_identity[0], scope_hash="3" * 64)] - assert lv.run_gate("g", gate, integrator_batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 5 - - -def test_integrator_rejects_arbitrary_member_identity_and_same_predecessor(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - predecessor = _commit_file(repo, "prior.txt", "prior") - current = _commit_file(repo, "current.txt", "current") - batch = { - "batch_id": "batch-1", - "role": "integrator", - "integration_head": current, - "integration_tree": _tree(repo), - "predecessor_head": predecessor, - "predecessor_tree": _tree(repo, predecessor), - "member_identity": "member-a@accepted-head", - } - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 2 - batch["member_identity"] = [ - _member_record( - repo, - "member-a", - subprocess.run( - ["git", "rev-parse", "origin/develop"], cwd=repo, check=True, capture_output=True, text=True - ).stdout.strip(), - predecessor, - ) - ] - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 3 - batch["predecessor_head"] = current - batch["predecessor_tree"] = _tree(repo) - assert lv.run_gate("g", gate, batch, 5.0, repo, lv.store_dir(repo)) == 0 - assert _count(marker) == 4 - - def test_wait_for_lock_closes_fd_when_cancelled(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: lock = tmp_path / "cancel.lock" closed: list[int] = [] @@ -915,211 +137,104 @@ def record_close(fd: int) -> None: assert closed -def test_ordered_path_runs_focused_before_required_preflight(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[str] = [] - focused = {"head": "a" * 40, "argv": ["focused"], "tool_versions": {}} - required = {"head": "b" * 40, "argv": ["required"], "tool_versions": {}} - identity = { - "batch": None, - "focused_gate": "focused", - "focused": focused, - "required_gate": "required-preflight", - "required": required, - } - - def fake_run_gate(gate: str, *args, **kwargs) -> tuple[int, str]: - calls.append(gate) - return 0, "reused" - - monkeypatch.setattr(lv, "run_gate_with_status", fake_run_gate) - monkeypatch.setattr(lv, "ordered_identity", lambda *args: identity) - monkeypatch.setattr(lv, "read_receipt", lambda *args, **kwargs: {}) - assert ( - lv.run_ordered_path( - focused_gate="focused", - focused_command=["focused"], - preflight_gate="required-preflight", - preflight_command=["required"], - batch=None, - lock_wait_seconds=1.0, - repo=repo, - store=lv.store_dir(repo), - ) - == 0 - ) - assert calls == ["focused", "required-preflight"] - - -def test_ordered_path_restarts_when_tree_or_batch_changes_between_stages( - repo: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - calls: list[str] = [] - - def transaction(token: str) -> dict: - return { - "batch": None, - "focused_gate": "focused", - "focused": {"head": token * 40, "argv": ["focused"], "tool_versions": {}}, - "required_gate": "required-preflight", - "required": {"head": token * 40, "argv": ["required"], "tool_versions": {}}, - } +def test_wait_on_fd_windows_uses_msvcrt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import types - identities = iter( - [ - transaction("a"), - transaction("b"), - transaction("b"), - transaction("b"), - transaction("b"), - ] - ) + lock = tmp_path / "win.lock" + lock.write_text("", encoding="utf-8") + fd = os.open(str(lock), os.O_CREAT | os.O_RDWR, 0o644) + calls: list[tuple[int, int]] = [] + fake = types.ModuleType("msvcrt") + fake.LK_NBLCK = 1 # type: ignore[attr-defined] + fake.LK_UNLCK = 0 # type: ignore[attr-defined] - monkeypatch.setattr(lv, "ordered_identity", lambda *args: next(identities)) + def fake_locking(fd_arg: int, mode: int, nbytes: int) -> None: + calls.append((mode, nbytes)) - def fake_run_gate(gate: str, *args, **kwargs) -> tuple[int, str]: - calls.append(gate) - return 0, "reused" + fake.locking = fake_locking # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "msvcrt", fake) + monkeypatch.setattr(lv.sys, "platform", "win32") + try: + lv.wait_on_fd(fd, lock, 5.0) + finally: + os.close(fd) + assert calls == [(1, 1)] - monkeypatch.setattr(lv, "run_gate_with_status", fake_run_gate) - monkeypatch.setattr(lv, "read_receipt", lambda *args, **kwargs: {}) - assert ( - lv.run_ordered_path( - focused_gate="focused", - focused_command=["focused-command"], - preflight_gate="required-preflight", - preflight_command=["preflight-command"], - batch={"role": "integrator", "integration_tree": "tree-one"}, - lock_wait_seconds=1.0, - repo=repo, - store=lv.store_dir(repo), - ) - == 0 - ) - assert calls == ["focused", "focused", "required-preflight"] +def test_read_holder_replaces_invalid_utf8(tmp_path: Path) -> None: + lock = tmp_path / "invalid.lock" + lock.write_bytes(b"pid:\xff\n") + assert lv.read_holder(lock) == "pid:\ufffd" -def test_worktree_local_paths_normalize_across_worktrees( - repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - first = tmp_path / "wt-a" - second = tmp_path / "wt-b" - first.mkdir() - second.mkdir() - monkeypatch.setenv("PATH", f"{first}/.venv/bin:/usr/bin") - monkeypatch.setenv("VIRTUAL_ENV", str(first / ".venv")) - env_first = lv._environment_identity(first) - monkeypatch.setenv("PATH", f"{second}/.venv/bin:/usr/bin") - monkeypatch.setenv("VIRTUAL_ENV", str(second / ".venv")) - env_second = lv._environment_identity(second) - assert env_first["PATH"] == env_second["PATH"] - assert env_first["VIRTUAL_ENV"] == env_second["VIRTUAL_ENV"] - assert lv._normalize_worktree_text(f"{first}/.venv/bin/pytest", first) == "/.venv/bin/pytest" - assert lv._normalize_worktree_text(f"{second}/.venv/bin/pytest", second) == "/.venv/bin/pytest" - assert lv._normalize_worktree_text("/usr/bin/make", first) == "/usr/bin/make" +@pytest.mark.skipif(sys.platform == "win32", reason="fcntl is POSIX-only") +def test_wait_on_fd_posix_propagates_non_contention_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import fcntl -def test_prepush_selector_excluded_from_identity(repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("BENCHBOX_PREPUSH", "1") - assert "BENCHBOX_PREPUSH" not in lv._environment_identity(repo) - monkeypatch.delenv("BENCHBOX_PREPUSH", raising=False) - monkeypatch.setenv("BENCHBOX_GATE_MODE", "mode-a") - assert "BENCHBOX_GATE_MODE" in lv._environment_identity(repo) - monkeypatch.delenv("BENCHBOX_GATE_MODE", raising=False) + lock = tmp_path / "posix.lock" + fd = os.open(str(lock), os.O_CREAT | os.O_RDWR, 0o644) - marker = tmp_path / "prepush-count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - monkeypatch.delenv("BENCHBOX_PREPUSH", raising=False) - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - monkeypatch.setenv("BENCHBOX_PREPUSH", "1") - assert lv.run_gate("g", gate, None, 5.0, repo, store) == 0 - assert _count(marker) == 1 + def denied(_fd: int, _flags: int) -> None: + raise PermissionError("lock denied") + monkeypatch.setattr(fcntl, "flock", denied) + try: + with pytest.raises(PermissionError, match="lock denied"): + lv.wait_on_fd(fd, lock, 5.0) + finally: + os.close(fd) -def test_integrator_member_order_is_part_of_identity(repo: Path) -> None: - _commit_file(repo, "prior.txt", "prior") - first = _commit_file(repo, "first.txt", "one") - second = _commit_file(repo, "second.txt", "two") - _commit_file(repo, "integration.txt", "combined") - source_base = subprocess.run( - ["git", "rev-parse", "origin/develop"], cwd=repo, check=True, capture_output=True, text=True - ).stdout.strip() - record_a = _member_record(repo, "member-a", source_base, first) - record_b = _member_record(repo, "member-b", source_base, second) - forward, reason = lv._canonical_member_identity(repo, [record_a, record_b]) - assert reason == "" - assert forward is not None and [member["id"] for member in forward] == ["member-a", "member-b"] - reverse, reason = lv._canonical_member_identity(repo, [record_b, record_a]) - assert reason == "" - assert reverse is not None and [member["id"] for member in reverse] == ["member-b", "member-a"] - assert forward != reverse - assert lv.receipt_path(Path("/tmp"), "g", {"h": 1}, {"member_identity": forward}) != lv.receipt_path( - Path("/tmp"), "g", {"h": 1}, {"member_identity": reverse} - ) +def test_wait_on_fd_windows_retries_contention(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import types -def test_ordered_uses_actual_singleflight_status(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: - command = [sys.executable, "-c", "pass"] - identity = { - "batch": None, - "focused_gate": "focused", - "focused": {"head": "a" * 40, "argv": ["focused"], "tool_versions": {}}, - "required_gate": "required-preflight", - "required": {"head": "b" * 40, "argv": ["required"], "tool_versions": {}}, - } - monkeypatch.setattr(lv, "ordered_identity", lambda *args: identity) - monkeypatch.setattr(lv, "read_receipt", lambda *args, **kwargs: {}) - monkeypatch.setattr(lv, "run_gate_with_status", lambda *args, **kwargs: (0, "reused")) - store = lv.store_dir(repo) - assert ( - lv.run_ordered_path( - focused_gate="focused", - focused_command=command, - preflight_gate="required-preflight", - preflight_command=command, - batch=None, - lock_wait_seconds=1.0, - repo=repo, - store=store, - ) - == 0 - ) - ordered = next(event for event in lv._accounting_report(store)["events"] if event["event_kind"] == "ordered") - assert ordered["executed_count"] == 0 and ordered["reused_count"] == 2 - assert ordered["command_executions"] == 0 - assert all(stage["status"] == "reused" for stage in ordered["stages"]) + lock = tmp_path / "win-contended.lock" + fd = os.open(str(lock), os.O_CREAT | os.O_RDWR, 0o644) + fake = types.ModuleType("msvcrt") + fake.LK_NBLCK = 1 # type: ignore[attr-defined] + calls = 0 + def locking(_fd: int, _mode: int, _nbytes: int) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise OSError(errno.EACCES, "lock held") -def test_run_gate_with_status_reports_reuse(repo: Path, tmp_path: Path) -> None: - marker = tmp_path / "count.txt" - gate = _counter_gate(marker) - store = lv.store_dir(repo) - code, status = lv.run_gate_with_status("g", gate, None, 5.0, repo, store) - assert (code, status) == (0, "executed") - code, status = lv.run_gate_with_status("g", gate, None, 5.0, repo, store) - assert (code, status) == (0, "reused") - assert _count(marker) == 1 + fake.locking = locking # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "msvcrt", fake) + monkeypatch.setattr(lv.sys, "platform", "win32") + monkeypatch.setattr(lv.time, "sleep", lambda _seconds: None) + try: + lv.wait_on_fd(fd, lock, 5.0) + finally: + os.close(fd) + assert calls == 2 -def test_wait_on_fd_windows_uses_msvcrt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("failure_errno", [errno.EBADF, errno.EINVAL]) +def test_wait_on_fd_windows_propagates_non_contention_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure_errno: int +) -> None: import types - lock = tmp_path / "win.lock" - lock.write_text("", encoding="utf-8") + lock = tmp_path / "win-invalid.lock" fd = os.open(str(lock), os.O_CREAT | os.O_RDWR, 0o644) - calls: list[tuple[int, int]] = [] fake = types.ModuleType("msvcrt") fake.LK_NBLCK = 1 # type: ignore[attr-defined] - fake.LK_UNLCK = 0 # type: ignore[attr-defined] + calls = 0 - def fake_locking(fd_arg: int, mode: int, nbytes: int) -> None: - calls.append((mode, nbytes)) + def locking(_fd: int, _mode: int, _nbytes: int) -> None: + nonlocal calls + calls += 1 + raise OSError(failure_errno, "lock failed") - fake.locking = fake_locking # type: ignore[attr-defined] + fake.locking = locking # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "msvcrt", fake) monkeypatch.setattr(lv.sys, "platform", "win32") try: - lv.wait_on_fd(fd, lock, 5.0) + with pytest.raises(OSError) as error: + lv.wait_on_fd(fd, lock, 5.0) finally: os.close(fd) - assert calls == [(1, 1)] + assert error.value.errno == failure_errno + assert calls == 1 diff --git a/tests/unit/scripts/test_make_interface_summary.py b/tests/unit/scripts/test_make_interface_summary.py new file mode 100644 index 0000000000..8b06f3470f --- /dev/null +++ b/tests/unit/scripts/test_make_interface_summary.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import importlib.util +import shutil +import subprocess +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "make" / "interface_summary.py" + + +def _load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("interface_summary", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _git(root: Path, *arguments: str) -> None: + subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", *arguments], + cwd=root, + check=True, + capture_output=True, + ) + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + shutil.copy2(REPO_ROOT / "Makefile", tmp_path / "Makefile") + shutil.copytree( + REPO_ROOT / "make", + tmp_path / "make", + ignore=shutil.ignore_patterns("inventory.json", "__pycache__"), + ) + _git(tmp_path, "init", "-q") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-q", "-m", "base") + return tmp_path + + +def test_summary_lists_added_removed_and_changed_public_targets(repository: Path, tmp_path_factory) -> None: + module = _load_module() + makefile = repository / "Makefile" + text = makefile.read_text(encoding="utf-8") + assert "test-fast:\n" in text + makefile.write_text( + text.replace("test-fast:\n", "test-fast-renamed:\n", 1) + + "\n.PHONY: probe-target\nprobe-target:\n\t@echo probe\n", + encoding="utf-8", + ) + _git(repository, "commit", "-q", "-am", "head") + module.ROOT = repository + summary = tmp_path_factory.mktemp("summary") / "summary.md" + + assert module.main(["--base-ref", "HEAD~1", "--summary", str(summary)]) == 0 + + text = summary.read_text(encoding="utf-8") + assert "**Removed public targets (1)**" in text and "`test-fast`" in text + assert "`test-fast-renamed`" in text and "`probe-target`" in text + + +def test_summary_reports_an_evaluation_change_without_a_public_change(repository: Path, tmp_path_factory) -> None: + module = _load_module() + makefile = repository / "Makefile" + makefile.write_text(makefile.read_text(encoding="utf-8") + "\nPROBE_VARIABLE := probe\n", encoding="utf-8") + _git(repository, "commit", "-q", "-am", "head") + module.ROOT = repository + summary = tmp_path_factory.mktemp("summary") / "summary.md" + + assert module.main(["--base-ref", "HEAD~1", "--summary", str(summary)]) == 0 + + text = summary.read_text(encoding="utf-8") + assert "`PROBE_VARIABLE`" in text + assert "No public target or recipe changed, but Make's evaluation changed." in text + assert "Make's evaluation is unchanged." not in text + + +def test_summary_is_empty_when_no_make_file_changed(repository: Path, tmp_path_factory) -> None: + module = _load_module() + (repository / "unrelated.txt").write_text("x\n", encoding="utf-8") + _git(repository, "add", "unrelated.txt") + _git(repository, "commit", "-q", "-m", "head") + module.ROOT = repository + summary = tmp_path_factory.mktemp("summary") / "summary.md" + + assert module.main(["--base-ref", "HEAD~1", "--summary", str(summary)]) == 0 + + assert not summary.exists() + + +def test_summary_never_fails_on_an_unknown_base(repository: Path, capsys: pytest.CaptureFixture[str]) -> None: + module = _load_module() + module.ROOT = repository + + assert module.main(["--base-ref", "does-not-exist"]) == 0 + + assert "Not computed" in capsys.readouterr().out diff --git a/tests/unit/scripts/test_oracle_review_check.py b/tests/unit/scripts/test_oracle_review_check.py new file mode 100644 index 0000000000..63dd03c906 --- /dev/null +++ b/tests/unit/scripts/test_oracle_review_check.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "_project/scripts/oracle_review_check.py" +sys.path.insert(0, str(SCRIPT.parent)) +SPEC = importlib.util.spec_from_file_location("oracle_review_check", SCRIPT) +assert SPEC and SPEC.loader +oracle_review_check = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = oracle_review_check +SPEC.loader.exec_module(oracle_review_check) + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +HEAD = "a" * 40 +OLDER = "b" * 40 +HEAD_DATE = "2026-10-01T12:00:00Z" +BEFORE_HEAD = "2026-10-01T11:00:00Z" +AFTER_HEAD = "2026-10-01T13:00:00Z" +SOUNDNESS_FILES = ["benchbox/core/equivalence/checker.py", "README.md"] +OTHER_FILES = ["README.md", "docs/index.md"] +CONNECTOR = "chatgpt-codex-connector[bot]" + + +def _decide( + files: list[str] = SOUNDNESS_FILES, + reviews: list[dict[str, Any]] | None = None, + reactions: list[dict[str, Any]] | None = None, + threads: list[dict[str, Any]] | None = None, + base_date: str | None = None, + comments: list[dict[str, Any]] | None = None, +) -> tuple[int, str]: + return oracle_review_check.decide( + HEAD, + HEAD_DATE, + files, + reviews or [], + reactions or [], + threads or [], + oracle_review_check.any_soundness_path, + base_date, + comments or [], + ) + + +def _review( + login: str = CONNECTOR, commit_id: str = HEAD, state: str = "COMMENTED", submitted_at: str = AFTER_HEAD +) -> dict[str, Any]: + return {"login": login, "commit_id": commit_id, "state": state, "submitted_at": submitted_at} + + +def _reaction(content: str = "+1", created_at: str = AFTER_HEAD, login: str = CONNECTOR) -> dict[str, Any]: + return {"login": login, "content": content, "created_at": created_at} + + +def _thread(resolved: bool, author: str = "chatgpt-codex-connector") -> dict[str, Any]: + return {"resolved": resolved, "author": author} + + +def test_non_soundness_change_skips() -> None: + status, message = _decide(files=OTHER_FILES) + assert status == 0 + assert message == "oracle-review: not a soundness path change" + + +def test_review_on_head_passes() -> None: + status, message = _decide(reviews=[_review()]) + assert status == 0 + assert "review" in message + assert HEAD in message + + +def test_review_on_older_commit_waits() -> None: + status, message = _decide(reviews=[_review(commit_id=OLDER)]) + assert status == 1 + assert message == ( + f"oracle-review: waiting for the Codex connector's review of {HEAD}, or a stand-in attestation " + f"'Stand-in oracle review: APPROVE {HEAD}' from a listed attester; rerun this check after it lands" + ) + + +def test_thumbs_up_after_head_passes() -> None: + status, message = _decide(reactions=[_reaction()]) + assert status == 0 + assert "+1" in message + + +def test_thumbs_up_before_head_waits() -> None: + status, _ = _decide(reactions=[_reaction(created_at=BEFORE_HEAD)]) + assert status == 1 + + +def test_eyes_only_waits() -> None: + status, _ = _decide(reactions=[_reaction(content="eyes")]) + assert status == 1 + + +def test_unresolved_connector_thread_waits_even_with_review() -> None: + status, message = _decide(reviews=[_review()], threads=[_thread(resolved=False)]) + assert status == 1 + assert "unresolved" in message + + +def test_resolved_connector_thread_with_review_passes() -> None: + status, _ = _decide(reviews=[_review()], threads=[_thread(resolved=True)]) + assert status == 0 + + +def test_unresolved_thread_by_another_author_does_not_block() -> None: + status, _ = _decide(reviews=[_review()], threads=[_thread(resolved=False, author="joeharris76")]) + assert status == 0 + + +def test_owner_review_does_not_count() -> None: + status, _ = _decide(reviews=[_review(login="joeharris76")]) + assert status == 1 + + +def test_owner_thumbs_up_does_not_count() -> None: + status, _ = _decide(reactions=[_reaction(login="joeharris76")]) + assert status == 1 + + +def test_pending_review_does_not_count() -> None: + status, _ = _decide(reviews=[_review(state="PENDING")]) + assert status == 1 + + +def test_rename_out_of_a_soundness_path_is_a_soundness_change() -> None: + files = oracle_review_check.changed_paths( + [{"filename": "docs/moved.py", "previous_filename": "benchbox/core/equivalence/checker.py"}] + ) + assert files == ["docs/moved.py", "benchbox/core/equivalence/checker.py"] + status, _ = _decide(files=files) + assert status == 1 + + +def test_changed_paths_without_a_rename_keeps_only_the_filename() -> None: + assert oracle_review_check.changed_paths([{"filename": "README.md"}]) == ["README.md"] + + +def test_backdated_head_commit_does_not_make_an_older_thumbs_up_count() -> None: + backdated_commit = "2026-09-01T00:00:00Z" + push_run = "2026-10-01T14:00:00Z" + head_date = oracle_review_check.head_transition_date(backdated_commit, [push_run]) + assert head_date == push_run + status, _ = oracle_review_check.decide( + HEAD, + head_date, + SOUNDNESS_FILES, + [], + [_reaction(created_at=AFTER_HEAD)], + [], + oracle_review_check.any_soundness_path, + ) + assert status == 1 + + +def test_head_transition_date_falls_back_to_the_committer_date_without_runs() -> None: + assert oracle_review_check.head_transition_date(HEAD_DATE, []) == HEAD_DATE + + +def test_head_transition_date_keeps_a_later_committer_date() -> None: + assert oracle_review_check.head_transition_date(AFTER_HEAD, [HEAD_DATE]) == AFTER_HEAD + + +def test_truncated_file_list_is_treated_as_a_soundness_change() -> None: + matcher = oracle_review_check.path_matcher(listed_files=3000, changed_files=3001) + assert matcher(OTHER_FILES) is True + status, _ = oracle_review_check.decide(HEAD, HEAD_DATE, OTHER_FILES, [], [], [], matcher) + assert status == 1 + + +def test_complete_file_list_uses_the_soundness_predicate() -> None: + matcher = oracle_review_check.path_matcher(listed_files=2, changed_files=2) + assert matcher(OTHER_FILES) is False + assert matcher(SOUNDNESS_FILES) is True + + +def _run(created_at: str, action: str, path: str = ".github/workflows/oracle-review.yml") -> dict[str, Any]: + return {"path": path, "created_at": created_at, "display_title": f"oracle-review ({action})"} + + +def test_own_run_dates_match_plain_and_ref_qualified_workflow_paths() -> None: + runs = [ + _run("2026-10-01T14:00:00Z", "opened"), + _run("2026-10-01T15:00:00Z", "synchronize", ".github/workflows/oracle-review.yml@refs/pull/7/merge"), + _run("2026-10-01T16:00:00Z", "synchronize", ".github/workflows/ci.yml"), + {"created_at": "2026-10-01T17:00:00Z"}, + ] + assert oracle_review_check.own_run_dates(runs) == [ + "2026-10-01T14:00:00Z", + "2026-10-01T14:00:00Z", + "2026-10-01T15:00:00Z", + ] + + +def test_reopened_and_ready_runs_do_not_move_the_head_transition() -> None: + runs = [ + _run("2026-10-01T12:30:00Z", "opened"), + _run("2026-10-01T15:00:00Z", "reopened"), + _run("2026-10-01T15:30:00Z", "ready_for_review"), + ] + dates = oracle_review_check.own_run_dates(runs) + assert oracle_review_check.head_transition_date(HEAD_DATE, dates) == "2026-10-01T12:30:00Z" + + +def test_first_run_for_a_head_that_advanced_while_closed_is_a_transition() -> None: + dates = oracle_review_check.own_run_dates([_run("2026-10-01T15:00:00Z", "reopened")]) + assert oracle_review_check.head_transition_date(HEAD_DATE, dates) == "2026-10-01T15:00:00Z" + + +def test_latest_run_for_a_restored_head_moves_the_head_transition() -> None: + first_run = "2026-10-01T12:30:00Z" + restored_run = "2026-10-01T16:00:00Z" + head_date = oracle_review_check.head_transition_date(HEAD_DATE, [first_run, restored_run]) + assert head_date == restored_run + status, _ = oracle_review_check.decide( + HEAD, + head_date, + SOUNDNESS_FILES, + [], + [_reaction(created_at=AFTER_HEAD)], + [], + oracle_review_check.any_soundness_path, + ) + assert status == 1 + + +RETARGETED = "2026-10-01T14:00:00Z" +AFTER_RETARGET = "2026-10-01T15:00:00Z" + + +def test_review_before_a_retarget_waits() -> None: + status, _ = _decide(reviews=[_review()], base_date=RETARGETED) + assert status == oracle_review_check.WAITING + + +def test_review_after_a_retarget_passes() -> None: + status, _ = _decide(reviews=[_review(submitted_at=AFTER_RETARGET)], base_date=RETARGETED) + assert status == oracle_review_check.PASS + + +def test_thumbs_up_before_a_retarget_waits() -> None: + status, _ = _decide(reactions=[_reaction()], base_date=RETARGETED) + assert status == oracle_review_check.WAITING + + +def test_thumbs_up_after_a_retarget_passes() -> None: + status, _ = _decide(reactions=[_reaction(created_at=AFTER_RETARGET)], base_date=RETARGETED) + assert status == oracle_review_check.PASS + + +def test_latest_base_change_picks_the_newest_retarget() -> None: + events = [ + {"event": "base_ref_changed", "created_at": "2026-10-01T10:00:00Z"}, + {"event": "commented", "created_at": "2026-10-01T16:00:00Z"}, + {"event": "base_ref_changed", "created_at": RETARGETED}, + ] + assert oracle_review_check.latest_base_change(events) == RETARGETED + assert oracle_review_check.latest_base_change([{"event": "commented", "created_at": RETARGETED}]) is None + + +def _attestation( + sha: str = HEAD, + login: str = "joeharris76", + created_at: str = AFTER_HEAD, + prefix: str = "", + updated_at: str | None = None, + user_type: str = "User", + body: str | None = None, +) -> dict[str, Any]: + text = body or f"Stand-in review by agy: no P0/P1 findings.\n\n{prefix}Stand-in oracle review: APPROVE {sha}\n" + return { + "login": login, + "user_type": user_type, + "body": text, + "created_at": created_at, + "updated_at": updated_at or created_at, + } + + +def test_standin_attestation_for_the_head_passes() -> None: + status, message = _decide(comments=[_attestation()]) + assert status == oracle_review_check.PASS + assert "stand-in review attested by joeharris76" in message + + +@pytest.mark.parametrize( + "comment", + [ + _attestation(sha=OLDER), + _attestation(created_at=BEFORE_HEAD), + _attestation(login="someone-else"), + _attestation(prefix="> "), + _attestation(body="Looks good to me"), + _attestation(updated_at="2026-10-01T14:00:00Z"), + _attestation(login="joeharris76[bot]", user_type="Bot"), + _attestation(user_type="Bot"), + _attestation(body=f"```\nStand-in oracle review: APPROVE {HEAD}\n```\n"), + _attestation(body=f"I would write Stand-in oracle review: APPROVE {HEAD} here"), + _attestation(sha=HEAD.upper()), + ], + ids=[ + "older-commit", + "before-head", + "not-an-attester", + "quoted", + "no-marker", + "edited", + "bot-suffix", + "bot-account", + "fenced", + "mid-line", + "uppercase-sha", + ], +) +def test_standin_attestation_must_be_exact_current_and_authorized(comment: dict[str, Any]) -> None: + status, _ = _decide(comments=[comment]) + assert status == oracle_review_check.WAITING + + +def test_standin_attestation_does_not_override_open_connector_threads() -> None: + status, message = _decide(comments=[_attestation()], threads=[_thread(resolved=False)]) + assert status == oracle_review_check.WAITING + assert "unresolved Codex connector review thread" in message + + +def test_standin_attestation_before_a_retarget_waits() -> None: + status, _ = _decide(comments=[_attestation(created_at=AFTER_HEAD)], base_date="2026-10-01T14:00:00Z") + assert status == oracle_review_check.WAITING + + +def test_a_later_valid_attestation_counts_after_an_earlier_invalid_one() -> None: + status, _ = _decide(comments=[_attestation(sha=OLDER), _attestation()]) + assert status == oracle_review_check.PASS + + +def test_connector_review_message_wins_over_a_standin() -> None: + status, message = _decide(reviews=[_review()], comments=[_attestation()]) + assert status == oracle_review_check.PASS + assert "Codex connector review" in message + + +def test_waiting_message_names_the_standin_marker() -> None: + _, message = _decide() + assert f"Stand-in oracle review: APPROVE {HEAD}" in message + + +def test_fetch_comments_maps_author_type_and_edit_time(monkeypatch: pytest.MonkeyPatch) -> None: + item = { + "user": {"login": "joeharris76", "type": "User"}, + "body": "x", + "created_at": AFTER_HEAD, + "updated_at": AFTER_HEAD, + } + monkeypatch.setattr(oracle_review_check, "_paginate", lambda _token, _path: [item]) + assert oracle_review_check.fetch_comments("t", "o/r", 1) == [ + {"login": "joeharris76", "user_type": "User", "body": "x", "created_at": AFTER_HEAD, "updated_at": AFTER_HEAD} + ] diff --git a/tests/unit/scripts/test_pr_refresh_certification.py b/tests/unit/scripts/test_pr_refresh_certification.py index 5d52dd8f44..9f611eb6ed 100644 --- a/tests/unit/scripts/test_pr_refresh_certification.py +++ b/tests/unit/scripts/test_pr_refresh_certification.py @@ -379,6 +379,11 @@ def test_fallback_unknown_path() -> None: assert result.reasons == [REASON_UNKNOWN_PATH] +def test_website_paths_are_known_roots() -> None: + result = classify(_eligible_request(authored_paths=["website/src/pages/index.astro"])) + assert REASON_UNKNOWN_PATH not in result.reasons + + def test_fallback_missing_path_evidence() -> None: result = classify(_eligible_request(authored_paths=None, intervening_paths=None)) assert result.decision == DECISION_FULL diff --git a/tests/unit/scripts/test_pr_workflow_outputs.py b/tests/unit/scripts/test_pr_workflow_outputs.py deleted file mode 100644 index 97de6836d1..0000000000 --- a/tests/unit/scripts/test_pr_workflow_outputs.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Regression guard for the develop-PR gate's path-filter plumbing. - -Every `needs.ci-paths.outputs.` referenced anywhere in -``.github/workflows/pr.yml`` MUST be declared in the ``ci-paths`` job's -``outputs:`` map. GitHub Actions only propagates a step output to -``needs..outputs.`` when the job re-exports it under ``outputs:``; -an undeclared reference silently evaluates to the empty string, so a job gated -on it (``== 'true'``) is permanently skipped. That exact bug once made -``package-smoke``/``dependency-audit``/``parity-check`` no-ops that -``ci-required-result`` still counted as passing. This test fails closed so it -cannot silently recur. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -PR_WORKFLOW = Path(__file__).resolve().parents[3] / ".github" / "workflows" / "pr.yml" - -_REFERENCE_RE = re.compile(r"needs\.ci-paths\.outputs\.([A-Za-z0-9_-]+)") - - -def _declared_ci_paths_outputs() -> set[str]: - data = yaml.safe_load(PR_WORKFLOW.read_text(encoding="utf-8")) - ci_paths = data["jobs"]["ci-paths"] - return set((ci_paths.get("outputs") or {}).keys()) - - -def _referenced_ci_paths_outputs() -> set[str]: - return set(_REFERENCE_RE.findall(PR_WORKFLOW.read_text(encoding="utf-8"))) - - -def test_every_referenced_ci_paths_output_is_declared() -> None: - declared = _declared_ci_paths_outputs() - referenced = _referenced_ci_paths_outputs() - undeclared = referenced - declared - assert not undeclared, ( - "pr.yml references ci-paths outputs that are not declared in the " - f"ci-paths job's `outputs:` map: {sorted(undeclared)}. Undeclared " - "outputs resolve to '' at runtime, so any job gated on them is " - "permanently skipped (and counted as passing). Add each to the " - "ci-paths `outputs:` map as `: ${{ steps.classify.outputs. }}`." - ) - - -def test_packaging_and_viz_gates_are_wired() -> None: - # These three jobs regressed to permanent-skip once; pin them explicitly so - # a future refactor that drops the declaration is caught by name. - declared = _declared_ci_paths_outputs() - for required in ("packaging-needed", "viz-needed"): - assert required in declared, ( - f"ci-paths must declare `{required}` — package-smoke / dependency-audit / parity-check gate on it." - ) diff --git a/tests/unit/scripts/test_preflight_targets.py b/tests/unit/scripts/test_preflight_targets.py new file mode 100644 index 0000000000..35ce7b368e --- /dev/null +++ b/tests/unit/scripts/test_preflight_targets.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +SCRIPT = Path(__file__).resolve().parents[3] / "_project" / "scripts" / "preflight_targets.py" +spec = importlib.util.spec_from_file_location("preflight_targets", SCRIPT) +assert spec is not None and spec.loader is not None +pt = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = pt +spec.loader.exec_module(pt) + + +def _git(repo: Path, *args: str) -> None: + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, env=env) + + +def _write(repo: Path, rel: str, text: str = "x = 1\n") -> None: + path = repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + root.mkdir() + for args in ( + ["init", "-b", "main"], + ["config", "user.email", "test@example.com"], + ["config", "user.name", "Test"], + ["config", "commit.gpgsign", "false"], + ): + _git(root, *args) + _write(root, "benchbox/core/engine.py") + _write(root, "benchbox/core/__init__.py", "") + _write(root, "benchbox/platforms/other.py") + _write(root, "scripts/tool.py") + _write(root, "tests/unit/core/test_engine.py") + _write(root, "tests/integration/test_engine.py") + _write(root, "tests/unit/test_tool.py") + _write(root, "tests/unit/test_unrelated.py") + _write(root, "docs/guide.md", "# guide\n") + _git(root, "add", ".") + _git(root, "commit", "-m", "init") + _git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + _git(root, "checkout", "-b", "feature") + return root + + +def _commit_all(repo: Path, message: str = "change") -> None: + _git(repo, "add", ".") + _git(repo, "commit", "-m", message) + + +def test_source_module_maps_to_every_matching_test_file(repo: Path) -> None: + _write(repo, "benchbox/core/engine.py", "x = 2\n") + _commit_all(repo) + + paths = pt.changed_paths(repo, "origin/develop") + + assert pt.map_tests(repo, paths) == ["tests/integration/test_engine.py", "tests/unit/core/test_engine.py"] + + +def test_changed_test_file_maps_to_itself(repo: Path) -> None: + _write(repo, "tests/unit/test_unrelated.py", "y = 2\n") + _commit_all(repo) + + assert pt.map_tests(repo, pt.changed_paths(repo, "origin/develop")) == ["tests/unit/test_unrelated.py"] + + +def test_scripts_module_maps_to_its_test(repo: Path) -> None: + _write(repo, "scripts/tool.py", "x = 2\n") + _commit_all(repo) + + assert pt.map_tests(repo, pt.changed_paths(repo, "origin/develop")) == ["tests/unit/test_tool.py"] + + +def test_unmapped_changes_select_no_tests(repo: Path) -> None: + _write(repo, "benchbox/platforms/other.py", "x = 2\n") + _write(repo, "benchbox/core/__init__.py", "x = 2\n") + _write(repo, "docs/guide.md", "# changed\n") + _commit_all(repo) + + assert pt.map_tests(repo, pt.changed_paths(repo, "origin/develop")) == [] + + +def test_uncommitted_and_untracked_changes_are_included(repo: Path) -> None: + _write(repo, "benchbox/core/engine.py", "x = 3\n") + _write(repo, "tests/unit/test_new.py") + _git(repo, "add", "tests/unit/test_new.py") + _write(repo, "scripts/untracked.py") + + paths = pt.changed_paths(repo, "origin/develop") + + assert paths == ["benchbox/core/engine.py", "scripts/untracked.py", "tests/unit/test_new.py"] + assert pt.changed_python(repo, paths) == paths + + +def test_deleted_source_still_selects_its_remaining_tests(repo: Path) -> None: + (repo / "benchbox/core/engine.py").unlink() + _commit_all(repo) + + paths = pt.changed_paths(repo, "origin/develop") + + assert paths == ["benchbox/core/engine.py"] + assert pt.changed_python(repo, paths) == [] + assert pt.map_tests(repo, paths) == ["tests/integration/test_engine.py", "tests/unit/core/test_engine.py"] + + +def test_deleted_test_file_is_not_selected(repo: Path) -> None: + (repo / "tests/unit/test_unrelated.py").unlink() + _commit_all(repo) + + paths = pt.changed_paths(repo, "origin/develop") + + assert paths == ["tests/unit/test_unrelated.py"] + assert pt.map_tests(repo, paths) == [] + + +def test_renamed_source_keeps_the_old_path_for_test_mapping(repo: Path) -> None: + _git(repo, "mv", "benchbox/core/engine.py", "benchbox/core/motor.py") + _commit_all(repo) + + paths = pt.changed_paths(repo, "origin/develop") + + assert paths == ["benchbox/core/engine.py", "benchbox/core/motor.py"] + assert pt.map_tests(repo, paths) == ["tests/integration/test_engine.py", "tests/unit/core/test_engine.py"] + + +def test_project_scripts_module_maps_to_its_test(repo: Path) -> None: + _write(repo, "_project/scripts/audit.py") + _write(repo, "tests/unit/scripts/test_audit.py") + _commit_all(repo) + _write(repo, "_project/scripts/audit.py", "x = 2\n") + + assert pt.map_tests(repo, pt.changed_paths(repo, "origin/develop")) == ["tests/unit/scripts/test_audit.py"] + + +def test_cli_prints_selection_and_reports_missing_base(repo: Path, capsys: pytest.CaptureFixture[str]) -> None: + _write(repo, "benchbox/core/engine.py", "x = 2\n") + _commit_all(repo) + + assert pt.main(["python", "--repo-root", str(repo)]) == 0 + assert capsys.readouterr().out.split() == ["benchbox/core/engine.py"] + assert pt.main(["tests", "--repo-root", str(repo), "--base-ref", "origin/missing"]) == 2 diff --git a/tests/unit/scripts/test_pytest_shard_evidence.py b/tests/unit/scripts/test_pytest_shard_evidence.py new file mode 100644 index 0000000000..8dc8cd6617 --- /dev/null +++ b/tests/unit/scripts/test_pytest_shard_evidence.py @@ -0,0 +1,263 @@ +"""Exercise shard evidence against real serial and distributed pytest runs.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.release_canary_sharding import ( + MEDIUM_SHARD_COUNT, + collect_node_ids, + partition_node_ids, + verify_medium_shards, +) + +pytestmark = [pytest.mark.unit, pytest.mark.medium] +ROOT = Path(__file__).resolve().parents[3] + + +@pytest.mark.parametrize("workers", [0, 2]) +@pytest.mark.parametrize( + "case", + ["complete", "missing", "failure", "skip", "call-skip", "xfail", "xpass", "teardown-failure"], +) +def test_actual_pytest_shard_execution(tmp_path: Path, workers: int, case: str) -> None: + tests = tmp_path / "tests" + tests.mkdir() + body = "def test_a():\n assert True\n\ndef test_b():\n assert True\n" + if case == "failure": + body = body.replace("def test_b():\n assert True", "def test_b():\n assert False") + if case == "skip": + body = "import pytest\n" + body.replace("def test_b():", "@pytest.mark.skip(reason='fixture')\ndef test_b():") + elif case == "call-skip": + body = "import pytest\n" + body.replace( + "def test_b():\n assert True", "def test_b():\n pytest.skip('optional runtime')" + ) + elif case in {"xfail", "xpass"}: + body = "import pytest\n" + body.replace( + "def test_b():", "@pytest.mark.xfail(reason='known limitation')\ndef test_b():" + ) + if case == "xfail": + body = body.replace("def test_b():\n assert True", "def test_b():\n assert False") + elif case == "teardown-failure": + body = ( + "import pytest\n@pytest.fixture\ndef cleanup():\n yield\n raise RuntimeError('teardown failure')\n" + + body.replace("def test_b():", "def test_b(cleanup):") + ) + (tests / "test_cases.py").write_text(body) + (tmp_path / "pytest.ini").write_text("[pytest]\naddopts =\n") + assigned = ["tests/test_cases.py::test_a", "tests/test_cases.py::test_b"] + assignment = tmp_path / "assignment.txt" + assignment.write_text("\n".join(assigned) + "\n") + evidence = tmp_path / "execution.json" + selected = assigned[:1] if case == "missing" else assigned + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + "-n", + str(workers), + "-p", + "xdist.plugin", + "-p", + "scripts.pytest_shard_evidence", + "--assigned-nodeids", + str(assignment), + "--shard-evidence", + str(evidence), + "--checked-sha", + "a" * 40, + *selected, + ], + cwd=tmp_path, + env={**os.environ, "PYTHONPATH": str(ROOT), "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"}, + capture_output=True, + text=True, + timeout=30, + ) + payload = json.loads(evidence.read_text()) + assert result.returncode == (1 if case in {"missing", "failure", "teardown-failure"} else 0), ( + result.stdout + result.stderr + ) + assert payload["complete"] is (case != "missing") + assert payload["commit_sha"] == "a" * 40 + assert payload["assigned_node_ids"] == assigned + assert len(payload["collected_node_ids"]) == (workers or 1) + assert all(collection == selected for collection in payload["collected_node_ids"]) + assert payload["executed_node_ids"] == selected + assert payload["pytest_exit_status"] == (1 if case in {"failure", "teardown-failure"} else 0) + outcomes = payload["node_outcomes"] + assert [item["node_id"] for item in outcomes] == selected + first = outcomes[0]["reports"] + assert [report["phase"] for report in first] == ["setup", "call", "teardown"] + assert all(report["outcome"] == "passed" for report in first) + if case != "missing": + reports = {report["phase"]: report for report in outcomes[1]["reports"]} + phase = "setup" if case == "skip" else "teardown" if case == "teardown-failure" else "call" + expected = ( + "failed" + if case in {"failure", "teardown-failure"} + else "skipped" + if case in {"skip", "call-skip", "xfail"} + else "passed" + ) + assert reports[phase]["outcome"] == expected + if expected == "skipped": + reason = "fixture" if case == "skip" else "optional runtime" if case == "call-skip" else "known limitation" + assert reason in reports[phase]["skip_reason"] + if case in {"xfail", "xpass"}: + assert reports["call"]["xfail_reason"] == "known limitation" + + +@pytest.mark.parametrize("workers", [0, 2]) +@pytest.mark.parametrize("case", ["skip", "xfail-no-reason", "xfail-whitespace", "xpass-no-reason"]) +def test_real_medium_receipts_reject_missing_or_inconsistent_outcomes(tmp_path: Path, workers: int, case: str) -> None: + project = tmp_path / "project" + tests = project / "tests" + tests.mkdir(parents=True) + (project / "pytest.ini").write_text("[pytest]\naddopts =\n") + decorator = "@pytest.mark.skip(reason='optional engine absent')" + assertion = "assert True" + if case != "skip": + decorator = "@pytest.mark.xfail(reason=' ')" if case == "xfail-whitespace" else "@pytest.mark.xfail" + assertion = "assert True" if case == "xpass-no-reason" else "assert False" + (tests / "test_cases.py").write_text( + "import pytest\n" + "def test_a():\n assert True\n" + f"{decorator}\ndef test_b():\n {assertion}\n" + "def test_c():\n assert True\n" + "def test_d():\n assert True\n" + ) + env = {**os.environ, "PYTHONPATH": str(ROOT), "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"} + collection_result = subprocess.run( + [sys.executable, "-m", "pytest", "--collect-only", "-q"], + cwd=project, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + assert collection_result.returncode == 0, collection_result.stdout + collection_result.stderr + sha = "a" * 40 + artifacts = tmp_path / "artifacts" + collection = artifacts / f"t2-medium-nodeids-{sha}" + collection.mkdir(parents=True) + output = collection / "collection.txt" + output.write_text(collection_result.stdout) + collect_node_ids( + output, + collection / "medium-nodeids.txt", + collection / "medium-collection.json", + expected_count=4, + shard_count=MEDIUM_SHARD_COUNT, + checked_sha=sha, + workflow="ci.yml", + job="medium-collect", + marker_expression="medium and not (slow or stress or resource_heavy or live_integration)", + ) + ids = (collection / "medium-nodeids.txt").read_text().splitlines() + for index in range(MEDIUM_SHARD_COUNT): + assigned = partition_node_ids(ids, index, MEDIUM_SHARD_COUNT) + shard = artifacts / f"t2-medium-shard-{index}-{sha}" + shard.mkdir() + assignment = shard / "assignment.txt" + assignment.write_text("\n".join(assigned) + "\n") + evidence = shard / f"shard-{index}-execution.json" + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + "-n", + str(workers), + "-p", + "xdist.plugin", + "-p", + "scripts.pytest_shard_evidence", + "--assigned-nodeids", + str(assignment), + "--shard-evidence", + str(evidence), + "--checked-sha", + sha, + *assigned, + ], + cwd=project, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + verify_medium_shards(artifacts, sha) + receipt = json.loads((artifacts / f"t2-medium-shard-1-{sha}" / "shard-1-execution.json").read_text()) + reports = {report["phase"]: report for report in receipt["node_outcomes"][0]["reports"]} + if case != "skip": + assert reports["call"]["xfail_reason"] == (" " if case == "xfail-whitespace" else "") + assert reports["call"]["outcome"] == ("passed" if case == "xpass-no-reason" else "skipped") + defects = [ + "absent", + "not-list", + "missing-node", + "duplicate-node", + "wrong-node", + "missing-reports", + "missing-phase", + "duplicate-phase", + "bad-phase", + "bad-outcome", + "missing-reason", + "bad-xfail", + "failed-with-zero-exit", + ] + for defect in defects: + damaged = tmp_path / defect + shutil.copytree(artifacts, damaged) + path = damaged / f"t2-medium-shard-1-{sha}" / "shard-1-execution.json" + payload = json.loads(path.read_text()) + if defect == "absent": + payload.pop("node_outcomes", None) + elif defect == "not-list": + payload["node_outcomes"] = {} + else: + # Keep actual predecessor receipts intact: absence itself is a + # regression failure, never replace it with fabricated success. + outcomes = payload.get("node_outcomes", []) + if outcomes: + reports = outcomes[0]["reports"] + if defect == "missing-node": + outcomes.pop() + elif defect == "duplicate-node": + outcomes.append(outcomes[0]) + elif defect == "wrong-node": + outcomes[0]["node_id"] = ids[0] + elif defect == "missing-reports": + outcomes[0].pop("reports") + elif defect == "missing-phase": + reports.pop() + elif defect == "duplicate-phase": + reports.append(reports[0]) + elif defect == "bad-phase": + reports[0]["phase"] = "collection" + elif defect == "bad-outcome": + reports[0]["outcome"] = "complete" + elif defect == "missing-reason": + skipped = next((report for report in reports if report["outcome"] == "skipped"), reports[0]) + skipped["outcome"] = "skipped" + skipped["skip_reason"] = None + elif defect == "bad-xfail": + reports[0]["xfail_reason"] = [] + else: + reports[0]["outcome"] = "failed" + path.write_text(json.dumps(payload)) + with pytest.raises(ValueError, match="outcome"): + verify_medium_shards(damaged, sha) diff --git a/tests/unit/scripts/test_regenerate_correctness_gate_digests.py b/tests/unit/scripts/test_regenerate_correctness_gate_digests.py new file mode 100644 index 0000000000..24d1bfece5 --- /dev/null +++ b/tests/unit/scripts/test_regenerate_correctness_gate_digests.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +@pytest.fixture +def generator(): + path = Path(__file__).resolve().parents[3] / "_project/scripts/regenerate_correctness_gate_digests.py" + spec = importlib.util.spec_from_file_location("_digest_regenerator", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_regeneration_uses_qgen_defaults_and_records_the_actual_parameter_set(generator, monkeypatch, tmp_path): + output = tmp_path / "reference.json" + calls = [] + + def run_gate(work_dir, query_ids, seed): + calls.append((query_ids, seed)) + return {"queries": [{"id": "18", "stream_id": "0", "status": "SUCCESS", "digest": "digest18"}]} + + monkeypatch.setenv(generator.CORRECTNESS_GATE_QUERY_IDS_ENV, "18") + monkeypatch.setattr(generator, "_run_gate", run_gate) + monkeypatch.setattr(generator, "REFERENCE_PATH", output) + monkeypatch.setattr(generator, "_REPO_ROOT", tmp_path) + assert generator.main() == 0 + assert calls == [(["18"], None)] + reference = json.loads(output.read_text()) + assert reference["reference_seed"] is None + assert reference["digests"] == {"18": "digest18"} + assert "qgen -d" in reference["provenance"]["note"] + + +@pytest.mark.parametrize("seed", [None, 7]) +def test_gate_child_receives_only_a_real_explicit_seed(generator, monkeypatch, tmp_path, seed): + from benchbox.core.results import loader + + result_path = tmp_path / "result.json" + result_path.write_text('{"queries": []}') + calls = [] + monkeypatch.setattr(loader, "find_latest_result", lambda *a, **k: result_path) + monkeypatch.setattr( + generator.subprocess, + "run", + lambda command, **kwargs: calls.append((command, kwargs)) or SimpleNamespace(returncode=0), + ) + assert generator._run_gate(tmp_path, ["18"], seed) == {"queries": []} + command, options = calls[0] + if seed is None: + assert "--seed" not in command + assert "None" not in command + else: + assert command[command.index("--seed") + 1] == "7" + assert options["env"][generator.EMIT_RESULT_DIGEST_ENV] == "1" + assert options["env"]["BENCHBOX_OUTPUT_DIR"] == str(tmp_path / "benchmark_runs") diff --git a/tests/unit/scripts/test_release_admitted_dist.py b/tests/unit/scripts/test_release_admitted_dist.py new file mode 100644 index 0000000000..d69856d87b --- /dev/null +++ b/tests/unit/scripts/test_release_admitted_dist.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import ast +import hashlib +import json +import shutil +import types +from pathlib import Path + +import pytest + +from scripts import release_admitted_dist as gate, release_artifact_consumer as consumer +from tests.unit.scripts.test_release_artifact_consumer import ( + ROOT, + SDIST, + SHA, + WHEEL, + distributions as distributions, + metadata as metadata, +) + +pytestmark = [pytest.mark.unit, pytest.mark.medium] +TAG = "v0.4.2" +SECOND_WHEEL = "benchbox-0.4.2-py3-none-manylinux_2_17_x86_64.whl" + + +@pytest.fixture +def admitted(distributions): + producer = json.loads((distributions / consumer.PRODUCER_RECEIPT).read_text()) + receipt = { + "tag": TAG, + "tag_object": "c" * 40, + "head_sha": SHA, + "version": "0.4.2", + "producer": producer, + "artifact_id": 11, + "artifact_digest": "sha256:" + "b" * 64, + "expires_at": "2099-01-01T00:00:00Z", + } + (distributions / gate.ADMISSION_RECEIPT).write_text(json.dumps(receipt)) + return distributions + + +def rewrite(directory: Path, name: str, change) -> None: + path = directory / name + value = json.loads(path.read_text()) + change(value) + path.write_text(json.dumps(value)) + + +def test_accepts_the_admitted_directory_and_stages_only_distributions(admitted, tmp_path): + receipt, payloads = gate.verify_admitted(admitted, TAG, SHA) + assert receipt["artifact_id"] == 11 + assert set(payloads) == {WHEEL, SDIST} + stage = tmp_path / "publish" + gate.stage_distributions(payloads, stage) + assert {path.name for path in stage.iterdir()} == {WHEEL, SDIST} + assert all((stage / name).read_bytes() == (admitted / name).read_bytes() for name in (WHEEL, SDIST)) + + +def test_refuses_to_stage_into_an_existing_directory(admitted, tmp_path): + _, payloads = gate.verify_admitted(admitted, TAG, SHA) + stage = tmp_path / "publish" + stage.mkdir() + with pytest.raises(FileExistsError): + gate.stage_distributions(payloads, stage) + + +@pytest.mark.parametrize( + "tag,sha,message", + [ + ("v0.4.3", SHA, "another tag"), + (TAG, "d" * 40, "another commit"), + ("0.4.2", SHA, "invalid version tag"), + (TAG, "A" * 40, "invalid head SHA"), + ], +) +def test_refuses_a_release_that_is_not_the_admitted_one(admitted, tag, sha, message): + with pytest.raises(ValueError, match=message): + gate.verify_admitted(admitted, tag, sha) + + +def test_refuses_altered_distribution_bytes(admitted): + (admitted / WHEEL).write_bytes(b"original wheel, edited") + with pytest.raises(ValueError, match="differs from the receipt"): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_altered_same_length_bytes(admitted): + (admitted / SDIST).write_bytes(b"X" + (admitted / SDIST).read_bytes()[1:]) + with pytest.raises(ValueError, match="hash differs"): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_an_edited_checksum_file(admitted): + path = admitted / gate.SHA256SUMS + digest, name = path.read_text().splitlines()[0].split(" ") + path.write_text(path.read_text().replace(digest, "0" * 64, 1)) + with pytest.raises(ValueError, match="SHA256SUMS"): + gate.verify_admitted(admitted, TAG, SHA) + + +@pytest.mark.parametrize("name", [gate.ADMISSION_RECEIPT, consumer.PRODUCER_RECEIPT, gate.SHA256SUMS, WHEEL]) +def test_refuses_a_missing_member(admitted, name): + (admitted / name).unlink() + with pytest.raises((OSError, ValueError)): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_an_extra_member(admitted): + (admitted / "notes.txt").write_text("not a distribution") + with pytest.raises(ValueError, match="unexpected admitted directory contents"): + gate.verify_admitted(admitted, TAG, SHA) + + +def _symlink(link: Path, target: Path, *, directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=directory) + except (OSError, NotImplementedError): + pytest.skip("symlinks are unavailable here") + + +def test_refuses_a_symlinked_distribution(admitted, tmp_path): + outside = tmp_path / "outside.whl" + shutil.move(admitted / WHEEL, outside) + _symlink(admitted / WHEEL, outside) + with pytest.raises(ValueError, match="not a regular file"): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_a_symlinked_admitted_directory(admitted, tmp_path): + link = tmp_path / "link" + _symlink(link, admitted, directory=True) + with pytest.raises(ValueError, match="not a directory"): + gate.verify_admitted(link, TAG, SHA) + + +@pytest.mark.parametrize("name", [consumer.PRODUCER_RECEIPT, gate.SHA256SUMS, WHEEL]) +def test_refuses_a_member_that_is_not_a_regular_file(admitted, name): + (admitted / name).unlink() + (admitted / name).mkdir() + with pytest.raises(ValueError, match="not a regular file"): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_a_member_larger_than_the_size_limit(admitted, monkeypatch): + monkeypatch.setattr(gate._consumer, "MAX_PAYLOAD_BYTES", 8) + with pytest.raises(ValueError, match="exceeds the size limit"): + gate.verify_admitted(admitted, TAG, SHA) + + +@pytest.mark.parametrize("renamed", ["../escape.whl", "sub/dir.whl", "/absolute.whl", "..\\escape.whl"]) +def test_refuses_a_receipt_file_name_that_leaves_the_directory(admitted, renamed): + + def rename(value): + value["files"][renamed] = value["files"].pop(WHEEL) + + rewrite(admitted, consumer.PRODUCER_RECEIPT, rename) + rewrite(admitted, gate.ADMISSION_RECEIPT, lambda value: rename(value["producer"])) + with pytest.raises(ValueError, match="unexpected admitted directory contents"): + gate.verify_admitted(admitted, TAG, SHA) + assert not (admitted.parent / "escape.whl").exists() + + +def test_refuses_more_than_one_wheel_and_one_sdist(admitted): + extra = "benchbox-0.4.2-py3-none-manylinux_2_17_x86_64.whl" + (admitted / extra).write_bytes((admitted / WHEEL).read_bytes()) + + def add(value): + value["files"][extra] = dict(value["files"][WHEEL]) + + rewrite(admitted, consumer.PRODUCER_RECEIPT, add) + rewrite(admitted, gate.ADMISSION_RECEIPT, lambda value: add(value["producer"])) + with pytest.raises(ValueError, match="exactly one wheel and one sdist"): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_a_producer_receipt_that_differs_from_the_admission_receipt(admitted): + rewrite(admitted, consumer.PRODUCER_RECEIPT, lambda value: value.update(run_id=8)) + with pytest.raises(ValueError, match="differs from the admission receipt"): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_duplicate_json_keys(admitted): + path = admitted / gate.ADMISSION_RECEIPT + path.write_text(path.read_text().replace('"tag":', '"tag": "v9.9.9", "tag":', 1)) + with pytest.raises(ValueError, match="duplicate JSON key"): + gate.verify_admitted(admitted, TAG, SHA) + + +@pytest.mark.parametrize( + "change,message", + [ + (lambda value: value.update(extra=1), "unexpected admission receipt fields"), + (lambda value: value.update(tag_object="short"), "invalid tag object"), + (lambda value: value.update(artifact_id=0), "invalid artifact ID"), + (lambda value: value.update(artifact_id=True), "invalid artifact ID"), + (lambda value: value.update(artifact_digest="b" * 64), "invalid artifact digest"), + (lambda value: value.update(version="0.4.3"), "version differs from the tag"), + (lambda value: value["producer"].update(schema=True), "not for this repository"), + (lambda value: value["producer"].update(schema=1.0), "not for this repository"), + (lambda value: value["producer"].update(repository="elsewhere/BenchBox"), "not for this repository"), + ( + lambda value: value["producer"].update(workflow_path=".github/workflows/other.yml"), + "not for this repository", + ), + (lambda value: value["producer"].update(artifact_name="dist-other"), "artifact name"), + (lambda value: value["producer"].update(head_repository_id=2), "invalid producer identity"), + ], +) +def test_refuses_a_receipt_that_does_not_bind_this_release(admitted, change, message): + def both(value): + change(value) + producer_only = value["producer"] + (admitted / consumer.PRODUCER_RECEIPT).write_text(json.dumps(producer_only)) + + rewrite(admitted, gate.ADMISSION_RECEIPT, both) + with pytest.raises(ValueError, match=message): + gate.verify_admitted(admitted, TAG, SHA) + + +def reseal(directory: Path, sums: bytes | None = None) -> None: + producer = json.loads((directory / gate.ADMISSION_RECEIPT).read_text())["producer"] + if sums is None: + sums = "".join(f"{record['sha256']} {name}\n" for name, record in producer["files"].items()).encode("ascii") + (directory / gate.SHA256SUMS).write_bytes(sums) + digest = hashlib.sha256(sums).hexdigest() + rewrite(directory, consumer.PRODUCER_RECEIPT, lambda value: value.update(sha256sums_sha256=digest)) + rewrite(directory, gate.ADMISSION_RECEIPT, lambda value: value["producer"].update(sha256sums_sha256=digest)) + + +def edit_producer(directory: Path, change) -> None: + rewrite(directory, consumer.PRODUCER_RECEIPT, change) + rewrite(directory, gate.ADMISSION_RECEIPT, lambda value: change(value["producer"])) + + +@pytest.mark.parametrize("renamed", ["benchbox-0.4.3-py3-none-any.whl", "other-0.4.2-py3-none-any.whl"]) +def test_refuses_distribution_names_for_another_version_or_package(admitted, renamed): + (admitted / WHEEL).rename(admitted / renamed) + edit_producer(admitted, lambda value: value["files"].update({renamed: value["files"].pop(WHEEL)})) + reseal(admitted) + with pytest.raises(ValueError, match="package or version differs from the tag"): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_a_checksum_file_whose_bytes_differ_from_the_receipt(admitted): + path = admitted / gate.SHA256SUMS + path.write_bytes(path.read_bytes() + b"\n") + with pytest.raises(ValueError, match="SHA256SUMS differs from receipt"): + gate.verify_admitted(admitted, TAG, SHA) + + +@pytest.mark.parametrize( + "content,message", + [ + (b"not a checksum record\n", "malformed SHA256SUMS record"), + (b"\xff\xfe\n", "ascii"), + (None, "duplicate SHA256SUMS record"), + ( + b"0" * 64 + b" " + WHEEL.encode() + b"\n" + b"0" * 64 + b" " + SDIST.encode() + b"\n", + "differs from the producer files", + ), + ], + ids=["malformed", "not-ascii", "duplicate", "wrong-digests"], +) +def test_refuses_an_inconsistent_checksum_file_even_when_the_receipts_match_it(admitted, content, message): + if content is None: + record = (admitted / gate.SHA256SUMS).read_bytes().splitlines()[0] + b"\n" + content = (admitted / gate.SHA256SUMS).read_bytes() + record + reseal(admitted, content) + with pytest.raises(ValueError, match=message): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_refuses_a_producer_receipt_with_an_extra_field(admitted): + edit_producer(admitted, lambda value: value.update(extra="field")) + with pytest.raises(ValueError, match="unexpected producer receipt fields"): + gate.verify_admitted(admitted, TAG, SHA) + + +@pytest.mark.parametrize( + "change,message,extra_member", + [ + (lambda files: files.update({"extra.txt": dict(files[WHEEL])}), "exactly one wheel and one sdist", "extra.txt"), + ( + lambda files: (files.update({SECOND_WHEEL: dict(files[WHEEL])}), files.pop(SDIST)), + "exactly one wheel and one sdist", + SECOND_WHEEL, + ), + (lambda files: files[WHEEL].update(extra=1), "invalid producer file record", None), + (lambda files: files[WHEEL].update(size="11"), "invalid file record", None), + (lambda files: files[WHEEL].update(sha256=files[WHEEL]["sha256"].upper()), "invalid file record", None), + (lambda files: files[WHEEL].update(size=files[WHEEL]["size"] + 1), "size differs from the receipt", None), + ], + ids=["third-file", "two-wheels", "record-extra-key", "size-not-int", "digest-not-lowercase", "size-wrong"], +) +def test_refuses_an_invalid_file_record_in_the_receipt(admitted, change, message, extra_member): + if extra_member is not None: + (admitted / extra_member).write_bytes((admitted / WHEEL).read_bytes()) + if extra_member == SECOND_WHEEL: + (admitted / SDIST).unlink() + edit_producer(admitted, lambda value: change(value["files"])) + with pytest.raises(ValueError, match=message): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_a_file_that_changes_while_it_is_read_is_refused(admitted, monkeypatch): + real_lstat = Path.lstat + + def lstat(self, *args, **kwargs): + status = real_lstat(self, *args, **kwargs) + if self.name == WHEEL: + return types.SimpleNamespace(st_mode=status.st_mode, st_size=status.st_size - 1) + return status + + monkeypatch.setattr(Path, "lstat", lstat) + with pytest.raises(ValueError, match="changed while it was read"): + gate.verify_admitted(admitted, TAG, SHA) + + +def test_a_staged_file_that_reads_back_differently_is_refused(admitted, tmp_path, monkeypatch): + _, payloads = gate.verify_admitted(admitted, TAG, SHA) + stage = tmp_path / "publish" + real_read = Path.read_bytes + monkeypatch.setattr(Path, "read_bytes", lambda self: b"changed" if self.parent == stage else real_read(self)) + with pytest.raises(ValueError, match="changed after it was staged"): + gate.stage_distributions(payloads, stage) + + +def test_command_line_stages_on_success_and_exits_nonzero_on_refusal(admitted, tmp_path, capsys): + stage = tmp_path / "publish" + assert gate.main(["--admitted", str(admitted), "--tag", TAG, "--sha", SHA, "--stage", str(stage)]) == 0 + assert json.loads(capsys.readouterr().out)["files"] == sorted([WHEEL, SDIST]) + assert {path.name for path in stage.iterdir()} == {WHEEL, SDIST} + (admitted / WHEEL).write_bytes(b"tampered") + with pytest.raises(SystemExit) as refusal: + gate.main(["--admitted", str(admitted), "--tag", TAG, "--sha", SHA, "--stage", str(tmp_path / "second")]) + assert refusal.value.code == 1 + assert not (tmp_path / "second").exists() + assert "Admitted distribution verification failed" in capsys.readouterr().err + + +def test_gate_never_builds_downloads_or_publishes(): + tree = ast.parse((ROOT / "scripts/release_admitted_dist.py").read_text()) + imported = { + alias.name.split(".")[0] for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names + } | {node.module.split(".")[0] for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module} + assert not imported & {"subprocess", "socket", "urllib", "http", "requests", "build", "shutil"} diff --git a/tests/unit/scripts/test_release_artifact_consumer.py b/tests/unit/scripts/test_release_artifact_consumer.py new file mode 100644 index 0000000000..a76fb1d322 --- /dev/null +++ b/tests/unit/scripts/test_release_artifact_consumer.py @@ -0,0 +1,523 @@ +"""Reject stale producer attempts and altered release artifact bytes.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import stat +import zipfile +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest + +from scripts import release_artifact_consumer as consumer + +pytestmark = [pytest.mark.unit, pytest.mark.medium] +ROOT = Path(__file__).resolve().parents[3] +SHA = "a" * 40 +WHEEL = "benchbox-0.4.2-py3-none-any.whl" +SDIST = "benchbox-0.4.2.tar.gz" + + +@pytest.fixture +def metadata(): + repository = {"id": 1135770337, "full_name": consumer.REPOSITORY} + run = { + "id": 7, + "run_attempt": 2, + "head_sha": SHA, + "event": "push", + "head_branch": "develop", + "path": consumer.WORKFLOW, + "status": "completed", + "conclusion": "success", + "created_at": "2026-09-30T00:00:00Z", + "repository": repository, + "head_repository": copy.deepcopy(repository), + } + job = { + "id": 9, + "name": "dist-artifact", + "run_id": 7, + "run_attempt": 2, + "head_sha": SHA, + "status": "completed", + "conclusion": "success", + } + artifact = { + "id": 11, + "name": f"dist-{SHA}-attempt-2", + "expired": False, + "expires_at": "2099-01-01T00:00:00Z", + "digest": "sha256:" + "b" * 64, + "size_in_bytes": 1000, + "workflow_run": { + "id": 7, + "head_sha": SHA, + "repository_id": repository["id"], + "head_repository_id": repository["id"], + }, + } + state = { + "repository": repository, + "runs": [run], + "run": run, + "jobs": [job], + "job": job, + "artifacts": [artifact], + "artifact": artifact, + "calls": [], + } + + def api(path): + state["calls"].append(path) + parsed = urlsplit(path) + query = parse_qs(parsed.query) + if not path: + return copy.deepcopy(state["repository"]) + groups = { + "actions/workflows/trunk.yml/runs": ("runs", "workflow_runs"), + "actions/runs/7/attempts/2/jobs": ("jobs", "jobs"), + "actions/runs/7/artifacts": ("artifacts", "artifacts"), + } + if parsed.path in groups: + source, key = groups[parsed.path] + page = int(query["page"][0]) + rows = state[source] + return {"total_count": len(rows), key: copy.deepcopy(rows[(page - 1) * 100 : page * 100])} + if path == "actions/runs/7": + return copy.deepcopy(state["run"]) + if path == "actions/artifacts/11": + return copy.deepcopy(state["artifact"]) + raise AssertionError(f"unexpected API path: {path}") + + state["api"] = api + return state + + +@pytest.fixture +def distributions(tmp_path, metadata): + directory = tmp_path / "dist" + directory.mkdir() + (directory / WHEEL).write_bytes(b"original wheel") + (directory / SDIST).write_bytes(b"original source distribution") + sums = "".join( + f"{hashlib.sha256((directory / name).read_bytes()).hexdigest()} {name}\n" for name in (WHEEL, SDIST) + ) + (directory / "SHA256SUMS").write_text(sums) + receipt = consumer.producer_receipt(directory, metadata["run"], metadata["job"]) + (directory / consumer.PRODUCER_RECEIPT).write_text(json.dumps(receipt)) + return directory + + +def write_zip(path, members): + with zipfile.ZipFile(path, "w") as archive: + for name, payload, mode in members: + entry = zipfile.ZipInfo(name) + entry.external_attr = mode << 16 + archive.writestr(entry, payload) + return {"digest": "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()} + + +def zip_members(directory): + return [(path.name, path.read_bytes(), stat.S_IFREG | 0o644) for path in directory.iterdir()] + + +def test_selects_exact_latest_successful_attempt(metadata): + run, job, artifact = consumer.select_producer(SHA, metadata["api"]) + assert (run["id"], run["run_attempt"], job["id"], artifact["id"]) == (7, 2, 9, 11) + assert "actions/runs/7/attempts/2/jobs?per_page=100&page=1" in metadata["calls"] + + +def test_lists_only_trunk_push_runs_on_develop(metadata): + consumer.select_producer(SHA, metadata["api"]) + listing = [path for path in metadata["calls"] if path.startswith("actions/workflows/")] + assert listing + for path in listing: + parsed = urlsplit(path) + assert parsed.path == "actions/workflows/trunk.yml/runs" + query = parse_qs(parsed.query) + assert query["head_sha"] == [SHA] + assert query["event"] == ["push"] + assert query["branch"] == ["develop"] + + +def test_refuses_commit_without_any_trunk_run(metadata): + metadata["runs"] = [] + with pytest.raises(ValueError, match="no trunk producer"): + consumer.select_producer(SHA, metadata["api"]) + + +def test_refuses_run_without_distribution_artifact(metadata): + metadata["artifacts"] = [] + with pytest.raises(ValueError, match="missing or ambiguous"): + consumer.select_producer(SHA, metadata["api"]) + + +def test_refuses_run_without_distribution_job(metadata): + metadata["jobs"] = [] + with pytest.raises(ValueError, match="distribution producer job"): + consumer.select_producer(SHA, metadata["api"]) + + +@pytest.mark.parametrize( + "field,value", + [ + ("event", "pull_request"), + ("event", "merge_group"), + ("event", "workflow_dispatch"), + ("head_branch", "feature"), + ("head_branch", None), + ("path", ".github/workflows/ci.yml"), + ("path", ".github/workflows/release.yml"), + ("head_sha", "c" * 40), + ("status", "in_progress"), + ("conclusion", "failure"), + ("conclusion", "cancelled"), + ("run_attempt", True), + ], +) +def test_refuses_wrong_or_unsuccessful_run(metadata, field, value): + metadata["run"][field] = value + with pytest.raises(ValueError): + consumer.select_producer(SHA, metadata["api"]) + + +@pytest.mark.parametrize("field", ["repository", "head_repository"]) +def test_refuses_foreign_run_repository(metadata, field): + metadata["run"][field] = {"id": 999, "full_name": consumer.REPOSITORY} + with pytest.raises(ValueError, match="repository"): + consumer.select_producer(SHA, metadata["api"]) + + +def test_never_uses_old_green_run_after_new_failed_run(metadata): + older = copy.deepcopy(metadata["run"]) + older.update(id=6, created_at="2026-09-29T00:00:00Z") + metadata["runs"].insert(0, older) + metadata["run"]["conclusion"] = "failure" + with pytest.raises(ValueError, match="latest producer"): + consumer.select_producer(SHA, metadata["api"]) + assert "actions/runs/6" not in metadata["calls"] + + +def test_later_failed_producer_on_second_page_cannot_be_hidden(metadata): + metadata["runs"] = [ + {**copy.deepcopy(metadata["run"]), "id": index + 100, "created_at": "2026-09-29T00:00:00Z"} + for index in range(100) + ] + [metadata["run"]] + metadata["run"]["conclusion"] = "failure" + with pytest.raises(ValueError, match="latest producer"): + consumer.select_producer(SHA, metadata["api"]) + assert any("page=2" in path for path in metadata["calls"]) + + +def test_run_attempt_change_between_listing_and_read_fails(metadata): + metadata["runs"] = [copy.deepcopy(metadata["run"])] + metadata["run"]["run_attempt"] = 3 + with pytest.raises(ValueError, match="attempt changed"): + consumer.select_producer(SHA, metadata["api"]) + + +@pytest.mark.parametrize( + "field,value", + [("run_id", 8), ("run_attempt", 1), ("head_sha", "d" * 40), ("status", "in_progress"), ("conclusion", "failure")], +) +def test_refuses_wrong_or_unsuccessful_producer_job(metadata, field, value): + metadata["job"][field] = value + with pytest.raises(ValueError, match="job"): + consumer.select_producer(SHA, metadata["api"]) + + +def test_ambiguous_producer_job_fails(metadata): + metadata["jobs"].append({**metadata["job"], "id": 10}) + with pytest.raises(ValueError, match="ambiguous"): + consumer.select_producer(SHA, metadata["api"]) + + +@pytest.mark.parametrize( + "field,value", + [ + ("name", f"dist-{SHA}"), + ("name", f"dist-{SHA}-attempt-1"), + ("expired", True), + ("expires_at", "2020-01-01T00:00:00Z"), + ("digest", None), + ("size_in_bytes", 0), + ("size_in_bytes", consumer.MAX_PAYLOAD_BYTES + 1), + ], +) +def test_refuses_legacy_stale_or_invalid_artifact(metadata, field, value): + metadata["artifact"][field] = value + with pytest.raises(ValueError): + consumer.select_producer(SHA, metadata["api"]) + + +@pytest.mark.parametrize( + "field,value", [("id", 8), ("head_sha", "d" * 40), ("repository_id", 99), ("head_repository_id", 99)] +) +def test_refuses_foreign_artifact_origin(metadata, field, value): + metadata["artifact"]["workflow_run"][field] = value + with pytest.raises(ValueError, match="artifact producer"): + consumer.select_producer(SHA, metadata["api"]) + + +def test_changed_artifact_metadata_fails(metadata): + metadata["artifacts"] = [copy.deepcopy(metadata["artifact"])] + metadata["artifact"]["digest"] = "sha256:" + "c" * 64 + with pytest.raises(ValueError, match="metadata changed"): + consumer.select_producer(SHA, metadata["api"]) + + +def test_duplicate_artifact_identities_fail(metadata): + metadata["artifacts"].append({**metadata["artifact"], "id": 12}) + with pytest.raises(ValueError, match="ambiguous"): + consumer.select_producer(SHA, metadata["api"]) + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"total_count": 2, "jobs": [{"id": 1}]}, + {"total_count": True, "jobs": []}, + {"total_count": 2, "jobs": [{"id": 1}, {"id": 1}]}, + ], +) +def test_incomplete_or_malformed_pages_fail(payload): + with pytest.raises(ValueError): + consumer._pages(lambda _: payload, "jobs", "jobs") + + +def test_population_drift_between_pages_fails(): + pages = iter( + [ + {"total_count": 101, "jobs": [{"id": number} for number in range(1, 101)]}, + {"total_count": 102, "jobs": [{"id": 101}]}, + ] + ) + with pytest.raises(ValueError, match="population changed"): + consumer._pages(lambda _: next(pages), "jobs", "jobs") + + +def test_real_zip_and_producer_receipt_preserve_all_bytes(tmp_path, distributions, metadata): + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, zip_members(distributions)) + payload = consumer.verify_archive(archive, artifact) + assert payload[WHEEL] == b"original wheel" + receipt = consumer.verify_producer_receipt(distributions, metadata["run"], metadata["job"]) + assert receipt["run_attempt"] == 2 and receipt["job_id"] == 9 + assert set(receipt["files"]) == {WHEEL, SDIST} + + +def test_zip_digest_checked_before_archive_parsing(tmp_path): + archive = tmp_path / "not-a-zip" + archive.write_bytes(b"tampered archive") + with pytest.raises(ValueError, match="ZIP digest"): + consumer.verify_archive(archive, {"digest": "sha256:" + "a" * 64}) + + +@pytest.mark.parametrize( + "name", + [ + "../escaped.whl", + "/absolute.whl", + "C:drive.whl", + "folder/nested.whl", + "folder\\nested.whl", + "trailing.whl ", + "trailing.whl.", + ], +) +def test_unsafe_zip_member_refused(tmp_path, distributions, name): + members = zip_members(distributions) + members[0] = (name, members[0][1], members[0][2]) + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, members) + with pytest.raises(ValueError, match="unsafe"): + consumer.verify_archive(archive, artifact) + assert not (tmp_path / "escaped.whl").exists() + + +@pytest.mark.parametrize("mode", [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR, stat.S_IFDIR]) +def test_nonregular_zip_member_refused(tmp_path, distributions, mode): + members = zip_members(distributions) + members[0] = (members[0][0], members[0][1], mode | 0o644) + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, members) + with pytest.raises(ValueError, match="nonregular"): + consumer.verify_archive(archive, artifact) + + +def test_case_alias_duplicate_zip_member_refused(tmp_path, distributions): + members = zip_members(distributions) + members[1] = (members[0][0].upper(), members[1][1], members[1][2]) + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, members) + with pytest.raises(ValueError, match="duplicate"): + consumer.verify_archive(archive, artifact) + + +def test_legacy_three_file_zip_refused(tmp_path, distributions): + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, [row for row in zip_members(distributions) if row[0] != consumer.PRODUCER_RECEIPT]) + with pytest.raises(ValueError, match="legacy"): + consumer.verify_archive(archive, artifact) + + +def test_wheel_tampering_refused_even_with_new_zip_digest(tmp_path, distributions): + (distributions / WHEEL).write_bytes(b"altered wheel") + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, zip_members(distributions)) + with pytest.raises(ValueError, match="distribution hash"): + consumer.verify_archive(archive, artifact) + + +@pytest.mark.parametrize("sums", ["", "invalid\n", "a" * 64 + " ../escaped.whl\n", "a" * 64 + " unexpected.whl\n"]) +def test_missing_or_malformed_distribution_hash_records_refused(tmp_path, distributions, sums): + (distributions / "SHA256SUMS").write_text(sums) + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, zip_members(distributions)) + with pytest.raises(ValueError, match="SHA256SUMS"): + consumer.verify_archive(archive, artifact) + + +def test_duplicate_hash_record_refused(tmp_path, distributions): + sums = distributions / "SHA256SUMS" + sums.write_text(sums.read_text() + sums.read_text().splitlines()[0] + "\n") + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, zip_members(distributions)) + with pytest.raises(ValueError, match="duplicate SHA256SUMS"): + consumer.verify_archive(archive, artifact) + + +@pytest.mark.parametrize( + "field,value", + [ + ("run_attempt", 1), + ("job_id", 8), + ("run_id", 8), + ("schema", True), + ("repository", "attacker/fork"), + ("workflow_path", "other.yml"), + ], +) +def test_producer_receipt_identity_substitution_refused(distributions, metadata, field, value): + path = distributions / consumer.PRODUCER_RECEIPT + receipt = json.loads(path.read_text()) + receipt[field] = value + path.write_text(json.dumps(receipt)) + with pytest.raises(ValueError, match="producer receipt"): + consumer.verify_producer_receipt(distributions, metadata["run"], metadata["job"]) + + +def test_payload_and_hashes_cannot_be_replaced_without_producer_receipt(distributions, metadata): + (distributions / WHEEL).write_bytes(b"replacement wheel") + sums = "".join( + f"{hashlib.sha256((distributions / name).read_bytes()).hexdigest()} {name}\n" for name in (WHEEL, SDIST) + ) + (distributions / "SHA256SUMS").write_text(sums) + with pytest.raises(ValueError, match="producer receipt"): + consumer.verify_producer_receipt(distributions, metadata["run"], metadata["job"]) + + +def test_duplicate_json_key_refused(distributions, metadata): + path = distributions / consumer.PRODUCER_RECEIPT + path.write_text('{"schema":1,"schema":1}') + with pytest.raises(ValueError, match="duplicate JSON key"): + consumer.verify_producer_receipt(distributions, metadata["run"], metadata["job"]) + + +def test_admission_has_no_build_publish_or_attestation_command(): + import ast + + tree = ast.parse((ROOT / "scripts/release_artifact_consumer.py").read_text()) + literals = [node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)] + assert not set(literals) & {"uv build", "build", "publish", "pypi", "test-pypi", "attestation"} + + +def test_encrypted_zip_member_refused(tmp_path, distributions): + archive = tmp_path / "artifact.zip" + write_zip(archive, zip_members(distributions)) + data = bytearray(archive.read_bytes()) + for signature, offset in ((b"PK\x03\x04", 6), (b"PK\x01\x02", 8)): + position = data.index(signature) + data[position + offset] |= 1 + archive.write_bytes(data) + artifact = {"digest": "sha256:" + hashlib.sha256(archive.read_bytes()).hexdigest()} + with pytest.raises(ValueError, match="nonregular"): + consumer.verify_archive(archive, artifact) + + +def test_declared_payload_size_limit_refused(tmp_path, distributions, monkeypatch): + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, zip_members(distributions)) + monkeypatch.setattr(consumer, "MAX_PAYLOAD_BYTES", 10) + with pytest.raises(ValueError, match="size limit"): + consumer.verify_archive(archive, artifact) + + +def test_pending_other_run_for_same_sha_fails(metadata): + older = {**copy.deepcopy(metadata["run"]), "id": 6, "created_at": "2026-09-29T00:00:00Z", "status": "queued"} + metadata["runs"].insert(0, older) + with pytest.raises(ValueError, match="still pending"): + consumer.select_producer(SHA, metadata["api"]) + + +def test_rerun_of_older_run_cannot_be_hidden_behind_newer_creation_time(metadata): + older = { + **copy.deepcopy(metadata["run"]), + "id": 6, + "created_at": "2026-09-29T00:00:00Z", + "run_started_at": "2026-10-01T00:00:00Z", + } + metadata["runs"].insert(0, older) + with pytest.raises(ValueError, match="newer attempt"): + consumer.select_producer(SHA, metadata["api"]) + + +def test_extra_zip_member_refused(tmp_path, distributions): + members = zip_members(distributions) + members[-1] = ("extra.txt", b"extra", stat.S_IFREG | 0o644) + archive = tmp_path / "artifact.zip" + artifact = write_zip(archive, members) + with pytest.raises(ValueError, match="membership"): + consumer.verify_archive(archive, artifact) + + +@pytest.mark.parametrize("value", ["", "not-a-date", "2026-09-30T00:00:00", None]) +def test_malformed_producer_timestamp_refused(metadata, value): + metadata["run"]["created_at"] = value + with pytest.raises(ValueError): + consumer.select_producer(SHA, metadata["api"]) + + +def test_timestamp_offsets_cannot_hide_newer_failed_producer(metadata): + older = {**copy.deepcopy(metadata["run"]), "id": 6, "created_at": "2026-09-30T01:00:00+02:00"} + metadata["runs"].insert(0, older) + metadata["run"]["conclusion"] = "failure" + with pytest.raises(ValueError, match="latest producer"): + consumer.select_producer(SHA, metadata["api"]) + + +@pytest.mark.parametrize("population,key", [("jobs", "jobs"), ("artifacts", "artifacts")]) +def test_producer_and_artifact_on_later_pages_are_not_lost(metadata, population, key): + target = metadata[population][0] + metadata[population] = [ + {**copy.deepcopy(target), "id": index + 100, "name": "irrelevant"} for index in range(100) + ] + [target] + assert consumer.select_producer(SHA, metadata["api"])[2]["id"] == 11 + assert any(key in path and "page=2" in path for path in metadata["calls"]) + + +def test_dos_directory_attribute_refused(tmp_path, distributions): + archive = tmp_path / "artifact.zip" + write_zip(archive, zip_members(distributions)) + data = bytearray(archive.read_bytes()) + position = data.index(b"PK\x01\x02") + data[position + 38] |= 0x10 + archive.write_bytes(data) + artifact = {"digest": "sha256:" + hashlib.sha256(data).hexdigest()} + with pytest.raises(ValueError, match="nonregular"): + consumer.verify_archive(archive, artifact) diff --git a/tests/unit/scripts/test_release_artifact_execution.py b/tests/unit/scripts/test_release_artifact_execution.py new file mode 100644 index 0000000000..6b6727fd4d --- /dev/null +++ b/tests/unit/scripts/test_release_artifact_execution.py @@ -0,0 +1,945 @@ +"""Exercise tag objects, isolated producer execution and fail-closed admission.""" + +from __future__ import annotations + +import hashlib +import json +import os +import signal +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts import release_artifact_consumer as consumer +from tests.unit.scripts.test_release_artifact_consumer import ( + ROOT, + distributions as distributions, + metadata as metadata, + write_zip, + zip_members, +) + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + + +def git(root: Path, *args: str) -> str: + return subprocess.check_output( + [ + "git", + "-c", + "core.hooksPath=/dev/null", + "-c", + "commit.gpgSign=false", + "-c", + "tag.gpgSign=false", + "-C", + str(root), + *args, + ], + text=True, + stderr=subprocess.STDOUT, + ).strip() + + +@pytest.fixture +def tagged_source(tmp_path): + root = tmp_path / "source" + root.mkdir() + git(root, "init", "-b", "develop") + git(root, "config", "user.name", "Test Fixture") + git(root, "config", "user.email", "fixture@example.invalid") + (root / "pyproject.toml").write_text('[project]\nname = "benchbox"\nversion = "0.4.2"\n') + git(root, "add", "pyproject.toml") + git(root, "commit", "-m", "Tagged package") + git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + git(root, "tag", "-a", "v0.4.2", "-m", "Release") + return root + + +def test_real_annotated_tag_on_develop_resolves_exact_commit(tagged_source): + source = consumer.resolve_tag(tagged_source, "v0.4.2") + assert source["head_sha"] == git(tagged_source, "rev-parse", "HEAD") + assert source["tag_object"] != source["head_sha"] + assert source["version"] == "0.4.2" + + +@pytest.mark.parametrize("case", ["tracked", "untracked", "staged"]) +def test_dirty_tagged_source_is_not_trusted(tagged_source, case): + if case == "tracked": + (tagged_source / "pyproject.toml").write_text("changed source") + else: + (tagged_source / "injected.py").write_text("untrusted verifier bytes") + if case == "staged": + git(tagged_source, "add", "injected.py") + with pytest.raises(ValueError, match="checkout is dirty"): + consumer.resolve_tag(tagged_source, "v0.4.2") + + +@pytest.mark.parametrize("case", ["lightweight", "off_develop", "version", "checkout", "branch"]) +def test_real_invalid_tag_or_checkout_refuses_admission(tagged_source, case): + tag = "v0.4.2" + if case == "lightweight": + git(tagged_source, "tag", "-d", tag) + git(tagged_source, "tag", tag) + elif case in {"off_develop", "checkout"}: + (tagged_source / "later.txt").write_text("Later source") + git(tagged_source, "add", "later.txt") + git(tagged_source, "commit", "-m", "Later source") + if case == "off_develop": + git(tagged_source, "tag", "-d", tag) + git(tagged_source, "tag", "-a", tag, "-m", "Off develop") + elif case == "version": + tag = "v0.4.3" + git(tagged_source, "tag", "-a", tag, "-m", "Wrong version") + else: + tag = "develop" + with pytest.raises((ValueError, subprocess.CalledProcessError)): + consumer.resolve_tag(tagged_source, tag) + + +def test_release_candidate_uses_existing_version_normalization(tagged_source): + (tagged_source / "pyproject.toml").write_text('[project]\nname = "benchbox"\nversion = "0.4.2rc1"\n') + git(tagged_source, "add", "pyproject.toml") + git(tagged_source, "commit", "-m", "Candidate version") + git(tagged_source, "update-ref", "refs/remotes/origin/develop", "HEAD") + git(tagged_source, "tag", "-a", "v0.4.2-rc.1", "-m", "Candidate") + assert consumer.resolve_tag(tagged_source, "v0.4.2-rc.1")["version"] == "0.4.2rc1" + + +def install_gh(tmp_path, monkeypatch, responses=None, archive=None): + executable = tmp_path / "gh" + executable.write_text( + f"#!{sys.executable}\nimport json,sys,pathlib\n" + f"responses={responses!r}\narchive={str(archive)!r}\n" + "assert sys.argv[1:4] == ['api', '--hostname', 'github.com'], sys.argv\n" + "assert len(sys.argv) == 5, sys.argv\n" + "endpoint=sys.argv[4]\n" + "if endpoint.endswith('/zip'):\n" + " sys.stdout.buffer.write(pathlib.Path(archive).read_bytes())\n" + "else:\n" + " print(json.dumps(responses[endpoint]))\n" + ) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + + +@pytest.mark.parametrize("attempt", ["2", "3"]) +def test_producer_command_uses_real_isolated_process_and_attempt_api( + tmp_path, monkeypatch, distributions, metadata, attempt +): + receipt_path = distributions / consumer.PRODUCER_RECEIPT + receipt_path.unlink() + metadata["run"].update(status="in_progress", conclusion=None) + metadata["job"].update(status="in_progress", conclusion=None) + prefix = f"repos/{consumer.REPOSITORY}/" + responses = { + prefix + "actions/runs/7": metadata["run"], + prefix + "actions/runs/7/attempts/2/jobs?per_page=100&page=1": {"total_count": 1, "jobs": [metadata["job"]]}, + } + install_gh(tmp_path, monkeypatch, responses=responses) + for key, value in { + "GITHUB_RUN_ID": "7", + "GITHUB_RUN_ATTEMPT": attempt, + "GITHUB_SHA": metadata["run"]["head_sha"], + "GITHUB_REPOSITORY_ID": "1135770337", + }.items(): + monkeypatch.setenv(key, value) + result = subprocess.run( + [ + sys.executable, + "-I", + "-S", + str(ROOT / "scripts/release_artifact_consumer.py"), + "producer", + "--dist", + str(distributions), + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + if attempt == "3": + assert result.returncode != 0 and "workflow context differs" in result.stderr + assert not receipt_path.exists() + else: + assert result.returncode == 0, result.stderr + receipt = json.loads(receipt_path.read_text()) + assert (receipt["run_id"], receipt["run_attempt"], receipt["job_id"]) == (7, 2, 9) + for name, info in receipt["files"].items(): + assert info["sha256"] == hashlib.sha256((distributions / name).read_bytes()).hexdigest() + + +def test_missing_actual_binary_verifier_fails_without_admission_output( + tmp_path, monkeypatch, tagged_source, distributions, metadata +): + sha = git(tagged_source, "rev-parse", "HEAD") + metadata["run"]["head_sha"] = sha + metadata["job"]["head_sha"] = sha + metadata["artifact"]["workflow_run"]["head_sha"] = sha + metadata["artifact"]["name"] = f"dist-{sha}-attempt-2" + receipt = consumer.producer_receipt(distributions, metadata["run"], metadata["job"]) + (distributions / consumer.PRODUCER_RECEIPT).write_text(json.dumps(receipt)) + archive = tmp_path / "download.zip" + digest = write_zip(archive, zip_members(distributions)) + metadata["artifact"].update(digest) + install_gh(tmp_path, monkeypatch, archive=archive) + output = tmp_path / "admitted" + with pytest.raises(ValueError, match="lacks required distribution binary verifier"): + consumer.admit(tagged_source, "v0.4.2", output, metadata["api"]) + assert not output.exists() + assert not list(tmp_path.glob("release-admission-*")) + + +def test_oversized_download_stream_refused(tmp_path, monkeypatch): + archive = tmp_path / "large.zip" + archive.write_bytes(b"x" * 64) + install_gh(tmp_path, monkeypatch, archive=archive) + monkeypatch.setattr(consumer, "MAX_PAYLOAD_BYTES", 16) + target = tmp_path / "download.zip" + with pytest.raises(ValueError, match="size limit"): + consumer._download(11, target) + + +def test_existing_output_is_never_overwritten(tmp_path, tagged_source, metadata): + sha = git(tagged_source, "rev-parse", "HEAD") + metadata["run"]["head_sha"] = sha + metadata["job"]["head_sha"] = sha + metadata["artifact"]["workflow_run"]["head_sha"] = sha + metadata["artifact"]["name"] = f"dist-{sha}-attempt-2" + metadata["artifact"]["digest"] = "sha256:" + "b" * 64 + output = tmp_path / "admitted" + output.mkdir() + (output / "keep").write_text("prior") + with pytest.raises(ValueError, match="output already exists"): + consumer.admit(tagged_source, "v0.4.2", output, metadata["api"]) + assert (output / "keep").read_text() == "prior" + + +@pytest.mark.parametrize("transport", ["metadata", "download"]) +def test_nondefault_gh_host_does_not_change_api_authority(tmp_path, monkeypatch, transport): + monkeypatch.setenv("GH_HOST", "attacker.invalid") + prefix = f"repos/{consumer.REPOSITORY}" + archive = tmp_path / "payload" + archive.write_bytes(b"trusted host bytes") + install_gh(tmp_path, monkeypatch, responses={prefix: {"authority": "github.com"}}, archive=archive) + if transport == "metadata": + assert consumer.github_json("") == {"authority": "github.com"} + else: + target = tmp_path / "download.zip" + consumer._download(11, target) + assert target.read_bytes() == archive.read_bytes() + + +def bind_admission_fixture(tagged_source, distributions, metadata, tmp_path, monkeypatch): + sha = git(tagged_source, "rev-parse", "HEAD") + metadata["run"]["head_sha"] = sha + metadata["job"]["head_sha"] = sha + metadata["artifact"]["workflow_run"]["head_sha"] = sha + metadata["artifact"]["name"] = f"dist-{sha}-attempt-2" + receipt = consumer.producer_receipt(distributions, metadata["run"], metadata["job"]) + (distributions / consumer.PRODUCER_RECEIPT).write_text(json.dumps(receipt)) + archive = tmp_path / "input.zip" + metadata["artifact"].update(write_zip(archive, zip_members(distributions))) + install_gh(tmp_path, monkeypatch, archive=archive) + + +def commit_rejection_probe(root, script): + """Commit only a rejecting sentinel, never a substitute positive binary verifier.""" + verifier = root / "scripts/verify_distribution_binaries.py" + verifier.parent.mkdir() + verifier.write_text(script) + binaries = root / "benchbox/_binaries" + binaries.mkdir(parents=True) + (binaries / "sentinel").write_text("committed rejection data") + git(root, "add", "scripts/verify_distribution_binaries.py", "benchbox/_binaries/sentinel") + git(root, "commit", "-m", "Rejecting execution boundary sentinel") + git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + git(root, "tag", "-d", "v0.4.2") + git(root, "tag", "-a", "v0.4.2", "-m", "Rejecting fixture") + return verifier + + +def test_mutated_then_restored_worktree_cannot_replace_committed_verifier( + tmp_path, monkeypatch, tagged_source, distributions, metadata +): + original = "import sys\nsys.exit(23)\n" + verifier = commit_rejection_probe(tagged_source, original) + bind_admission_fixture(tagged_source, distributions, metadata, tmp_path, monkeypatch) + real_run = subprocess.run + seen = [] + + def interpose(argv, **kwargs): + if any(str(value).endswith("verify_distribution_binaries.py") for value in argv): + seen.append(argv) + verifier.write_text("import sys\nsys.exit(0)\n") + try: + return real_run(argv, **kwargs) + finally: + verifier.write_text(original) + return real_run(argv, **kwargs) + + monkeypatch.setattr(consumer.subprocess, "run", interpose) + output = tmp_path / "admitted" + with pytest.raises(subprocess.CalledProcessError) as error: + consumer.admit(tagged_source, "v0.4.2", output, metadata["api"]) + assert error.value.returncode == 23 + assert seen and "-I" in seen[0] + assert verifier.read_text() == original + assert not output.exists() and not list(tmp_path.glob("release-admission-*")) + + +def test_python_environment_cannot_turn_rejecting_verifier_into_success( + tmp_path, monkeypatch, tagged_source, distributions, metadata +): + commit_rejection_probe( + tagged_source, + "import os,sys\nsys.exit(0 if any(k.startswith('PYTHON') for k in os.environ) else 23)\n", + ) + bind_admission_fixture(tagged_source, distributions, metadata, tmp_path, monkeypatch) + monkeypatch.setenv("PYTHONPATH", str(tmp_path / "poison")) + monkeypatch.setenv("PYTHONSTARTUP", str(tmp_path / "startup.py")) + output = tmp_path / "admitted" + with pytest.raises(subprocess.CalledProcessError) as error: + consumer.admit(tagged_source, "v0.4.2", output, metadata["api"]) + assert error.value.returncode == 23 + assert not output.exists() and not list(tmp_path.glob("release-admission-*")) + + +def test_snapshot_uses_committed_blob_despite_worktree_and_export_attributes(tmp_path, tagged_source): + (tagged_source / ".gitattributes").write_text("pyproject.toml export-ignore\n") + git(tagged_source, "add", ".gitattributes") + git(tagged_source, "commit", "-m", "Archive exclusion fixture") + commit = git(tagged_source, "rev-parse", "HEAD") + original = (tagged_source / "pyproject.toml").read_bytes() + (tagged_source / "pyproject.toml").write_bytes(b"mutable replacement") + target = tmp_path / "snapshot" + target.mkdir() + consumer._committed_snapshot(tagged_source, commit, target) + assert (target / "pyproject.toml").read_bytes() == original + + +def test_snapshot_rejects_committed_verifier_symlink(tmp_path, tagged_source): + scripts = tagged_source / "scripts" + scripts.mkdir() + (scripts / "verify_distribution_binaries.py").symlink_to("../pyproject.toml") + git(tagged_source, "add", "scripts/verify_distribution_binaries.py") + git(tagged_source, "commit", "-m", "Symlink rejection fixture") + with pytest.raises(ValueError, match="unsafe committed snapshot"): + consumer._committed_snapshot(tagged_source, git(tagged_source, "rev-parse", "HEAD"), tmp_path / "snapshot") + + +@pytest.mark.parametrize("kind", ["empty", "nonempty", "file", "symlink"]) +def test_atomic_output_never_replaces_an_existing_destination(tmp_path, kind): + stage = tmp_path / "stage" + stage.mkdir() + (stage / "receipt").write_text("new verified output") + output = tmp_path / "output" + if kind in {"empty", "nonempty"}: + output.mkdir() + if kind == "nonempty": + (output / "owned").write_text("other actor") + elif kind == "file": + output.write_text("other actor") + else: + output.symlink_to(tmp_path / "absent", target_is_directory=True) + with pytest.raises(OSError): + consumer._publish_output(stage, output) + assert (stage / "receipt").read_text() == "new verified output" + assert not (output / "receipt").exists() + + +def test_atomic_output_race_is_refused_at_actual_rename_boundary(tmp_path, monkeypatch): + stage = tmp_path / "stage" + stage.mkdir() + (stage / "receipt").write_text("new output") + output = tmp_path / "output" + assert not output.exists() + if os.name == "nt": + real_rename = os.rename + + def racing_rename(source, destination): + output.mkdir() + return real_rename(source, destination) + + monkeypatch.setattr(consumer.os, "rename", racing_rename) + else: + library = consumer.ctypes.CDLL(None, use_errno=True) + name = "renamex_np" if sys.platform == "darwin" else "renameat2" + original = getattr(library, name) + + class RacingOperation: + def __call__(self, *args): + original.argtypes = self.argtypes + original.restype = self.restype + output.mkdir() + return original(*args) + + class RacingLibrary: + pass + + wrapper = RacingLibrary() + setattr(wrapper, name, RacingOperation()) + monkeypatch.setattr(consumer.ctypes, "CDLL", lambda *args, **kwargs: wrapper) + with pytest.raises(FileExistsError): + consumer._publish_output(stage, output) + assert output.is_dir() and list(output.iterdir()) == [] + assert (stage / "receipt").read_text() == "new output" + + +def test_atomic_output_publishes_whole_directory(tmp_path): + stage = tmp_path / "stage" + stage.mkdir() + (stage / "receipt").write_text("boundary-only fixture, not admission") + output = tmp_path / "output" + consumer._publish_output(stage, output) + assert not stage.exists() + assert (output / "receipt").read_text() == "boundary-only fixture, not admission" + + +@pytest.mark.parametrize("behavior", ["stall", "oversize", "failure"]) +def test_download_failure_reaps_real_child_and_removes_partial(tmp_path, monkeypatch, behavior): + executable = tmp_path / "gh" + # A shell stub, not a Python one: a freshly created Python script occasionally takes about three + # seconds to start on macOS, which these process-lifetime tests must not depend on. + executable.write_text( + "#!/bin/sh\n" + '[ "$1 $2 $3" = "api --hostname github.com" ] || exit 9\n' + + { + "stall": "sleep 30\n", + "oversize": "head -c 128 /dev/zero\nsleep 30\n", + "failure": "printf partial\nexit 4\n", + }[behavior] + ) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + # Only the stall case needs a short deadline. The other two need the child to start and + # write before it, so a short deadline races process startup on a loaded machine. + monkeypatch.setattr(consumer, "DOWNLOAD_TIMEOUT_SECONDS", 0.3 if behavior == "stall" else 30.0) + monkeypatch.setattr(consumer, "MAX_PAYLOAD_BYTES", 16) + real_popen = subprocess.Popen + children = [] + + def launch(*args, **kwargs): + process = real_popen(*args, **kwargs) + children.append(process) + return process + + monkeypatch.setattr(consumer.subprocess, "Popen", launch) + target = tmp_path / "download.zip" + with pytest.raises( + ValueError, match={"stall": "deadline", "oversize": "size limit", "failure": "failed"}[behavior] + ): + consumer._download(11, target) + assert children and children[0].returncode is not None + assert children[0].stdout.closed + assert not target.exists() + + +def test_download_refuses_existing_file_without_removing_it(tmp_path): + archive = tmp_path / "owned.zip" + archive.write_bytes(b"another actor's file") + with pytest.raises(FileExistsError): + consumer._download(11, archive) + assert archive.read_bytes() == b"another actor's file" + + +def test_stalled_admission_leaves_no_output_or_partial_stage( + tmp_path, monkeypatch, tagged_source, distributions, metadata +): + bind_admission_fixture(tagged_source, distributions, metadata, tmp_path, monkeypatch) + executable = tmp_path / "gh" + executable.write_text(f"#!{sys.executable}\nimport time\ntime.sleep(30)\n") + monkeypatch.setattr(consumer, "DOWNLOAD_TIMEOUT_SECONDS", 0.3) + real_popen = subprocess.Popen + children = [] + + def launch(argv, **kwargs): + process = real_popen(argv, **kwargs) + if argv[0] == "gh": + children.append(process) + return process + + monkeypatch.setattr(consumer.subprocess, "Popen", launch) + output = tmp_path / "admitted" + with pytest.raises(ValueError, match="deadline"): + consumer.admit(tagged_source, "v0.4.2", output, metadata["api"]) + assert children and children[0].returncode is not None + assert not output.exists() and not list(tmp_path.glob("release-admission-*")) + + +def test_reader_start_failure_reaps_child_and_removes_partial(tmp_path, monkeypatch): + archive = tmp_path / "input.zip" + archive.write_bytes(b"download fixture") + install_gh(tmp_path, monkeypatch, archive=archive) + real_popen = subprocess.Popen + children = [] + + def launch(*args, **kwargs): + process = real_popen(*args, **kwargs) + children.append(process) + return process + + def fail_start(self): + raise RuntimeError("reader cannot start") + + monkeypatch.setattr(consumer.subprocess, "Popen", launch) + monkeypatch.setattr(consumer.threading.Thread, "start", fail_start) + target = tmp_path / "download.zip" + with pytest.raises(RuntimeError, match="reader cannot start"): + consumer._download(11, target) + assert children and children[0].returncode is not None + assert children[0].stdout.closed + assert not target.exists() + + +def test_transient_git_replacement_cannot_replace_committed_verifier( + tmp_path, monkeypatch, tagged_source, distributions, metadata +): + verifier = commit_rejection_probe(tagged_source, "import sys\nsys.exit(23)\n") + candidate = git(tagged_source, "rev-parse", "HEAD") + verifier.write_text("import sys\nsys.exit(0)\n") + git(tagged_source, "add", "scripts/verify_distribution_binaries.py") + git(tagged_source, "commit", "-m", "Replacement-object attack fixture") + replacement = git(tagged_source, "rev-parse", "HEAD") + git(tagged_source, "checkout", candidate) + bind_admission_fixture(tagged_source, distributions, metadata, tmp_path, monkeypatch) + snapshot = consumer._committed_snapshot + + def replaced_snapshot(root, commit, destination): + git(root, "replace", candidate, replacement) + try: + return snapshot(root, commit, destination) + finally: + git(root, "replace", "-d", candidate) + + monkeypatch.setattr(consumer, "_committed_snapshot", replaced_snapshot) + output = tmp_path / "admitted" + with pytest.raises(subprocess.CalledProcessError) as error: + consumer.admit(tagged_source, "v0.4.2", output, metadata["api"]) + assert error.value.returncode == 23 + assert git(tagged_source, "replace", "-l") == "" + assert not output.exists() and not list(tmp_path.glob("release-admission-*")) + + +@pytest.mark.parametrize("parent_exits", [False, True]) +def test_inherited_pipe_writer_cannot_hold_download_cleanup(tmp_path, monkeypatch, parent_exits): + """Outer owned supervisor bounds a predecessor hang without touching other owners.""" + from benchbox.utils.clock import elapsed_seconds, mono_time + + pidfile = tmp_path / "descendant.pid" + executable = tmp_path / "gh" + # A shell stub, not a Python one, so its startup is deterministic; the background `sleep` + # inherits the stub's stdout pipe, which is exactly the descendant cleanup must not wait on. + executable.write_text( + "#!/bin/sh\n" + '[ "$1 $2 $3" = "api --hostname github.com" ] || exit 9\n' + "sleep 30 &\n" + f"echo $! > {pidfile}\n" + ("exit 0\n" if parent_exits else "sleep 30\n") + ) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + archive = tmp_path / "download.zip" + supervisor = tmp_path / "supervisor.py" + supervisor.write_text( + "import importlib.util,pathlib,sys\n" + f"spec=importlib.util.spec_from_file_location('consumer',{str(ROOT / 'scripts/release_artifact_consumer.py')!r})\n" + "module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)\n" + # Spawning a fresh script child has been measured to stall for about three seconds on a + # loaded macOS machine, before the script runs at all. The deadline needs margin over that + # so the child is up and has written its pid file, and is still far below its 30 second sleeps. + "module.DOWNLOAD_TIMEOUT_SECONDS=8.0\n" + f"archive=pathlib.Path({str(archive)!r})\n" + "try:\n module._download(11,archive)\n" + "except ValueError as exc:\n assert 'deadline' in str(exc)\n" + "else:\n raise AssertionError('deadline did not refuse')\n" + "assert not archive.exists()\n" + ) + process = subprocess.Popen( + [sys.executable, "-I", str(supervisor)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=os.name != "nt", + ) + try: + stdout, stderr = process.communicate(timeout=40) + assert process.returncode == 0, (stdout, stderr) + assert pidfile.exists(), "the stub gh never started within the download deadline" + descendant = int(pidfile.read_text()) + started = mono_time() + while True: + if os.name == "nt": + # Windows descendant termination still needs its native CI leg. + break + state = subprocess.run(["ps", "-p", str(descendant), "-o", "stat="], capture_output=True, text=True) + if not state.stdout.strip() or state.stdout.strip().startswith("Z"): + break + assert elapsed_seconds(started) < 1, "owned pipe writer survived" + import threading + + threading.Event().wait(0.02) + assert not archive.exists() + finally: + if os.name != "nt": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + elif process.poll() is None: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(process.pid)], capture_output=True, check=False) + if process.poll() is None: + process.kill() + process.communicate(timeout=3) + + +def commit_snapshot_files(root: Path, files: dict[str, str]) -> str: + """Commit exactly these files as the tagged source and return the commit.""" + for name, text in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + git(root, "add", name) + git(root, "commit", "-m", "Snapshot boundary fixture") + git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + git(root, "tag", "-d", "v0.4.2") + git(root, "tag", "-a", "v0.4.2", "-m", "Snapshot boundary fixture") + return git(root, "rev-parse", "HEAD") + + +VERIFIER_BOUNDARY_PROBE = """\ +import sys +from benchbox.utils.binary_manifest import SOURCE + +assert sys.flags.isolated and sys.flags.no_site and sys.flags.dont_write_bytecode, sys.flags +assert not any("site-packages" in entry for entry in sys.path), sys.path +sys.exit(0 if SOURCE == "committed snapshot" else 23) +""" + + +def test_verifier_runs_committed_package_without_site_or_package_init(tagged_source): + # The package __init__ files import third-party modules, so they must never run, and `-I` + # removes the script directory from sys.path, so the import must be mapped to the snapshot. + commit = commit_snapshot_files( + tagged_source, + { + "scripts/verify_distribution_binaries.py": VERIFIER_BOUNDARY_PROBE, + "benchbox/__init__.py": "raise RuntimeError('package __init__ must not run')\n", + "benchbox/utils/__init__.py": "raise RuntimeError('package __init__ must not run')\n", + "benchbox/utils/binary_manifest.py": 'SOURCE = "committed snapshot"\n', + }, + ) + consumer._verify_committed_binaries(tagged_source, commit, []) + + +def test_verifier_rejection_in_committed_module_is_not_masked(tagged_source): + commit = commit_snapshot_files( + tagged_source, + { + "scripts/verify_distribution_binaries.py": VERIFIER_BOUNDARY_PROBE, + "benchbox/utils/binary_manifest.py": 'SOURCE = "a different module"\n', + }, + ) + with pytest.raises(subprocess.CalledProcessError) as error: + consumer._verify_committed_binaries(tagged_source, commit, []) + assert error.value.returncode == 23 + + +def test_verifier_and_git_children_inherit_no_credentials(tagged_source, monkeypatch): + secrets = { + "GH_TOKEN": "synthetic-gh-token", + "GITHUB_TOKEN": "synthetic-github-token", + "GH_ENTERPRISE_TOKEN": "synthetic-enterprise-token", + "AWS_SECRET_ACCESS_KEY": "synthetic-aws-secret", + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.fsmonitor", + "GIT_CONFIG_VALUE_0": "/nonexistent/helper", + } + probe = ( + "import os, sys\n" + "leaked = [k for k in os.environ if k.startswith(('GH_', 'GITHUB_', 'GIT_', 'AWS_')) or 'TOKEN' in k]\n" + "sys.exit(23 if leaked else 0)\n" + ) + commit = commit_snapshot_files( + tagged_source, + {"scripts/verify_distribution_binaries.py": probe, "benchbox/utils/binary_manifest.py": ""}, + ) + # Set after the fixture commit so the test's own git calls do not inherit the injected config. + for key, value in secrets.items(): + monkeypatch.setenv(key, value) + consumer._verify_committed_binaries(tagged_source, commit, []) + environment = consumer._child_environment(**consumer._GIT_ENVIRONMENT) + assert not set(secrets) & set(environment) + assert environment["GIT_CONFIG_GLOBAL"] == os.devnull and environment["GIT_CONFIG_NOSYSTEM"] == "1" + + +@pytest.mark.parametrize("route", ["local-config", "environment"]) +def test_git_children_do_not_run_configured_filesystem_monitor(tagged_source, monkeypatch, tmp_path, route): + marker = tmp_path / "helper-ran" + helper = tmp_path / "helper.sh" + helper.write_text(f"#!/bin/sh\necho ran >> {marker}\nexit 1\n") + helper.chmod(0o755) + if route == "local-config": + git(tagged_source, "config", "core.fsmonitor", str(helper)) + else: + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.fsmonitor") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", str(helper)) + # Control: plain Git runs the helper, so the scenario is real in this environment. + subprocess.run(["git", "-C", str(tagged_source), "status", "--porcelain"], capture_output=True, check=False) + assert marker.exists(), "control: configured helper should run under plain git" + marker.unlink() + consumer._run_git(tagged_source, "status", "--porcelain", check=True, stdout=subprocess.PIPE, timeout=60) + assert not marker.exists(), "hardened git must not run the configured filesystem monitor" + + +def test_snapshot_budget_is_enforced_before_any_blob_is_written(tagged_source, monkeypatch, tmp_path): + commit = commit_snapshot_files(tagged_source, {"benchbox/payload.bin": "x" * 5000}) + monkeypatch.setattr(consumer, "MAX_PAYLOAD_BYTES", 1000) + real_run = subprocess.run + commands = [] + + def record(argv, **kwargs): + commands.append(list(argv)) + return real_run(argv, **kwargs) + + monkeypatch.setattr(consumer.subprocess, "run", record) + destination = tmp_path / "snapshot" + destination.mkdir() + with pytest.raises(ValueError, match="exceeds size limit"): + consumer._committed_snapshot(tagged_source, commit, destination) + assert any("ls-tree" in command for command in commands) + assert not any("cat-file" in command for command in commands), "blob contents were read before the budget check" + assert not list(destination.rglob("*")) + + +def test_download_fails_closed_without_posix_process_ownership(monkeypatch, tmp_path): + import types + + monkeypatch.setattr(consumer, "os", types.SimpleNamespace(name="nt")) + archive = tmp_path / "artifact.zip" + with pytest.raises(ValueError, match="Windows is unsupported"): + consumer._download(1, archive) + assert not archive.exists() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX process groups; Windows download is unsupported") +def test_cleanup_tolerates_an_exited_unreaped_group_leader(): + import threading + + process = subprocess.Popen(["/bin/sh", "-c", "exit 0"], stdout=subprocess.PIPE, bufsize=0, start_new_session=True) + # Wait for the exit without reaping, so the group holds only a zombie leader. macOS answers + # killpg on such a group with EPERM instead of ESRCH, which must not escape the cleanup. + # os.waitid is missing from some interpreters, so poll the process state like the other tests. + for _ in range(500): + state = subprocess.run(["ps", "-p", str(process.pid), "-o", "stat="], capture_output=True, text=True) + if state.stdout.strip().startswith("Z"): + break + threading.Event().wait(0.01) + else: + pytest.fail("the child never became an unreaped zombie") + consumer._reap_download(process, None, threading.Event()) + assert process.returncode == 0 + assert process.stdout is not None and process.stdout.closed + + +def test_git_children_do_not_run_repository_local_transport_helpers(tagged_source, tmp_path): + marker = tmp_path / "ssh-helper-ran" + helper = tmp_path / "ssh-helper.sh" + helper.write_text(f"#!/bin/sh\necho ran >> {marker}\nexit 1\n") + helper.chmod(0o755) + git(tagged_source, "config", "core.sshCommand", str(helper)) + # Control: plain Git runs the configured helper for an ssh remote. + subprocess.run( + ["git", "-C", str(tagged_source), "ls-remote", "ssh://invalid.example/repository"], + capture_output=True, + check=False, + timeout=60, + ) + assert marker.exists(), "control: configured ssh helper should run under plain git" + marker.unlink() + result = consumer._run_git( + tagged_source, + "ls-remote", + "ssh://invalid.example/repository", + capture_output=True, + text=True, + check=False, + timeout=60, + ) + assert result.returncode != 0 + assert "not allowed" in result.stderr + assert not marker.exists(), "hardened git must not run a repository-local transport helper" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits") +@pytest.mark.parametrize( + "mode, accepted", + [(0o700, True), (0o755, True), (0o777, False), (0o775, False), (0o1777, True)], +) +def test_output_parent_must_not_let_other_users_swap_entries(tmp_path, mode, accepted): + parent = tmp_path / "parent" + parent.mkdir() + parent.chmod(mode) + try: + if accepted: + consumer._require_private_parent(parent) + else: + with pytest.raises(ValueError, match="writable by other users"): + consumer._require_private_parent(parent) + finally: + parent.chmod(0o700) + + +def test_swapped_staging_directory_is_refused_before_publication( + tmp_path, monkeypatch, tagged_source, distributions, metadata +): + commit_snapshot_files( + tagged_source, + { + "scripts/verify_distribution_binaries.py": "import sys\nsys.exit(0)\n", + "benchbox/utils/binary_manifest.py": "", + }, + ) + bind_admission_fixture(tagged_source, distributions, metadata, tmp_path, monkeypatch) + output = tmp_path / "admitted" + real_resolve_tag = consumer.resolve_tag + calls = [] + + def resolve_then_swap(*args, **kwargs): + result = real_resolve_tag(*args, **kwargs) + calls.append(1) + if len(calls) == 2: + # Verification is complete. Another writer now replaces the staging directory. + stage = next(tmp_path.glob("release-admission-*")) + stage.rename(tmp_path / "moved-away") + stage.mkdir() + return result + + monkeypatch.setattr(consumer, "resolve_tag", resolve_then_swap) + with pytest.raises(ValueError, match="staging directory changed during admission"): + consumer.admit(tagged_source, "v0.4.2", output, metadata["api"]) + assert not output.exists() + + +def test_moved_hosted_tag_changes_the_local_tag_so_the_stale_checkout_is_refused(tagged_source, tmp_path): + first = consumer.resolve_tag(tagged_source, "v0.4.2") + # The hosted repository moves v0.4.2 to a different commit that declares the same version. + hosted = tmp_path / "hosted" + subprocess.run(["git", "clone", "-q", str(tagged_source), str(hosted)], check=True) + git(hosted, "config", "user.name", "Test Fixture") + git(hosted, "config", "user.email", "fixture@example.invalid") + (hosted / "moved.txt").write_text("a different commit\n") + git(hosted, "add", "moved.txt") + git(hosted, "commit", "-m", "Commit the hosted tag now points at") + git(hosted, "tag", "-d", "v0.4.2") + git(hosted, "tag", "-a", "v0.4.2", "-m", "Moved hosted tag") + # Control: a fetch that ignores tags leaves the local tag, and the stale checkout still admits. + subprocess.run(["git", "-C", str(tagged_source), "fetch", "-q", "--no-tags", str(hosted), "develop"], check=True) + assert consumer.resolve_tag(tagged_source, "v0.4.2") == first + # The refspecs the consumer uses force the local tag to the hosted tag object. + subprocess.run( + ["git", "-C", str(tagged_source), "fetch", "-q", "--no-tags", str(hosted), *consumer.hosted_refspecs("v0.4.2")], + check=True, + ) + with pytest.raises(ValueError, match="checkout differs from tagged source"): + consumer.resolve_tag(tagged_source, "v0.4.2") + + +@pytest.mark.parametrize("tag", ["", "0.4.2", "v", "v1;touch x", "../v1", "v1 --upload-pack=x", "v1\n"]) +def test_hosted_fetch_refuses_a_malformed_tag(tag): + with pytest.raises(ValueError, match="invalid version tag"): + consumer.hosted_refspecs(tag) + + +def test_hosted_fetch_uses_the_fixed_url_and_forces_the_tag(monkeypatch, tmp_path): + calls = [] + + def spy(root, *args, **options): + calls.append((root, args, options)) + + monkeypatch.setattr(consumer, "_run_git", spy) + consumer.fetch_hosted_refs(tmp_path, "v0.4.2") + ((root, args, options),) = calls + assert root == tmp_path + assert args == ( + "fetch", + "--no-tags", + "https://github.com/BenchBox-dev/BenchBox.git", + "develop:refs/remotes/origin/develop", + "+refs/tags/v0.4.2:refs/tags/v0.4.2", + ) + assert options == {"check": True, "timeout": 600} + + +def test_local_url_rewrite_cannot_reach_an_ext_transport(tagged_source, tmp_path): + marker = tmp_path / "ext-helper-ran" + helper = tmp_path / "ext-helper.sh" + helper.write_text(f"#!/bin/sh\necho ran >> {marker}\nexit 1\n") + helper.chmod(0o755) + url = "https://github.com/BenchBox-dev/BenchBox.git" + # A specific `protocol.ext.allow` in repository config outranks the general default. + git(tagged_source, "config", f"url.ext::{helper}.insteadOf", url) + git(tagged_source, "config", "protocol.ext.allow", "always") + # Control: plain Git rewrites the URL and runs the helper. + subprocess.run(["git", "-C", str(tagged_source), "ls-remote", url], capture_output=True, check=False, timeout=60) + assert marker.exists(), "control: the rewritten ext transport should run under plain git" + marker.unlink() + result = consumer._run_git(tagged_source, "ls-remote", url, capture_output=True, text=True, check=False, timeout=60) + assert result.returncode != 0 + assert not marker.exists(), "hardened git must not run an ext transport named by repository configuration" + + +def test_gh_children_get_only_authentication_and_network_settings(tmp_path, monkeypatch): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + seen = tmp_path / "gh-environment.json" + stub = bin_dir / "gh" + stub.write_text( + f"#!{sys.executable}\n" + "import json, os\n" + f"open({str(seen)!r}, 'w').write(json.dumps(sorted(os.environ)))\n" + "print('{}')\n" + ) + stub.chmod(0o755) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + monkeypatch.setenv("GH_TOKEN", "synthetic-token") + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.invalid:3128") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "synthetic-aws-secret") + monkeypatch.setenv("GH_HOST", "evil.example") + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + assert consumer.github_json("") == {} + names = set(json.loads(seen.read_text())) + assert {"PATH", "GH_TOKEN", "HTTPS_PROXY"} <= names + assert not {"AWS_SECRET_ACCESS_KEY", "GH_HOST", "GIT_CONFIG_COUNT"} & names + + +def test_cli_fetches_hosted_refs_before_it_admits(monkeypatch, tmp_path, capsys): + order = [] + monkeypatch.setattr(consumer, "fetch_hosted_refs", lambda source, tag: order.append(("fetch", source, tag))) + + def record_admission(root, tag, output, *args, **kwargs): + order.append(("admit", root, tag)) + return {"tag": tag} + + monkeypatch.setattr(consumer, "admit", record_admission) + argv = ["admit", "--source", str(tmp_path), "--tag", "v0.4.2", "--output", str(tmp_path / "admitted")] + assert consumer.main(argv) == 0 + # Without the fetch a stale local tag could admit the previous commit's artifact. + assert order == [("fetch", tmp_path, "v0.4.2"), ("admit", tmp_path.resolve(), "v0.4.2")] + assert '"tag": "v0.4.2"' in capsys.readouterr().out + + +def test_cli_refuses_to_admit_when_the_hosted_fetch_fails(monkeypatch, tmp_path): + admitted = [] + + def failing_fetch(source, tag): + raise subprocess.CalledProcessError(128, ["git", "fetch"]) + + monkeypatch.setattr(consumer, "fetch_hosted_refs", failing_fetch) + monkeypatch.setattr(consumer, "admit", lambda *args, **kwargs: admitted.append(args)) + argv = ["admit", "--source", str(tmp_path), "--tag", "v0.4.2", "--output", str(tmp_path / "admitted")] + with pytest.raises(SystemExit) as error: + consumer.main(argv) + assert error.value.code == 1 + assert not admitted diff --git a/tests/unit/scripts/test_release_flow.py b/tests/unit/scripts/test_release_flow.py new file mode 100644 index 0000000000..f76db118fa --- /dev/null +++ b/tests/unit/scripts/test_release_flow.py @@ -0,0 +1,578 @@ +"""Tests for the tag-on-develop release preparation and pre-tag check.""" + +from __future__ import annotations + +import importlib.util +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPTS = REPO_ROOT / "scripts" + +spec = importlib.util.spec_from_file_location("release_flow", SCRIPTS / "release_flow.py") +release_flow = importlib.util.module_from_spec(spec) +assert spec.loader is not None +sys.modules[spec.name] = release_flow +spec.loader.exec_module(release_flow) + +VERSION = "1.2.3" + +CHANGELOG = """# Changelog + +## [Unreleased] + +## [1.2.3] - 2026-01-01 + +### Added + +- A hand-curated entry that describes a user-visible change. + +## [1.2.2] - 2025-12-01 + +### Added + +- Earlier entry. +""" + +LOCK = """version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "benchbox" +version = "{version}" +source = {{ editable = "." }} +""" + +LANDING = """v{version} +""" + + +def write_tree(root: Path, version: str = VERSION) -> Path: + """Write the minimum tree the version, changelog, and lock checks read.""" + (root / "scripts").mkdir(parents=True, exist_ok=True) + for name in ("update_version.py", "generate_changelog_entry.py", "release_flow.py"): + shutil.copy(SCRIPTS / name, root / "scripts" / name) + (root / "benchbox" / "utils").mkdir(parents=True, exist_ok=True) + (root / "docs").mkdir(exist_ok=True) + (root / "landing").mkdir(exist_ok=True) + (root / "pyproject.toml").write_text( + f'[project]\nname = "benchbox"\nversion = "{version}"\nrequires-python = ">=3.11"\n\n' + '[tool.ruff]\ntarget-version = "py311"\n', + encoding="utf-8", + ) + (root / "benchbox" / "__init__.py").write_text(f'__version__ = "{version}"\n', encoding="utf-8") + (root / "README.md").write_text(f"# BenchBox\n\nCurrent release: v{version}.\n", encoding="utf-8") + (root / "docs" / "README.md").write_text(f"Current release: `v{version}`. More text.\n", encoding="utf-8") + (root / "benchbox" / "utils" / "VERSION_MANAGEMENT.md").write_text( + f"Current release: `v{version}`.\n", encoding="utf-8" + ) + (root / "landing" / "index.html").write_text(LANDING.format(version=version), encoding="utf-8") + (root / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8") + (root / "uv.lock").write_text(LOCK.format(version=version), encoding="utf-8") + return root + + +def ok_runner(argv, cwd): # noqa: ARG001 + return 0, "" + + +def failing_runner(script_name: str): + def runner(argv, cwd): # noqa: ARG001 + if any(Path(part).name == script_name for part in argv): + return 1, f"{script_name} failed" + return 0, "" + + return runner + + +@pytest.fixture +def tree(tmp_path: Path) -> Path: + root = write_tree(tmp_path) + git(root, "init", "-q") + git(root, "add", "--", "uv.lock") + git(root, "commit", "-q", "-m", "Initial lock revision") + git(root, "tag", "v1.2.2") + return root + + +def test_check_passes_when_every_element_is_present(tree: Path) -> None: + assert release_flow.run_checks(tree, VERSION, ok_runner) == [] + + +@pytest.mark.parametrize( + ("relative_path", "needle", "replacement", "expected"), + [ + ("pyproject.toml", f'version = "{VERSION}"', 'version = "1.2.4"', "pyproject.toml"), + ("benchbox/__init__.py", VERSION, "1.2.4", "benchbox/__init__.py"), + ("README.md", f"v{VERSION}", "v1.2.4", "README.md"), + ("docs/README.md", f"v{VERSION}", "v1.2.4", "docs/README.md"), + ("benchbox/utils/VERSION_MANAGEMENT.md", f"v{VERSION}", "v1.2.4", "VERSION_MANAGEMENT.md"), + ("landing/index.html", f"v{VERSION}", "v1.2.4", "landing/index.html"), + ("uv.lock", f'version = "{VERSION}"', 'version = "1.2.4"', "uv.lock"), + ], +) +def test_check_fails_when_a_version_marker_is_stale( + tree: Path, relative_path: str, needle: str, replacement: str, expected: str +) -> None: + path = tree / relative_path + path.write_text(path.read_text(encoding="utf-8").replace(needle, replacement, 1), encoding="utf-8") + + problems = release_flow.run_checks(tree, VERSION, ok_runner) + + assert any(expected in problem for problem in problems), problems + + +@pytest.mark.parametrize("relative_path", ["README.md", "landing/index.html", "uv.lock"]) +def test_check_fails_when_a_version_marker_is_missing(tree: Path, relative_path: str) -> None: + (tree / relative_path).write_text("nothing here\n", encoding="utf-8") + + problems = release_flow.run_checks(tree, VERSION, ok_runner) + + assert any(relative_path in problem for problem in problems), problems + + +def test_check_compares_lock_version_as_pep440(tmp_path: Path) -> None: + root = write_tree(tmp_path, "1.3.0-rc.1") + (root / "uv.lock").write_text(LOCK.format(version="1.3.0rc1"), encoding="utf-8") + (root / "CHANGELOG.md").write_text(CHANGELOG.replace("1.2.3", "1.3.0-rc.1", 1), encoding="utf-8") + + assert release_flow.check_version_markers(root, "1.3.0-rc.1") == [] + + +def test_check_fails_when_the_changelog_entry_is_missing(tree: Path) -> None: + (tree / "CHANGELOG.md").write_text(CHANGELOG.replace("## [1.2.3]", "## [1.2.0]"), encoding="utf-8") + + problems = release_flow.run_checks(tree, VERSION, ok_runner) + + assert any("no '## [1.2.3]" in problem for problem in problems), problems + + +def test_check_fails_when_the_changelog_entry_is_an_uncurated_draft(tree: Path) -> None: + draft = CHANGELOG.replace( + "- A hand-curated entry that describes a user-visible change.", + "- add a thing (#123)", + ) + (tree / "CHANGELOG.md").write_text(draft, encoding="utf-8") + + problems = release_flow.run_checks(tree, VERSION, ok_runner) + + assert any("verbatim commit subjects" in problem for problem in problems), problems + + +@pytest.mark.parametrize("invalid_date", ["TBD", "2026-02-30", "2026-1-1"]) +def test_check_rejects_an_invalid_changelog_date(tree: Path, invalid_date: str) -> None: + (tree / "CHANGELOG.md").write_text(CHANGELOG.replace("2026-01-01", invalid_date), encoding="utf-8") + assert any("valid YYYY-MM-DD" in problem for problem in release_flow.check_changelog(tree, VERSION)) + + +def test_check_rejects_a_committed_lock_revision_downgrade(tree: Path) -> None: + scripts = tree / "_project" / "scripts" + scripts.mkdir(parents=True) + shutil.copy(REPO_ROOT / "_project/scripts/check_uv_lock_revision.py", scripts) + (tree / "uv.lock").write_text(LOCK.format(version=VERSION).replace("revision = 3", "revision = 2")) + git(tree, "add", "--", "uv.lock") + git(tree, "commit", "-q", "-m", "Downgrade lock revision") + git(tree, "tag", "v1.2.3") + problems = release_flow.run_checks(tree, VERSION) + assert any("DOWNGRADE" in problem for problem in problems), problems + + +def test_check_pins_an_explicit_lock_baseline(tree: Path) -> None: + expected = subprocess.check_output(["git", "rev-parse", "refs/tags/v1.2.2"], cwd=tree, text=True).strip() + seen = [] + + def runner(argv, cwd): # noqa: ARG001 + seen.append(list(argv)) + return 0, "" + + assert release_flow.check_uv_lock_revision(tree, runner, "refs/tags/v1.2.2") == [] + assert seen[0][-2:] == ["--baseline-ref", expected] + assert release_flow.check_uv_lock_revision(tree, runner, "refs/tags/missing") + assert len(seen) == 1 + + +def test_lock_baseline_cannot_be_replaced_to_hide_a_committed_downgrade(tree: Path) -> None: + base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=tree, text=True).strip() + scripts = tree / "_project/scripts" + scripts.mkdir(parents=True) + shutil.copy(REPO_ROOT / "_project/scripts/check_uv_lock_revision.py", scripts) + (tree / "uv.lock").write_text(LOCK.format(version=VERSION).replace("revision = 3", "revision = 2")) + git(tree, "add", "--", "uv.lock") + git(tree, "commit", "-q", "-m", "Downgrade lock revision") + git(tree, "replace", base, "HEAD") + problems = release_flow.check_uv_lock_revision(tree, release_flow.run_command, base) + assert any("DOWNGRADE" in problem for problem in problems), problems + + +@pytest.mark.parametrize( + "path", + [ + "scripts/release_flow.py", + "scripts/update_version.py", + "scripts/generate_changelog_entry.py", + "scripts/check_release_curation.py", + "scripts/check_dependency_bounds.py", + "_project/scripts/check_uv_lock_revision.py", + "_project/decisions/single-repo-migration.md", + ], +) +def test_release_enforcement_requires_external_review_for_each_dependency(path: str) -> None: + spec = importlib.util.spec_from_file_location( + "release_soundness_paths", REPO_ROOT / "_project/scripts/soundness_paths.py" + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + assert module.is_soundness_path(path) + + +@pytest.mark.skipif(shutil.which("uv") is None, reason="needs uv") +def test_make_release_check_refuses_stale_lock_without_modifying_it( + tree: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False) + subprocess.run(["uv", "lock", "--offline"], cwd=tree, check=True, capture_output=True) + original = (tree / "uv.lock").read_bytes() + project = tree / "pyproject.toml" + project.write_text(project.read_text().replace('version = "1.2.3"', 'version = "1.2.4"')) + result = subprocess.run( + ["make", "-f", str(REPO_ROOT / "Makefile"), "release-check", "VERSION=1.2.4"], + cwd=tree, + capture_output=True, + text=True, + ) + assert result.returncode != 0, result.stdout + result.stderr + assert "lockfile" in result.stderr and "--locked" in result.stderr, result.stdout + result.stderr + assert (tree / "uv.lock").read_bytes() == original + + +@pytest.mark.parametrize( + "version,ref,release_date", + [ + ("bad-version", "refs/tags/v1.2.2", None), + (VERSION, "refs/tags/missing", None), + (VERSION, "refs/tags/v1.2.2", "2026-02-30"), + ], +) +def test_invalid_preparation_inputs_leave_files_unchanged( + tree: Path, version: str, ref: str, release_date: str | None +) -> None: + before = {path: path.read_bytes() for path in tree.rglob("*") if path.is_file() and ".git" not in path.parts} + assert release_flow.prep(tree, version, ref, release_date, release_flow.run_command) != 0 + assert {path: path.read_bytes() for path in before} == before + + +def test_missing_preparation_marker_fails_before_any_write(tree: Path) -> None: + (tree / "docs/README.md").unlink() + before = {path: path.read_bytes() for path in tree.rglob("*") if path.is_file() and ".git" not in path.parts} + assert release_flow.prep(tree, "1.2.4", "refs/tags/v1.2.2", None, release_flow.run_command) != 0 + assert {path: path.read_bytes() for path in before} == before + + +@pytest.mark.parametrize( + ("script_name", "label"), + [ + ("check_uv_lock_revision.py", "uv.lock revision"), + ("check_release_curation.py", "release curation"), + ("check_dependency_bounds.py", "dependency bounds"), + ], +) +def test_check_fails_when_a_delegated_check_fails(tree: Path, script_name: str, label: str) -> None: + problems = release_flow.run_checks(tree, VERSION, failing_runner(script_name)) + + assert len(problems) == 1 + assert problems[0].startswith(label) + + +def test_dependency_bounds_check_blocks_on_cap_reached(tree: Path) -> None: + seen: list[list[str]] = [] + + def runner(argv, cwd): # noqa: ARG001 + seen.append(list(argv)) + return 0, "" + + release_flow.run_checks(tree, VERSION, runner) + + bounds = next(argv for argv in seen if Path(argv[1]).name == "check_dependency_bounds.py") + assert "--fail-on=cap-reached" in bounds + + +def test_check_rejects_an_invalid_version(tree: Path) -> None: + assert release_flow.run_checks(tree, "not-a-version", ok_runner) + + +def test_cli_check_reports_all_problems_and_exits_nonzero(tree: Path) -> None: + (tree / "README.md").write_text("no marker\n", encoding="utf-8") + (tree / "CHANGELOG.md").write_text("# Changelog\n", encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(SCRIPTS / "release_flow.py"), "check", "--version", VERSION, "--root", str(tree)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 1 + assert "README.md" in result.stderr + assert "CHANGELOG.md" in result.stderr + + +def git(cwd: Path, *args: str) -> None: + subprocess.run( + ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", *args], + cwd=cwd, + check=True, + capture_output=True, + ) + + +@pytest.mark.skipif(shutil.which("uv") is None or shutil.which("git") is None, reason="needs uv and git") +@pytest.mark.parametrize("version", ["1.2.3", "1.2.3-rc.1"]) +def test_prep_updates_every_marker_and_drafts_the_changelog( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, version: str +) -> None: + """A fixture release, never a real one: prep yields exactly the files check reads.""" + monkeypatch.setenv("BENCHBOX_CHANGELOG_SUMMARIZE", "0") + monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False) + root = write_tree(tmp_path, "1.2.2") + (root / "CHANGELOG.md").write_text(CHANGELOG.split("## [1.2.3]")[0] + "## [1.2.2] - 2025-12-01\n", encoding="utf-8") + # Offline-resolvable lock: the fixture project has no dependencies. + subprocess.run(["uv", "lock", "--offline"], cwd=root, check=True, capture_output=True) + git(root, "init", "-q") + git(root, "add", "--all") + git(root, "commit", "-q", "-m", "chore: initial") + git(root, "tag", "v1.2.2") + (root / "feature.txt").write_text("new\n", encoding="utf-8") + git(root, "add", "feature.txt") + git(root, "commit", "-q", "-m", "feat: add a fixture feature") + before = subprocess.run(["git", "status", "--porcelain"], cwd=root, capture_output=True, text=True).stdout + assert before == "" + + code = release_flow.prep(root, version, None, "2026-02-03", release_flow.run_command) + + assert code == 0 + changed = set( + subprocess.run( + ["git", "diff", "--name-only"], cwd=root, capture_output=True, text=True, check=True + ).stdout.split() + ) + assert changed == { + "pyproject.toml", + "uv.lock", + "CHANGELOG.md", + "benchbox/__init__.py", + "README.md", + "docs/README.md", + "benchbox/utils/VERSION_MANAGEMENT.md", + "landing/index.html", + } + assert release_flow.check_version_markers(root, version) == [] + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + assert f"## [{version}] - 2026-02-03" in changelog + assert "add a fixture feature" in changelog + + +def test_prep_stops_at_the_first_failing_step(tree: Path) -> None: + calls: list[list[str]] = [] + + def runner(argv, cwd): # noqa: ARG001 + calls.append(list(argv)) + if argv[0] == "git": + return 0, "a" * 40 + return 3, "boom" + + assert release_flow.prep(tree, VERSION, "refs/tags/v1.2.2", None, runner) == 3 + assert len(calls) == 2 + assert Path(calls[1][1]).name == "update_version.py" + + +def test_prep_passes_the_requested_lower_bound_to_the_changelog_step(tree: Path) -> None: + calls: list[list[str]] = [] + + def runner(argv, cwd): # noqa: ARG001 + calls.append(list(argv)) + return 0, "" + + assert release_flow.prep(tree, VERSION, "origin/develop~5", None, runner) == 0 + + changelog = next(argv for argv in calls if Path(argv[1]).name == "generate_changelog_entry.py") + assert changelog[changelog.index("--since-ref") + 1] == "origin/develop~5" + assert any(argv[:2] == ["uv", "lock"] for argv in calls) + + +@pytest.fixture +def candidate_tree(tmp_path: Path) -> tuple[Path, str]: + root = write_tree(tmp_path) + git(root, "init", "-q") + paths = [str(path.relative_to(root)) for path in root.rglob("*") if path.is_file() and ".git" not in path.parts] + git(root, "add", "--", *paths) + git(root, "commit", "-q", "-m", "Initial release contents") + base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + return root, base + + +@pytest.mark.parametrize( + "path", + [ + "pyproject.toml", + "benchbox/__init__.py", + "uv.lock", + "README.md", + "docs/README.md", + "benchbox/utils/VERSION_MANAGEMENT.md", + "landing/index.html", + ], +) +def test_candidate_selection_catches_each_split_version_bump(candidate_tree: tuple[Path, str], path: str) -> None: + root, base = candidate_tree + file = root / path + file.write_text(file.read_text().replace(VERSION, "1.2.4")) + selected, _version = release_flow.select_candidate(root, base) + assert selected + assert release_flow.run_checks(root, _version, ok_runner, baseline_ref=base) + + +@pytest.mark.parametrize( + ("path", "old", "new"), + [ + ("CHANGELOG.md", "A hand-curated entry", "Updated release note"), + ("uv.lock", "revision = 3", "revision = 2"), + ("uv.lock", "version = 1\n", "version = 2\n"), + ], +) +def test_candidate_selection_catches_release_section_and_lock_schema( + candidate_tree: tuple[Path, str], + path: str, + old: str, + new: str, +) -> None: + root, base = candidate_tree + file = root / path + file.write_text(file.read_text().replace(old, new)) + assert release_flow.select_candidate(root, base)[0] + + +def test_candidate_selection_preserves_ordinary_prose_dependency_and_source_edits( + candidate_tree: tuple[Path, str], +) -> None: + root, base = candidate_tree + readme = root / "README.md" + readme.write_text(readme.read_text() + "\nInstallation instructions improved.\n") + project = root / "pyproject.toml" + project.write_text(project.read_text().replace("[tool.ruff]", 'dependencies = ["packaging>=24"]\n\n[tool.ruff]')) + (root / "benchbox/new_module.py").write_text('"""Ordinary module."""\n') + changelog = root / "CHANGELOG.md" + changelog.write_text(changelog.read_text().replace("Earlier entry.", "Clarified older entry.")) + assert release_flow.select_candidate(root, base) == (False, VERSION) + + +@pytest.mark.parametrize( + "path", + [ + "scripts/release_flow.py", + "scripts/check_dependency_bounds.py", + "_project/decisions/single-repo-migration.md", + ".github/workflows/ci.yml", + ], +) +def test_candidate_selection_includes_committed_enforcement_changes( + candidate_tree: tuple[Path, str], path: str +) -> None: + root, base = candidate_tree + file = root / path + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(file.read_text() + "\n# Updated enforcement.\n" if file.exists() else "# Updated enforcement.\n") + git(root, "add", "--", path) + git(root, "commit", "-q", "-m", "Update release enforcement") + assert release_flow.select_candidate(root, base) == (True, VERSION) + + +@pytest.mark.parametrize("base", ["HEAD", "", "0" * 40]) +def test_candidate_selection_rejects_missing_or_moving_baseline(candidate_tree: tuple[Path, str], base: str) -> None: + with pytest.raises(ValueError, match="event base"): + release_flow.select_candidate(candidate_tree[0], base) + + +def test_candidate_selection_rejects_missing_baseline_contents(candidate_tree: tuple[Path, str]) -> None: + root, _base = candidate_tree + git(root, "rm", "--", "README.md") + git(root, "commit", "-q", "-m", "Remove baseline marker") + base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + (root / "README.md").write_text(f"Current release: v{VERSION}.\n") + with pytest.raises(ValueError, match="baseline README"): + release_flow.select_candidate(root, base) + + +def test_candidate_selection_cli_is_stdlib_only_and_preserves_lock( + candidate_tree: tuple[Path, str], tmp_path: Path +) -> None: + root, base = candidate_tree + original = (root / "uv.lock").read_bytes() + output = tmp_path / "output.txt" + result = subprocess.run( + [ + sys.executable, + "-I", + "-S", + str(root / "scripts/release_flow.py"), + "select", + "--base-sha", + base, + "--github-output", + str(output), + ], + cwd=root, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert output.read_text() == f"release-check-needed=false\nrelease-version={VERSION}\n" + assert (root / "uv.lock").read_bytes() == original + + +def test_candidate_check_is_required_on_pull_requests_without_changing_legacy_publisher() -> None: + import yaml + + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_text()) + classifier = workflow["jobs"]["ci-paths"] + assert "if" not in classifier + steps = classifier["steps"] + selection = next(step for step in steps if step.get("id") == "release") + assert selection["env"]["EVENT_BASE_SHA"] == "${{ github.event.pull_request.base.sha }}" + assert 'git fetch --no-tags origin "$EVENT_BASE_SHA"' in selection["run"] + check = next(step for step in steps if step.get("name") == "Check release content") + assert check["if"] == "steps.release.outputs.release-check-needed == 'true'" + assert check["run"] == 'make release-check VERSION="$RELEASE_VERSION" BASE_REF="$EVENT_BASE_SHA"' + core = workflow["jobs"]["core"] + assert "ci-paths" in core["needs"] + assert "--always ci-paths" in core["steps"][-1]["run"] + assert "origin/release" in (REPO_ROOT / ".github/workflows/release.yml").read_text() + assert "Do not tag the merged develop commit" in (REPO_ROOT / "docs/operations/release-guide.md").read_text() + + +def test_make_targets_exist_and_legacy_release_targets_are_kept() -> None: + def dry_run(target: str, version: str = VERSION) -> str: + return subprocess.run( + ["make", "-n", target, f"VERSION={version}"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + + assert "release_flow.py check --version" in dry_run("release-check") + assert "release_flow.py prep --version" in dry_run("release-prep") + assert "release_finalize.py" in dry_run("release-finalize") + assert "release_cut_start.sh" in dry_run("release-cut") diff --git a/tests/unit/scripts/test_required_case_evidence.py b/tests/unit/scripts/test_required_case_evidence.py new file mode 100644 index 0000000000..5a31ff94fa --- /dev/null +++ b/tests/unit/scripts/test_required_case_evidence.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.release_canary_sharding import verify_required_cases + +pytestmark = [pytest.mark.unit, pytest.mark.medium] +ROOT = Path(__file__).resolve().parents[3] +SHA = "b" * 40 +REQUIRED = ["tests/test_required.py::test_a", "tests/test_required.py::test_b"] + +MODULES = { + "pass": "def test_a():\n assert True\n\ndef test_b():\n assert True\n", + "failure": "def test_a():\n assert True\n\ndef test_b():\n assert False\n", + "mark-skip": "import pytest\n\ndef test_a():\n assert True\n\n@pytest.mark.skip(reason='not available')\ndef test_b():\n pass\n", + "call-skip": "import pytest\n\ndef test_a():\n assert True\n\ndef test_b():\n pytest.skip('optional runtime')\n", + "xfail": "import pytest\n\ndef test_a():\n assert True\n\n@pytest.mark.xfail(reason='known limitation')\ndef test_b():\n assert False\n", + "xpass": "import pytest\n\ndef test_a():\n assert True\n\n@pytest.mark.xfail(reason='known limitation')\ndef test_b():\n assert True\n", +} + + +def _run(tmp_path: Path, body: str, *extra: str, selected: list[str] | None = None) -> tuple[Path, Path, int]: + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_required.py").write_text(body) + (tmp_path / "pytest.ini").write_text("[pytest]\naddopts =\n") + nodeids = tmp_path / "required.txt" + nodeids.write_text("\n".join(REQUIRED) + "\n") + evidence = tmp_path / "evidence.json" + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + "-p", + "scripts.pytest_shard_evidence", + "--assigned-nodeids", + str(nodeids), + "--shard-evidence", + str(evidence), + "--checked-sha", + SHA, + *extra, + *(selected if selected is not None else REQUIRED), + ], + cwd=tmp_path, + env={**os.environ, "PYTHONPATH": str(ROOT), "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"}, + capture_output=True, + text=True, + timeout=60, + ) + return evidence, nodeids, result.returncode + + +def test_a_run_that_passes_every_required_case_is_accepted(tmp_path: Path) -> None: + evidence, nodeids, status = _run(tmp_path, MODULES["pass"]) + assert status == 0 + verify_required_cases(evidence, SHA, nodeids) + + +@pytest.mark.parametrize( + ("case", "message"), + [ + ("failure", "not all collected and executed successfully"), + ("mark-skip", "did not run setup, call and teardown"), + ("call-skip", "did not pass"), + ("xfail", "did not pass"), + ("xpass", "skipped or expected to fail"), + ], +) +def test_a_required_case_that_skips_fails_or_is_expected_to_fail_is_rejected( + tmp_path: Path, case: str, message: str +) -> None: + evidence, nodeids, _ = _run(tmp_path, MODULES[case]) + with pytest.raises(ValueError, match=message): + verify_required_cases(evidence, SHA, nodeids) + + +def test_a_deselected_required_case_is_rejected(tmp_path: Path) -> None: + evidence, nodeids, status = _run(tmp_path, MODULES["pass"], "-k", "not test_b") + assert status != 0 + with pytest.raises(ValueError, match="not all collected and executed successfully"): + verify_required_cases(evidence, SHA, nodeids) + + +def test_a_missing_required_case_is_rejected(tmp_path: Path) -> None: + evidence, nodeids, status = _run(tmp_path, MODULES["pass"], selected=REQUIRED[:1]) + assert status != 0 + with pytest.raises(ValueError, match="not all collected and executed successfully"): + verify_required_cases(evidence, SHA, nodeids) + + +def test_evidence_for_another_commit_is_rejected(tmp_path: Path) -> None: + evidence, nodeids, _ = _run(tmp_path, MODULES["pass"]) + with pytest.raises(ValueError, match="checked SHA"): + verify_required_cases(evidence, "c" * 40, nodeids) + + +def test_missing_evidence_and_an_empty_required_list_are_rejected(tmp_path: Path) -> None: + evidence, nodeids, _ = _run(tmp_path, MODULES["pass"]) + with pytest.raises(OSError): + verify_required_cases(tmp_path / "absent.json", SHA, nodeids) + nodeids.write_text("") + with pytest.raises(ValueError, match="empty"): + verify_required_cases(evidence, SHA, nodeids) + + +def test_the_command_line_exits_non_zero_for_a_skipped_required_case(tmp_path: Path) -> None: + def verify(evidence: Path, nodeids: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "release_canary_sharding.py"), + "verify-required", + "--evidence", + str(evidence), + "--nodeids", + str(nodeids), + "--checked-sha", + SHA, + ], + cwd=ROOT, + env={**os.environ, "PYTHONPATH": str(ROOT)}, + capture_output=True, + text=True, + timeout=60, + ) + + passing = tmp_path / "pass" + passing.mkdir() + ok = verify(*_run(passing, MODULES["pass"])[:2]) + assert ok.returncode == 0, ok.stderr + skipping = tmp_path / "skip" + skipping.mkdir() + bad = verify(*_run(skipping, MODULES["call-skip"])[:2]) + assert bad.returncode == 1 + assert "did not pass" in bad.stderr + + +def test_the_make_target_runs_the_required_cases_under_the_evidence_plugin() -> None: + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + body = makefile.split("\ntest-required-local-cases:", 1)[1].split("\n\n", 1)[0] + assert "REQUIRED_LOCAL_CASES" in body + assert '-m ""' in body + for needle in ("-p scripts.pytest_shard_evidence", "--assigned-nodeids", "--shard-evidence", "--basetemp"): + assert needle in body + assert "release_canary_sharding.py verify-required" in body + assert "exit $$STATUS" in body diff --git a/tests/unit/scripts/test_site_inventory.py b/tests/unit/scripts/test_site_inventory.py new file mode 100644 index 0000000000..0256f71d6c --- /dev/null +++ b/tests/unit/scripts/test_site_inventory.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts import site_inventory +from scripts.assemble_public_site import RESULTS_FALLBACK + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +ATOM = """ + + https://benchbox.dev/blog/b.htmlSecond + https://benchbox.dev/blog/a.htmlFirst + +""" + +INDEX = """ Home Page + + + +

Home

+

Intro

Detail

Outro

+jumpguidepost +extmailspa + +""" + +GUIDE = """Guide

Guide

+

Setup

home +""" + + +def _write(root: Path, name: str, content: str = "x") -> None: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _fixture(root: Path) -> Path: + _write(root, "index.html", INDEX) + _write(root, "docs/guide.html", GUIDE) + _write(root, "blog/a.html", "A

A

") + _write(root, "blog/b.html", "B

B

") + _write(root, "blog/atom.xml", ATOM) + _write(root, "_static/theme.css") + _write(root, "_static/app.js") + _write(root, "_images/logo.svg") + _write(root, "results/index.html", "Results

Results

") + _write(root, "results/assets/index-AbCd1234.js") + _write(root, "404.html", RESULTS_FALLBACK) + return root + + +def _build(root: Path) -> dict: + return site_inventory.build_inventory(root) + + +def test_inventory_extracts_page_fields(tmp_path: Path) -> None: + inventory = _build(_fixture(tmp_path / "site")) + + page = inventory["pages"]["/index.html"] + assert page["title"] == "Home Page" + assert page["description"] == "The home" + assert page["canonical"] == "/" + assert page["h1"] == ["Home"] + assert page["headings"] == [["h2", "Intro"], ["h3", "Detail"], ["h2", "Outro"]] + assert page["links"] == [ + "/blog/a.html", + "/docs/guide.html#setup", + "/index.html#intro", + "/index.html#x", + "/results/benchmarks/", + ] + assert page["images"] == ["/_images/logo.svg"] + assert inventory["assets"] == ["/_static/app.js", "/_static/theme.css"] + assert "intro" in page["ids"] + assert inventory["chrome_links"] == [] + assert inventory["path_count"] == len(inventory["paths"]) == 11 + assert inventory["page_count"] == 6 + + +def test_inventory_records_feed_not_found_and_routes(tmp_path: Path) -> None: + inventory = _build(_fixture(tmp_path / "site")) + + assert [entry["title"] for entry in inventory["feeds"]["/blog/atom.xml"]] == ["First", "Second"] + assert inventory["feeds"]["/blog/atom.xml"][0]["links"] == ["/blog/a.html"] + assert inventory["not_found"]["session_storage_keys"] == ["benchbox.results.redirect"] + assert inventory["not_found"]["redirect_targets"] == ["/results/"] + assert inventory["routes"]["/"] == "/index.html" + assert inventory["routes"]["/results/benchmarks/"] == "spa-fallback" + assert inventory["routes"]["/docs/usage/getting-started.html"] == "" + + +def test_output_is_deterministic_and_sorted(tmp_path: Path) -> None: + site = _fixture(tmp_path / "site") + first, second = tmp_path / "a.json", tmp_path / "b.json" + + assert site_inventory.main(["build", "--site-dir", str(site), "--output", str(first)]) == 0 + assert site_inventory.main(["build", "--site-dir", str(site), "--output", str(second)]) == 0 + + assert first.read_bytes() == second.read_bytes() + text = first.read_text(encoding="utf-8") + assert text.endswith("\n") and not text.endswith("\n\n") + data = json.loads(text) + assert data["paths"] == sorted(data["paths"]) + assert text == json.dumps(data, indent=1, sort_keys=True, ensure_ascii=False) + "\n" + + +def test_unchanged_site_has_empty_diff(tmp_path: Path) -> None: + inventory = _build(_fixture(tmp_path / "site")) + report = site_inventory.diff_inventories(inventory, inventory) + + assert all(not items for items in report.values()) + assert site_inventory.exit_code(report, strict=True) == 0 + + +def test_diff_classifies_missing_path_heading_link_and_image(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + (candidate_site / "blog" / "b.html").unlink() + (candidate_site / "_images" / "logo.svg").unlink() + _write( + candidate_site, "docs/guide.html", GUIDE.replace('id="setup"', 'id="other"').replace("

Guide", "

Manual") + ) + candidate = _build(candidate_site) + + report = site_inventory.diff_inventories(baseline, candidate) + + assert report["missing path"] == ["/_images/logo.svg", "/blog/b.html"] + assert len(report["changed heading"]) == 1 + assert "[h1]" in report["changed heading"][0] and "Manual" in report["changed heading"][0] + assert report["broken internal link"] == [ + "/blog/atom.xml: /blog/b.html (missing path)", + "/index.html: /docs/guide.html#setup (missing fragment)", + ] + assert report["missing fragment"] == ["/docs/guide.html#setup"] + assert report["missing image"] == ["/index.html: /_images/logo.svg (not served)"] + assert site_inventory.exit_code(report, strict=False) == 1 + + +def test_changed_heading_alone_fails_only_when_strict(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + _write(candidate_site, "blog/a.html", "A

Renamed

") + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + + assert [key for key, items in report.items() if items] == ["changed heading"] + assert site_inventory.exit_code(report, strict=False) == 0 + assert site_inventory.exit_code(report, strict=True) == 1 + + +def test_hashed_explorer_assets_are_not_missing_paths(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + (candidate_site / "results" / "assets" / "index-AbCd1234.js").rename( + candidate_site / "results" / "assets" / "index-ZyXw9876.js" + ) + + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + + assert report["missing path"] == [] + + +def test_lost_spa_fallback_reports_capture_route(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + (candidate_site / "404.html").unlink() + + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + + assert "/404.html" in report["missing path"] + assert "/results/benchmarks/ (capture route)" in report["missing path"] + + +def test_cli_diff_exit_codes_and_report(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + base_site = _fixture(tmp_path / "base") + cand_site = _fixture(tmp_path / "cand") + base_json, cand_json = tmp_path / "base.json", tmp_path / "cand.json" + site_inventory.main(["build", "--site-dir", str(base_site), "--output", str(base_json)]) + site_inventory.main(["build", "--site-dir", str(cand_site), "--output", str(cand_json)]) + args = ["diff", "--baseline", str(base_json), "--candidate", str(cand_json)] + assert site_inventory.main(args) == 0 + + (cand_site / "docs" / "guide.html").unlink() + site_inventory.main(["build", "--site-dir", str(cand_site), "--output", str(cand_json)]) + capsys.readouterr() + assert site_inventory.main(args) == 1 + out = capsys.readouterr().out + assert "missing path: /docs/guide.html" in out + assert "broken internal link: /index.html: /docs/guide.html#setup (missing path)" in out + assert out.splitlines()[-1].startswith("summary: 1 missing path, 0 missing fragment, 1 broken internal link") + + +def test_check_reports_broken_links_within_one_inventory(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + site = _fixture(tmp_path / "site") + _write(site, "docs/guide.html", GUIDE.replace('id="setup"', 'id="other"')) + inventory_path = tmp_path / "inv.json" + site_inventory.main(["build", "--site-dir", str(site), "--output", str(inventory_path)]) + capsys.readouterr() + + assert site_inventory.main(["check", "--inventory", str(inventory_path)]) == 1 + assert "/docs/guide.html#setup (missing fragment)" in capsys.readouterr().out + + +def test_missing_site_dir_is_a_usage_error(tmp_path: Path) -> None: + code = site_inventory.main(["build", "--site-dir", str(tmp_path / "none"), "--output", str(tmp_path / "o.json")]) + + assert code == 2 + + +def test_internal_target_normalization() -> None: + assert site_inventory.internal_target("https://benchbox.dev/a/b.html#x", "/") == ("/a/b.html", "x") + assert site_inventory.internal_target("../c.html", "/a/b.html") == ("/c.html", "") + assert site_inventory.internal_target("#frag", "/a/b.html") == ("/a/b.html", "frag") + assert site_inventory.internal_target("https://example.com/", "/") is None + assert site_inventory.internal_target("javascript:void(0)", "/") is None + + +def test_chrome_links_are_hoisted_and_checked_once(tmp_path: Path) -> None: + site = _fixture(tmp_path / "site") + _write( + site, + "docs/guide.html", + '' + '

Guide

intro', + ) + inventory = _build(site) + + assert inventory["pages"]["/docs/guide.html"]["links"] == ["/index.html#intro"] + assert inventory["chrome_links"] == ["/gone.html", "/index.html"] + assert ("(site chrome)", "/gone.html", "missing path") in site_inventory.broken_links(inventory) + + +def test_all_ids_and_names_are_recorded(tmp_path: Path) -> None: + site = _fixture(tmp_path / "site") + _write(site, "docs/guide.html", GUIDE.replace("", '

')) + inventory = _build(site) + + assert inventory["pages"]["/index.html"]["ids"] == ["intro", "top-body"] + assert inventory["pages"]["/docs/guide.html"]["ids"] == ["legacy", "setup", "unlinked"] + + +def test_title_ignores_svg_titles_and_later_titles(tmp_path: Path) -> None: + site = _fixture(tmp_path / "site") + markup = ( + "RealIcon" + "

H

Other" + ) + _write(site, "docs/guide.html", markup) + svg_first = "IconReal" + _write(site, "docs/other.html", svg_first) + inventory = _build(site) + + assert inventory["pages"]["/docs/guide.html"]["title"] == "Real" + assert inventory["pages"]["/docs/other.html"]["title"] == "Real" + + +def _removal(path: str, reason: str = "reviewed") -> dict[str, str]: + return {"path": path, "reason": reason} + + +def test_missing_fragment_fails_unless_expected(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + _write(candidate_site, "docs/guide.html", GUIDE.replace('id="setup"', 'id="other"')) + candidate = _build(candidate_site) + + report = site_inventory.diff_inventories(baseline, candidate) + assert report["missing fragment"] == ["/docs/guide.html#setup"] + assert site_inventory.exit_code(report, strict=False) == 1 + + allowed = site_inventory.diff_inventories(baseline, candidate, [_removal("/docs/guide.html#setup")]) + assert allowed["missing fragment"] == [] + assert allowed["broken internal link"] == [] + assert allowed["stale allowance"] == [] + assert site_inventory.exit_code(allowed, strict=True) == 0 + + +def test_expected_removals_support_prefixes_and_report_stale_entries(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + (candidate_site / "blog" / "b.html").unlink() + (candidate_site / "blog" / "a.html").unlink() + candidate = _build(candidate_site) + + removals = [_removal("/blog/"), _removal("/nothing/*"), _removal("/_images/logo.svg")] + report = site_inventory.diff_inventories(baseline, candidate, removals) + + assert report["missing path"] == [] + assert report["broken internal link"] == [] + assert report["stale allowance"] == ["/_images/logo.svg (matched nothing)", "/nothing/* (matched nothing)"] + assert site_inventory.exit_code(report, strict=False) == 0 + assert site_inventory.exit_code(report, strict=True) == 1 + + +def test_expected_removals_file_requires_reason(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + site = _fixture(tmp_path / "site") + inventory_path, removals = tmp_path / "inv.json", tmp_path / "removals.json" + site_inventory.main(["build", "--site-dir", str(site), "--output", str(inventory_path)]) + args = ["diff", "--baseline", str(inventory_path), "--candidate", str(inventory_path)] + + removals.write_text(json.dumps([{"path": "/blog/", "reason": " "}]), encoding="utf-8") + assert site_inventory.main([*args, "--expected-removals", str(removals)]) == 2 + removals.write_text(json.dumps([_removal("/blog/")]), encoding="utf-8") + capsys.readouterr() + assert site_inventory.main([*args, "--expected-removals", str(removals)]) == 0 + assert "1 stale allowance" in capsys.readouterr().out + + +def test_feed_links_are_link_sources(tmp_path: Path) -> None: + site = _fixture(tmp_path / "site") + _write(site, "blog/atom.xml", ATOM.replace('href="a.html"', 'href="blog/a.html"')) + inventory = _build(site) + + assert ("/blog/atom.xml", "/blog/blog/a.html", "missing path") in site_inventory.broken_links(inventory) + assert not any( + source == "/blog/atom.xml" for source, _, _ in site_inventory.broken_links(_build(_fixture(tmp_path / "ok"))) + ) + + +def test_feed_entry_and_link_loss_fail(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + shortened = ATOM.replace('', "").replace("Second", "Renamed") + _write(candidate_site, "blog/atom.xml", shortened) + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + + assert report["feed regression"] == [ + "/blog/atom.xml: entry https://benchbox.dev/blog/a.html lost link /blog/a.html" + ] + assert len(report["changed metadata"]) == 1 and "Renamed" in report["changed metadata"][0] + assert site_inventory.exit_code(report, strict=False) == 1 + + dropped = ATOM.replace(ATOM.splitlines()[2], "") + _write(candidate_site, "blog/atom.xml", dropped) + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + assert report["feed regression"] == ["/blog/atom.xml: entry https://benchbox.dev/blog/b.html removed"] + + +def test_canonical_loss_fails_but_description_change_is_informational(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + _write(candidate_site, "index.html", INDEX.replace("The home", "Changed")) + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + + assert [key for key, items in report.items() if items] == ["changed metadata"] + assert site_inventory.exit_code(report, strict=False) == 0 + assert site_inventory.exit_code(report, strict=True) == 1 + + _write(candidate_site, "index.html", INDEX.replace('', "")) + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + assert report["canonical loss"] == ["/index.html [canonical]: '/' -> ''"] + assert site_inventory.exit_code(report, strict=False) == 1 + + +def test_not_found_fallback_change_fails(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + _write(candidate_site, "404.html", RESULTS_FALLBACK.replace("benchbox.results.redirect", "benchbox.other.key")) + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + + assert any("session_storage_keys" in item for item in report["404 fallback change"]) + assert site_inventory.exit_code(report, strict=False) == 1 + + +def test_dropped_image_reference_is_informational_but_unserved_image_fails(tmp_path: Path) -> None: + baseline = _build(_fixture(tmp_path / "base")) + candidate_site = _fixture(tmp_path / "cand") + _write(candidate_site, "index.html", INDEX.replace('', "")) + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + + assert report["unreferenced image"] == ["/index.html: /_images/logo.svg (no longer referenced)"] + assert report["missing image"] == [] + assert site_inventory.exit_code(report, strict=False) == 0 + assert site_inventory.exit_code(report, strict=True) == 1 + + _write(candidate_site, "index.html", INDEX.replace("/_images/logo.svg", "/_images/gone.svg")) + report = site_inventory.diff_inventories(baseline, _build(candidate_site)) + assert report["missing image"] == ["/index.html: /_images/gone.svg (not served)"] + assert site_inventory.exit_code(report, strict=False) == 1 + + +def test_capture_routes_are_parsed_from_the_playwright_spec() -> None: + assert site_inventory.capture_routes() == [ + "/", + "/docs/usage/getting-started.html", + "/blog/2026-05-18-v0-3-0-release-overview.html", + "/results/", + "/results/benchmarks/", + "/results/platforms/", + ] + + +def test_capture_routes_reject_a_spec_without_routes(tmp_path: Path) -> None: + spec = tmp_path / "spec.ts" + spec.write_text("const ROUTES = [];\n", encoding="utf-8") + + with pytest.raises(ValueError, match="no capture routes"): + site_inventory.capture_routes(spec) + + +def _big_site(root: Path, pages: int) -> Path: + _fixture(root) + for number in range(pages): + _write(root, f"docs/q/page{number:03d}.html", f"P{number}

{'x' * 3000}

{number}

") + return root + + +def test_sharded_output_matches_single_file_and_respects_size_cap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(site_inventory, "SHARD_BUDGET", 20_000) + monkeypatch.setattr(site_inventory, "SHARD_LIMIT", 24_000) + site = _big_site(tmp_path / "site", 30) + single = tmp_path / "single.json" + sharded = tmp_path / "sharded" + assert site_inventory.main(["build", "--site-dir", str(site), "--output", str(single), "--source-sha", "abc"]) == 0 + assert ( + site_inventory.main(["build", "--site-dir", str(site), "--output-dir", str(sharded), "--source-sha", "abc"]) + == 0 + ) + + files = sorted(sharded.glob("*.json")) + assert len([f for f in files if f.name.startswith("pages-docs-")]) > 1 + assert all(f.stat().st_size < 24_000 for f in files) + assert site_inventory.load_inventory(sharded) == site_inventory.load_inventory(single) + assert json.loads((sharded / "index.json").read_text(encoding="utf-8"))["source_sha"] == "abc" + + +def test_sharded_output_is_deterministic_and_clears_stale_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(site_inventory, "SHARD_BUDGET", 20_000) + monkeypatch.setattr(site_inventory, "SHARD_LIMIT", 24_000) + site = _big_site(tmp_path / "site", 20) + out = tmp_path / "out" + site_inventory.main(["build", "--site-dir", str(site), "--output-dir", str(out)]) + first = {f.name: f.read_bytes() for f in out.glob("*.json")} + _write(out, "pages-stale-001.json", "{}") + site_inventory.main(["build", "--site-dir", str(site), "--output-dir", str(out)]) + + assert {f.name: f.read_bytes() for f in out.glob("*.json")} == first + + +def test_oversized_single_entry_is_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(site_inventory, "SHARD_BUDGET", 500) + site = _big_site(tmp_path / "site", 1) + + assert site_inventory.main(["build", "--site-dir", str(site), "--output-dir", str(tmp_path / "o")]) == 2 + + +def test_diff_and_check_accept_directories(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + base_dir, cand_file = tmp_path / "base", tmp_path / "cand.json" + base_site = _fixture(tmp_path / "bs") + cand_site = _fixture(tmp_path / "cs") + (cand_site / "blog" / "b.html").unlink() + site_inventory.main(["build", "--site-dir", str(base_site), "--output-dir", str(base_dir)]) + site_inventory.main(["build", "--site-dir", str(cand_site), "--output", str(cand_file)]) + capsys.readouterr() + + assert site_inventory.main(["diff", "--baseline", str(base_dir), "--candidate", str(cand_file)]) == 1 + assert "missing path: /blog/b.html" in capsys.readouterr().out + assert site_inventory.main(["check", "--inventory", str(base_dir)]) == 1 + + +def test_known_broken_allows_listed_links_only(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + site = _fixture(tmp_path / "site") + inventory_dir, known = tmp_path / "inv", tmp_path / "known.json" + site_inventory.main(["build", "--site-dir", str(site), "--output-dir", str(inventory_dir)]) + assert site_inventory.main(["check", "--inventory", str(inventory_dir), "--write-known-broken", str(known)]) == 0 + entries = json.loads(known.read_text(encoding="utf-8")) + assert entries == sorted(entries) and entries + check = ["check", "--inventory", str(inventory_dir), "--known-broken", str(known)] + assert site_inventory.main(check) == 0 + + _write(site, "docs/guide.html", GUIDE.replace('id="setup"', 'id="other"')) + site_inventory.main(["build", "--site-dir", str(site), "--output-dir", str(inventory_dir)]) + capsys.readouterr() + assert site_inventory.main(check) == 1 + assert "/docs/guide.html#setup (missing fragment)" in capsys.readouterr().out diff --git a/tests/unit/scripts/test_tpcds_divergence_report.py b/tests/unit/scripts/test_tpcds_divergence_report.py new file mode 100644 index 0000000000..7a6a0331e2 --- /dev/null +++ b/tests/unit/scripts/test_tpcds_divergence_report.py @@ -0,0 +1,367 @@ +"""Tests for the cause labels in scripts/tpcds_divergence_report.py (canned detail text, no data).""" + +from __future__ import annotations + +import pytest +from tpcds_divergence_report import ( + DECIMAL_FLOAT, + ERROR, + FLAKY, + INT_VS_FLOAT, + NULL_ORDER, + NULL_VALUE, + NULL_VS_NAN, + ORDER, + PARAMETER_DRIFT, + ROW_COUNT_LOGIC, + UNBOUND, + UNCLASSIFIED, + Cell, + _RowCapture, + _run_cell, + classify_cell, + label_detail, + render_markdown, +) + +pytestmark = [pytest.mark.unit, pytest.mark.medium, pytest.mark.tpcds] + +ORDER_KEY_NULL = ( + "Q71.0: ORDER BY key mismatch at position 0. Original key: (None, 7008009), " + "Variant key: (3023.76, 7008009) (order-key columns [4, 0]) - the returned order differs" +) +ORDER_KEY_NOISE = ( + "Q36.0: ORDER BY key mismatch at position 4. Original key: (-0.7541713805310839, 0), " + "Variant key: (-0.7541713805310837, 0) (order-key columns [2, 1]) - the returned order differs" +) +ORDER_KEY_INT_FLOAT = ( + "Q30.0: ORDER BY key mismatch at position 5. Original key: ('AAAAAAAAAJLEBAAA', 31, 8, None), " + "Variant key: ('AAAAAAAAAJLEBAAA', 31.0, 8.0, nan) (order-key columns [0, 5, 6, 7]) - the returned order differs" +) +ORDER_KEY_REORDERED = ( + "Q93.0: ORDER BY key mismatch at position 0. Original key: (0.0, 976), " + "Variant key: (0.0, 52.0) (order-key columns [1, 0]) - the returned order differs" +) +ORDER_KEY_OTHER = ( + "Q4.0: ORDER BY key mismatch at position 0. Original key: ('a', 1), " + "Variant key: ('b', 1) (order-key columns [0, 1]) - the returned order differs" +) + + +@pytest.mark.parametrize( + ("detail", "cause"), + [ + ("Q39.0: Column count mismatch at row 0. Original: 10, Variant: 8", ROW_COUNT_LOGIC), + ("Q10.0: Row count mismatch. Original: 2, Variant: 6", ROW_COUNT_LOGIC), + (ORDER_KEY_NULL, NULL_ORDER), + (ORDER_KEY_NOISE, DECIMAL_FLOAT), + (ORDER_KEY_OTHER, UNCLASSIFIED), + ("Q5.0: Value mismatch at row 0, column 3. Original: None, Variant: 0.0, Tolerance: 1e-10", NULL_VALUE), + ("Q66.0: Value mismatch at row 0, column 8. Original: 8288141.53, Variant: 238632.13", ROW_COUNT_LOGIC), + ("Q9.0: Value mismatch at row 0, column 1. Original: 100.0000000001, Variant: 100.0000000002", DECIMAL_FLOAT), + ("Q44.0: Value mismatch at row 0, column 1. Original: n 1, Variant: n 2", UNCLASSIFIED), + ("something else entirely", UNCLASSIFIED), + ], +) +def test_detail_text_gets_the_cause_it_shows(detail, cause): + assert label_detail(detail)[0] == cause + + +def test_a_null_against_nan_is_its_own_label_not_null_order(): + detail = "Q27.0: Value mismatch at row 0, column 3. Original: None, Variant: nan; also columns [4, 6]" + + assert label_detail(detail)[0] == NULL_VS_NAN + + +def test_a_null_against_a_number_is_a_null_value_not_null_order(): + detail = "Q5.0: Value mismatch at row 0, column 3. Original: None, Variant: 0.0" + + assert label_detail(detail)[0] == NULL_VALUE + + +def test_null_order_needs_the_same_rows_when_the_rows_are_known(): + same = ([(None, 1), (5.0, 2)], [(5.0, 2), (None, 1)]) + different = ([(None, 1), (5.0, 2)], [(5.0, 2), (7.0, 1)]) + + assert label_detail(ORDER_KEY_NULL, same)[0] == NULL_ORDER + assert label_detail(ORDER_KEY_NULL, different)[0] == NULL_VALUE + + +def test_an_integer_against_the_same_float_is_a_dtype_difference(): + detail = "Q30.0: Value mismatch at row 4, column 5. Original: 31, Variant: 31.0" + assert label_detail(detail)[0] == INT_VS_FLOAT + + +@pytest.mark.parametrize("prefix", [("31", "31.0"), ("1.12345678499", "1.12345678501")]) +def test_equal_numeric_key_cells_do_not_hide_a_later_null_nan_difference(prefix): + left, right = prefix + detail = ( + f"Q1.0: ORDER BY key mismatch at position 0. Original key: ({left}, None), " + f"Variant key: ({right}, nan) (order-key columns [0, 1]) - the returned order differs" + ) + + assert label_detail(detail)[0] == NULL_VS_NAN + assert label_detail(ORDER_KEY_INT_FLOAT)[0] == NULL_VS_NAN + + +@pytest.mark.parametrize("prefix", [("31", "32"), ("nan", "nan")]) +def test_a_real_earlier_key_difference_is_not_skipped(prefix): + left, right = prefix + detail = ( + f"Q1.0: ORDER BY key mismatch at position 0. Original key: ({left}, None), " + f"Variant key: ({right}, nan) (order-key columns [0, 1]) - the returned order differs" + ) + + assert label_detail(detail)[0] == UNCLASSIFIED + + +@pytest.mark.parametrize( + ("left", "right", "cause"), + [ + (1.123456781, 1.123456784, ROW_COUNT_LOGIC), + (1.12345678499, 1.12345678501, ORDER), + ], +) +def test_captured_rows_use_validator_tolerance_when_classifying_order(left, right, cause): + reference = [(0.0, 976, left), (0.0, 52, 7.0)] + candidate = [(0.0, 52.0, 7.0), (0.0, 976.0, right)] + + assert label_detail(ORDER_KEY_REORDERED, (reference, candidate))[0] == cause + + +@pytest.mark.parametrize("value", [float("nan"), 0.0]) +def test_captured_rows_preserve_strict_null_value_differences(value): + reference = [(0.0, 976, None), (0.0, 52, 7.0)] + candidate = [(0.0, 52.0, 7.0), (0.0, 976.0, value)] + + assert label_detail(ORDER_KEY_REORDERED, (reference, candidate))[0] == ROW_COUNT_LOGIC + + +def test_captured_rows_preserve_row_multiplicity(): + reference = [(0.0, 976), (0.0, 976), (0.0, 52)] + candidate = [(0.0, 52.0), (0.0, 52.0), (0.0, 976.0)] + + assert label_detail(ORDER_KEY_REORDERED, (reference, candidate))[0] == ROW_COUNT_LOGIC + + +def test_float_noise_between_two_floats_is_still_decimal_float(): + detail = "Q9.0: Value mismatch at row 0, column 1. Original: 100.0, Variant: 100.0000000001" + + assert label_detail(detail)[0] == DECIMAL_FLOAT + + +def test_the_same_rows_in_a_different_order_are_labelled_order(): + reference = [(0.0, 976), (0.0, 52), (3.5, 7)] + candidate = [(0.0, 52.0), (3.5, 7.0), (0.0, 976.0)] + + assert label_detail(ORDER_KEY_REORDERED, (reference, candidate))[0] == ORDER + + +def test_different_rows_are_not_labelled_order(): + detail = ( + "Q92.0: ORDER BY key mismatch at position 0. Original key: (27418.73,), " + "Variant key: (21503.71,) (order-key columns [0]) - the returned order differs" + ) + + cause = label_detail(detail, ([(27418.73,)], [(21503.71,)]))[0] + + assert cause == ROW_COUNT_LOGIC + + +def test_an_order_key_mismatch_without_rows_cannot_be_called_order(): + assert label_detail(ORDER_KEY_REORDERED)[0] == UNCLASSIFIED + + +def test_an_ordering_difference_is_evidence_on_its_own_without_an_adapter(): + rows = ([(0.0, 976), (0.0, 52)], [(0.0, 52), (0.0, 976)]) + + assert classify_cell(ORDER_KEY_REORDERED, adapted=False, rows=rows)["cause"] == ORDER + assert classify_cell(ORDER_KEY_INT_FLOAT, adapted=False)["cause"] == NULL_VS_NAN + assert classify_cell("Q5.0: Value mismatch at row 0, column 3. Original: None, Variant: 0.0", adapted=False)[ + "cause" + ] == (NULL_VALUE) + + +def test_a_null_in_the_first_differing_key_cell_is_found_even_after_equal_cells(): + detail = ( + "Q1.0: ORDER BY key mismatch at position 2. Original key: ('x', 5, 'a, b'), " + "Variant key: ('x', 5, None) (order-key columns [0, 1, 2]) - the returned order differs" + ) + + assert label_detail(detail)[0] == NULL_ORDER + + +def test_a_query_without_an_adapter_is_unbound_unless_the_text_shows_null_placement(): + assert classify_cell("Q10.0: Row count mismatch. Original: 2, Variant: 6", adapted=False)["cause"] == UNBOUND + record = classify_cell("Q10.0: Row count mismatch. Original: 2, Variant: 6", adapted=False) + assert record["secondary"] == ROW_COUNT_LOGIC + assert classify_cell(ORDER_KEY_NULL, adapted=False)["cause"] == NULL_ORDER + + +def test_a_query_with_an_adapter_keeps_the_label_the_text_shows(): + assert classify_cell("Q39.0: Column count mismatch at row 0. Original: 10, Variant: 8", adapted=True)["cause"] == ( + ROW_COUNT_LOGIC + ) + + +def test_a_cell_the_adapter_fixed_is_parameter_drift(): + assert classify_cell("", adapted=True, drift_fixed=True)["cause"] == PARAMETER_DRIFT + + +def test_runs_that_disagree_make_a_cell_flaky_whatever_the_text_says(): + record = classify_cell(ORDER_KEY_NOISE, adapted=True, outcomes=["", ORDER_KEY_NOISE, ""]) + + assert record["cause"] == FLAKY + assert classify_cell(ORDER_KEY_NOISE, adapted=True, outcomes=[ORDER_KEY_NOISE] * 3)["cause"] == DECIMAL_FLOAT + + +def test_an_error_is_reported_as_an_error(): + assert classify_cell("", adapted=True, error="ValueError: boom")["cause"] == ERROR + + +def test_the_report_lists_each_cell_with_its_full_evidence(): + cell = Cell( + scale=0.1, + backend="pandas", + query="11", + status="divergent", + cause=ROW_COUNT_LOGIC, + why="88 rows against 0", + evidence="Q11.0: Row count mismatch. Original: 88, Variant: 0 | pipe", + ) + + report = render_markdown([cell]) + + assert "## Scale factor 0.1" in report + assert "| Q11 | pandas | divergent | row count/logic | 88 rows against 0 |" in report + assert "Original: 88, Variant: 0 \\| pipe" in report + + +def test_a_value_mismatch_followed_by_extra_columns_is_still_read(): + detail = "Q66.0: Value mismatch at row 0, column 8. Original: 8288141.53, Variant: 238632.13; also columns [9, 10]" + + assert label_detail(detail)[0] == ROW_COUNT_LOGIC + near = "Q9.0: Value mismatch at row 0, column 1. Original: 5.0000000001, Variant: 5.0000000002, Tolerance: 1e-10; also columns [2]" + assert label_detail(near)[0] == DECIMAL_FLOAT + + +def test_a_scale_with_no_divergences_is_still_in_the_report(): + report = render_markdown([], [0.03]) + + assert "## Scale factor 0.03" in report + assert "no divergent cells" in report + + +class _Divergence: + def __init__(self, detail): + self.detail = detail + + +class _Harness: + def __init__(self, detail): + self._detail = detail + + def find_cross_surface_divergences(self, *_args, **_kwargs): + return [_Divergence(self._detail)] if self._detail else [] + + +class _Data: + connection = None + reference_sql = None + + +class _Gate: + def build_validator(self): + return None + + +@pytest.mark.parametrize("detail", ["error: boom", "reference query failed: no such table"]) +def test_failures_the_harness_captured_are_errors_not_divergences(detail): + status, text = _run_cell(_Harness(detail), _Gate(), _Data(), None, "1", "pandas", None) + + assert (status, text) == ("error", detail) + + +def test_a_real_divergence_is_still_divergent(): + status, _ = _run_cell(_Harness(ORDER_KEY_NULL), _Gate(), _Data(), None, "71", "pandas", None) + + assert status == "divergent" + + +class _PanicException(BaseException): + pass + + +_PanicException.__name__ = "PanicException" + + +class _Raises: + def __init__(self, exc): + self._exc = exc + + def find_cross_surface_divergences(self, *_args, **_kwargs): + raise self._exc + + +def test_a_polars_panic_is_an_error_for_that_cell_not_a_crash(): + panic = _PanicException("called `Result::unwrap()` on an `Err` value: Invalid argument (os error 22)") + + status, text = _run_cell(_Raises(panic), _Gate(), _Data(), None, "1", "pandas", None) + + assert status == "error" + assert "PanicException" in text + assert "os error 22" in text + + +@pytest.mark.parametrize("exc", [KeyboardInterrupt(), SystemExit(1), GeneratorExit()]) +def test_other_base_exceptions_still_stop_the_run(exc): + with pytest.raises(type(exc)): + _run_cell(_Raises(exc), _Gate(), _Data(), None, "1", "pandas", None) + + +class _Fetching: + def __init__(self): + self.fetch_reference_rows = lambda *_a, **_k: [(1,), (2,)] + self.materialize_rows = lambda *_a, **_k: [(2.0,), (1.0,)] + + def find_cross_surface_divergences(self, *_args, **_kwargs): + reference = self.fetch_reference_rows() + self.fetch_reference_rows() + self.materialize_rows() + return [_Divergence(ORDER_KEY_REORDERED)] if reference else [] + + +def test_the_rows_of_a_comparison_are_captured_and_the_harness_is_left_as_it_was(): + xs = _Fetching() + fetch, materialize = xs.fetch_reference_rows, xs.materialize_rows + capture = _RowCapture() + + _run_cell(xs, _Gate(), _Data(), None, "93", "pandas", None, capture) + + assert capture.rows == ([(1,), (2,)], [(2.0,), (1.0,)]) + assert (xs.fetch_reference_rows, xs.materialize_rows) == (fetch, materialize) + assert label_detail(ORDER_KEY_REORDERED, capture.rows)[0] == ORDER + + +@pytest.mark.parametrize( + ("left", "right", "cause"), + [ + (1.123456781, 1.123456784, ROW_COUNT_LOGIC), + (1.12345678499, 1.12345678501, ORDER), + ], +) +def test_captured_row_tolerance_reaches_the_rendered_report(left, right, cause): + xs = _Fetching() + xs.fetch_reference_rows = lambda *_a, **_k: [(0.0, 976, left), (0.0, 52, 7.0)] + xs.materialize_rows = lambda *_a, **_k: [(0.0, 52.0, 7.0), (0.0, 976.0, right)] + capture = _RowCapture() + + status, detail = _run_cell(xs, _Gate(), _Data(), None, "93", "pandas", None, capture) + record = classify_cell(detail, adapted=True, rows=capture.rows) + cell = Cell(scale=0.1, backend="pandas", query="93", status=status, evidence=detail, **record) + + report = render_markdown([cell]) + + assert record["cause"] == cause + assert f"| Q93 | pandas | divergent | {cause} |" in report + assert detail in report diff --git a/tests/unit/scripts/test_tpcds_platform_identity.py b/tests/unit/scripts/test_tpcds_platform_identity.py new file mode 100644 index 0000000000..2a86be0540 --- /dev/null +++ b/tests/unit/scripts/test_tpcds_platform_identity.py @@ -0,0 +1,168 @@ +"""Tests for scripts/tpcds_platform_identity.py.""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import pytest +from tpcds_platform_identity import VOLATILE_TABLES, build_manifest, compare_manifests, main, table_entry + +pytestmark = [pytest.mark.unit, pytest.mark.medium, pytest.mark.tpcds] + + +def _manifest(system: str = "Linux", **overrides) -> dict: + manifest = { + "scale_factor": 0.01, + "seed": 7, + "platform": {"system": system, "machine": "x86_64", "python": "3.12.0"}, + "binaries": {"dsqgen": "a" * 64, "dsdgen": "b" * 64}, + "tables": { + "store_sales": {"rows": 10, "bytes": 100, "sha256": "1" * 64}, + "dbgen_version": {"rows": 1, "bytes": 50, "sha256": "2" * 64}, + }, + "queries": { + "39": {"values": {"YEAR.01": "2002", "MONTH.01": "4"}, "sql_sha256": "3" * 64}, + "8": {"values": {"YEAR.01": "2001"}, "sql_sha256": "4" * 64}, + }, + } + manifest.update(overrides) + return manifest + + +class TestCompareManifests: + def test_identical_manifests_have_no_differences(self): + assert compare_manifests({"linux": _manifest(), "macos": _manifest("Darwin")}) == [] + + def test_a_table_checksum_difference_is_reported(self): + other = _manifest("Darwin") + other["tables"]["store_sales"]["sha256"] = "9" * 64 + + problems = compare_manifests({"linux": _manifest(), "macos": other}) + + assert len(problems) == 1 + assert "table store_sales: sha256" in problems[0] + + def test_a_row_count_difference_is_reported_with_the_checksum(self): + other = _manifest("Darwin") + other["tables"]["store_sales"].update(rows=11, sha256="9" * 64) + + problems = compare_manifests({"linux": _manifest(), "macos": other}) + + assert any("10 rows against 11" in problem for problem in problems) + + def test_a_missing_table_is_reported(self): + other = _manifest("Darwin") + del other["tables"]["store_sales"] + + problems = compare_manifests({"linux": _manifest(), "macos": other}) + + assert problems == ["linux vs macos: table store_sales only in linux"] + + def test_a_volatile_table_is_compared_by_row_count_only(self): + assert "dbgen_version" in VOLATILE_TABLES + other = _manifest("Darwin") + other["tables"]["dbgen_version"]["sha256"] = "9" * 64 + assert compare_manifests({"linux": _manifest(), "macos": other}) == [] + + other["tables"]["dbgen_version"]["rows"] = 2 + assert any( + "dbgen_version: 1 rows against 2" in problem + for problem in compare_manifests({"linux": _manifest(), "macos": other}) + ) + + def test_a_parameter_difference_names_the_values(self): + other = _manifest("Darwin") + other["queries"]["39"]["values"]["MONTH.01"] = "5" + + problems = compare_manifests({"linux": _manifest(), "macos": other}) + + assert len(problems) == 1 + assert "query 39 parameters differ (1): MONTH.01: '4' against '5'" in problems[0] + + def test_different_sql_for_identical_parameters_is_reported(self): + other = _manifest("Darwin") + other["queries"]["8"]["sql_sha256"] = "9" * 64 + + problems = compare_manifests({"linux": _manifest(), "macos": other}) + + assert problems == ["linux vs macos: query 8 renders different SQL for identical parameters"] + + def test_manifests_built_with_different_seeds_are_not_comparable(self): + problems = compare_manifests({"linux": _manifest(), "macos": _manifest("Darwin", seed=8)}) + + assert len(problems) == 1 + assert "seed differs" in problems[0] + + def test_a_single_manifest_cannot_be_compared(self): + assert compare_manifests({"linux": _manifest()}) == ["need at least two manifests to compare"] + + def test_three_manifests_are_each_compared_to_the_first(self): + third = copy.deepcopy(_manifest("Windows")) + third["queries"]["8"]["values"]["YEAR.01"] = "1999" + + problems = compare_manifests({"a-linux": _manifest(), "b-macos": _manifest("Darwin"), "c-windows": third}) + + assert len(problems) == 1 + assert problems[0].startswith("a-linux vs c-windows: query 8") + + +def test_compare_command_exit_code_and_report(tmp_path: Path, capsys): + good, bad = _manifest(), _manifest("Darwin") + bad["tables"]["store_sales"]["sha256"] = "9" * 64 + paths = [] + for name, manifest in (("linux.json", good), ("same.json", copy.deepcopy(good)), ("macos.json", bad)): + path = tmp_path / name + path.write_text(json.dumps(manifest), encoding="utf-8") + paths.append(path) + + assert main(["compare", str(paths[0]), str(paths[1])]) == 0 + assert main(["compare", str(paths[0]), str(paths[2])]) == 1 + assert "table store_sales: sha256" in capsys.readouterr().out + + +def test_manifest_is_deterministic_on_one_platform_and_covers_the_pinned_seed(): + """Two builds with the same seed must compare equal, or a cross-platform difference would be noise.""" + from benchbox.core.tpcds.c_tools import DSQGenBinary, TPCDSError + + try: + DSQGenBinary() + except (TPCDSError, FileNotFoundError, RuntimeError) as exc: + pytest.skip(f"dsqgen binary or templates unavailable: {exc}") + + first = build_manifest(query_ids=(8, 39, 46)) + second = build_manifest(query_ids=(8, 39, 46)) + + assert compare_manifests({"first": first, "second": {**second, "platform": {"system": "x", "machine": "y"}}}) == [] + assert first["seed"] == 7 and first["scale_factor"] == 0.01 + assert len(first["tables"]) == 25 + assert first["tables"]["store_sales"]["rows"] > 0 + assert first["queries"]["39"]["values"]["YEAR.01"] + assert len(first["queries"]["39"]["sql_sha256"]) == 64 + # SF 0.01 draws one city for every Q46 slot; the SF 100 parameter pass draws distinct cities. + small = first["queries"]["46"]["values"] + large = first["parameter_scales"]["100.0"]["46"]["values"] + assert len({value for key, value in small.items() if key.startswith("CITY_")}) == 1 + assert len({value for key, value in large.items() if key.startswith("CITY_")}) > 1 + + +def test_a_parameter_difference_at_a_parameter_only_scale_is_reported(): + queries = {"46": {"values": {"CITY_A.01": "Oak Grove"}, "sql_sha256": "5" * 64}} + reference = _manifest(parameter_scales={"100.0": queries}) + other = _manifest("Darwin", parameter_scales={"100.0": copy.deepcopy(queries)}) + other["parameter_scales"]["100.0"]["46"]["values"]["CITY_A.01"] = "Midway" + problems = compare_manifests({"a": reference, "b": other}) + assert problems == ["a vs b at SF 100.0: query 46 parameters differ (1): CITY_A.01: 'Oak Grove' against 'Midway'"] + + +def test_manifests_with_different_parameter_only_scales_are_not_comparable_there(): + problems = compare_manifests({"a": _manifest(parameter_scales={"100.0": {}}), "b": _manifest("Darwin")}) + assert len(problems) == 1 and "parameter-only scales differ" in problems[0] + + +def test_crlf_table_files_compare_equal_to_lf(tmp_path: Path): + lf, crlf = tmp_path / "lf.dat", tmp_path / "crlf.dat" + lf.write_bytes(b"1|a|\n2|b|\n") + crlf.write_bytes(b"1|a|\r\n2|b|\r\n") + assert table_entry(lf) == table_entry(crlf) diff --git a/tests/unit/scripts/test_trunk_revert.py b/tests/unit/scripts/test_trunk_revert.py new file mode 100644 index 0000000000..3249bcbcde --- /dev/null +++ b/tests/unit/scripts/test_trunk_revert.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import importlib.util +import json +import shlex +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +ROOT = Path(__file__).resolve().parents[3] +OID = "b" * 40 +NOW = datetime(2026, 10, 3, 12, 0, tzinfo=UTC) +RUN_URL = "https://github.com/BenchBox-dev/BenchBox/actions/runs/123" + + +def _load(): + spec = importlib.util.spec_from_file_location("trunk_revert", ROOT / "scripts/trunk_revert.py") + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +trunk_revert = _load() + + +WORKTREE = "/work/BenchBox.wt-revert-12" + + +class FakeRun: + def __init__( + self, + *, + pr_view: dict | None = None, + fail: dict[str, int] | None = None, + origin: str = "git@github.com:BenchBox-dev/BenchBox.git", + runs: list[dict] | None = None, + ): + self.origin = origin + self.pr_view = pr_view + self.fail = fail or {} + self.runs = _runs(("completed", "failure", timedelta(minutes=31))) if runs is None else runs + self.calls: list[list[str]] = [] + + def __call__(self, cmd: list[str]) -> tuple[int, str]: + self.calls.append(cmd) + if cmd[:3] == ["gh", "pr", "view"]: + return 0, json.dumps(self.pr_view) + if cmd[:4] == ["git", "remote", "get-url", "--push"]: + return 0, self.origin + if cmd[:3] == ["git", "rev-parse", "--show-toplevel"]: + return 0, "/work/BenchBox.wt-current\n" + if cmd[:3] == ["gh", "pr", "create"] and "gh pr create" not in self.fail: + return 0, "https://github.com/BenchBox-dev/BenchBox/pull/99\n" + for prefix, code in self.fail.items(): + if " ".join(cmd).startswith(prefix): + return code, "boom" + if cmd[:3] == ["gh", "run", "list"]: + return 0, json.dumps(self.runs) + return 0, "" + + +def _merged(title: str = "fix(x): thing") -> dict: + return {"state": "MERGED", "mergeCommit": {"oid": OID}, "title": title} + + +def test_refuses_a_pr_that_is_not_merged(capsys: pytest.CaptureFixture[str]) -> None: + run = FakeRun(pr_view={"state": "OPEN", "mergeCommit": None, "title": "t"}) + assert trunk_revert.revert(12, run=run) == 1 + assert "not MERGED" in capsys.readouterr().err + assert not any(call[:1] == ["make"] or call[:2] == ["git", "revert"] for call in run.calls) + + +def test_builds_the_expected_worktree_revert_and_pr_commands(capsys: pytest.CaptureFixture[str]) -> None: + run = FakeRun(pr_view=_merged("fix(x): thing")) + assert trunk_revert.revert(12, run=run) == 0 + assert run.calls == [ + ["gh", "pr", "view", "12", "--repo", trunk_revert.REPOSITORY, "--json", "state,mergeCommit,title"], + [ + "gh", + "run", + "list", + "--repo", + trunk_revert.REPOSITORY, + "--workflow", + "trunk.yml", + "--branch", + "develop", + "--limit", + "30", + "--json", + trunk_revert.RUN_FIELDS, + ], + ["git", "remote", "get-url", "--push", "origin"], + ["git", "rev-parse", "--show-toplevel"], + ["git", "fetch", "origin", "develop", "--quiet"], + ["git", "merge-base", "--is-ancestor", OID, "origin/develop"], + ["make", "worktree-create", "BRANCH=fix/revert-12", f"WORKTREE_PATH={WORKTREE}"], + ["git", "-C", WORKTREE, "revert", "--no-edit", OID], + [ + "git", + "-C", + WORKTREE, + "commit", + "--amend", + "-m", + 'Revert "fix(x): thing" (#12)', + "-m", + f"This reverts commit {OID}.", + ], + ["git", "-C", WORKTREE, "push", "-u", "origin", "fix/revert-12"], + [ + "gh", + "pr", + "create", + "--repo", + trunk_revert.REPOSITORY, + "--base", + "develop", + "--head", + "fix/revert-12", + "--title", + 'Revert "fix(x): thing" (#12)', + "--body", + f"Reverts #12 to address the failed trunk.yml run: {RUN_URL}", + ], + ] + out = capsys.readouterr().out + assert "pull/99" in out + assert f"Revert worktree: {WORKTREE}" in out + + +def test_leaves_the_current_worktree_untouched() -> None: + run = FakeRun(pr_view=_merged()) + assert trunk_revert.revert(12, run=run) == 0 + assert any(call[:2] == ["make", "worktree-create"] for call in run.calls) + for call in run.calls: + assert call[:2] != ["git", "switch"] and call[:2] != ["git", "checkout"] + if call[:2] in (["git", "revert"], ["git", "commit"], ["git", "push"]): + raise AssertionError(f"ran in the current worktree: {call}") + + +def test_qualifies_the_head_when_origin_is_a_fork() -> None: + run = FakeRun(pr_view=_merged(), origin="https://github.com/someone/BenchBox.git") + assert trunk_revert.revert(12, run=run) == 0 + create = next(call for call in run.calls if call[:3] == ["gh", "pr", "create"]) + assert create[create.index("--head") + 1] == "someone:fix/revert-12" + + +def test_title_with_quotes_and_command_substitution_stays_one_argument() -> None: + title = 'fix: "quoted" $(touch /tmp/pwned) `id` ; rm -rf /' + run = FakeRun(pr_view=_merged(title)) + assert trunk_revert.revert(5, run=run) == 0 + amend = next(call for call in run.calls if "--amend" in call) + assert amend[amend.index("-m") + 1] == f'Revert "{title}" (#5)' + create = next(call for call in run.calls if call[:3] == ["gh", "pr", "create"]) + assert create[create.index("--title") + 1] == f'Revert "{title}" (#5)' + assert create[create.index("--body") + 1].endswith(RUN_URL) + assert all(isinstance(part, str) for call in run.calls for part in call) + assert not any(call[0] == "sh" or "-c" in call[:2] for call in run.calls) + + +def test_removes_the_new_worktree_when_the_revert_conflicts(capsys: pytest.CaptureFixture[str]) -> None: + run = FakeRun(pr_view=_merged(), fail={"git -C": 1}) + assert trunk_revert.revert(12, run=run) == 1 + assert ["git", "-C", WORKTREE, "revert", "--abort"] in run.calls + assert ["git", "worktree", "remove", "--force", WORKTREE] in run.calls + assert ["git", "branch", "-D", "fix/revert-12"] in run.calls + assert not any(call[3:4] == ["push"] for call in run.calls) + assert "git revert failed" in capsys.readouterr().err + + +def test_removes_the_new_worktree_when_the_push_fails() -> None: + run = FakeRun(pr_view=_merged(), fail={f"git -C {WORKTREE} push": 1}) + assert trunk_revert.revert(12, run=run) == 1 + assert ["git", "worktree", "remove", "--force", WORKTREE] in run.calls + assert ["git", "branch", "-D", "fix/revert-12"] in run.calls + + +def test_keeps_the_pushed_branch_and_says_how_to_finish_when_pr_creation_fails( + capsys: pytest.CaptureFixture[str], +) -> None: + runs = _runs(("completed", "failure", timedelta(minutes=31))) + runs[0]["url"] = RUN_URL + "?label='quoted'&value=$(touch /tmp/pwned)" + run = FakeRun(pr_view=_merged('fix: "quoted" $(touch /tmp/pwned)'), fail={"gh pr create": 1}, runs=runs) + assert trunk_revert.revert(12, run=run) == 1 + assert ["git", "branch", "-D", "fix/revert-12"] not in run.calls + recovery = capsys.readouterr().err.split("open it with: ", 1)[1].strip() + create = next(call for call in run.calls if call[:3] == ["gh", "pr", "create"]) + assert shlex.split(recovery) == create + assert create[create.index("--body") + 1].endswith(runs[0]["url"]) + assert "--fill" not in create + + +def test_does_not_create_a_worktree_when_worktree_create_refuses() -> None: + run = FakeRun(pr_view=_merged(), fail={"make worktree-create": 1}) + assert trunk_revert.revert(12, run=run) == 1 + assert not any(call[:3] == ["git", "worktree", "remove"] for call in run.calls) + assert not any(call[:3] == ["git", "-C", WORKTREE] for call in run.calls) + + +def test_refuses_a_merge_commit_that_is_not_on_develop() -> None: + run = FakeRun(pr_view=_merged(), fail={"git merge-base": 1}) + assert trunk_revert.revert(12, run=run) == 1 + assert not any(call[:1] == ["make"] for call in run.calls) + + +@pytest.mark.parametrize("runs", [[], [{"status": "completed", "conclusion": "failure"}]]) +def test_refuses_missing_run_evidence_before_mutating(runs: list[dict], capsys: pytest.CaptureFixture[str]) -> None: + run = FakeRun(pr_view=_merged(), runs=runs) + assert trunk_revert.revert(12, run=run) == 1 + assert "trunk-revert: refusing:" in capsys.readouterr().err + assert all(call[:1] == ["gh"] for call in run.calls) + + +def test_refuses_when_run_lookup_fails_before_mutating() -> None: + run = FakeRun(pr_view=_merged(), fail={"gh run list": 1}) + assert trunk_revert.revert(12, run=run) == 1 + assert all(call[:1] == ["gh"] for call in run.calls) + + +def test_refuses_when_there_is_no_failed_run_before_mutating(capsys: pytest.CaptureFixture[str]) -> None: + run = FakeRun(pr_view=_merged(), runs=_runs(("completed", "success", timedelta(hours=1)))) + assert trunk_revert.revert(12, run=run) == 1 + assert "no failed trunk.yml run" in capsys.readouterr().err + assert all(call[:1] == ["gh"] for call in run.calls) + + +def test_revert_names_the_newest_failed_run_by_creation_with_a_pending_run() -> None: + runs = _runs( + ("completed", "failure", timedelta(hours=1)), + ("in_progress", None, timedelta(minutes=1)), + ("completed", "failure", timedelta(hours=3)), + ) + runs[2]["updatedAt"] = NOW.strftime("%Y-%m-%dT%H:%M:%SZ") + run = FakeRun(pr_view=_merged(), runs=runs) + assert trunk_revert.revert(12, run=run) == 0 + create = next(call for call in run.calls if call[:3] == ["gh", "pr", "create"]) + assert create[create.index("--body") + 1].endswith(runs[0]["url"]) + + +def _runs(*items: tuple[str, str | None, timedelta]) -> list[dict]: + return [ + { + "status": status, + "conclusion": conclusion, + "createdAt": (NOW - age - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "updatedAt": (NOW - age).strftime("%Y-%m-%dT%H:%M:%SZ"), + "databaseId": 123 + index, + "url": f"https://github.com/BenchBox-dev/BenchBox/actions/runs/{123 + index}", + } + for index, (status, conclusion, age) in enumerate(items) + ] + + +def _gate(runs: list[dict] | None, branch: str = "fix/thing", *, code: int = 0, out: str | None = None): + payload = json.dumps(runs) if out is None else out + + def run(cmd: list[str]) -> tuple[int, str]: + assert cmd[:3] == ["gh", "run", "list"] + assert cmd[cmd.index("--workflow") :][:6] == [ + "--workflow", + "trunk.yml", + "--branch", + "develop", + "--limit", + "30", + ] + assert cmd[-2:] == ["--json", "conclusion,status,createdAt,updatedAt,databaseId,url"] + return code, payload + + return trunk_revert.trunk_gate(branch, run=run, now=NOW) + + +def test_gate_allows_when_there_are_no_runs(capsys: pytest.CaptureFixture[str]) -> None: + assert _gate([]) is None + assert capsys.readouterr().err.count("warning") == 1 + + +def test_gate_allows_when_gh_errors(capsys: pytest.CaptureFixture[str]) -> None: + assert _gate(None, code=1, out="HTTP 404: workflow trunk.yml not found") is None + assert capsys.readouterr().err.count("warning") == 1 + + +@pytest.mark.parametrize("out", ["invalid JSON", "{}", '[{"status": "completed"}]']) +def test_gate_keeps_malformed_run_metadata_advisory(out: str, capsys: pytest.CaptureFixture[str]) -> None: + assert _gate(None, out=out) is None + assert capsys.readouterr().err.count("warning") == 1 + + +def test_gate_allows_when_the_newest_run_is_green() -> None: + assert ( + _gate(_runs(("completed", "success", timedelta(minutes=5)), ("completed", "failure", timedelta(hours=3)))) + is None + ) + + +def test_gate_allows_a_red_run_under_thirty_minutes() -> None: + assert _gate(_runs(("completed", "failure", timedelta(minutes=29)))) is None + + +def test_gate_allows_a_red_run_at_thirty_minutes() -> None: + assert _gate(_runs(("completed", "failure", timedelta(minutes=30)))) is None + + +@pytest.mark.parametrize("conclusion", ["failure", "timed_out", "startup_failure"]) +def test_gate_refuses_a_red_run_over_thirty_minutes(conclusion: str) -> None: + refusal = _gate(_runs(("completed", conclusion, timedelta(minutes=31)))) + assert refusal is not None + assert "red for 31 minutes" in refusal + assert "make trunk-revert" in refusal + assert RUN_URL in refusal + + +@pytest.mark.parametrize("conclusion", ["cancelled", "neutral", "skipped", "action_required", "stale"]) +def test_gate_allows_when_the_newest_completed_run_is_not_red(conclusion: str) -> None: + assert _gate(_runs(("completed", conclusion, timedelta(hours=1)))) is None + + +def test_gate_orders_by_creation_even_when_an_older_run_finishes_later() -> None: + runs = _runs( + ("completed", "failure", timedelta(hours=3)), + ("completed", "failure", timedelta(minutes=40)), + ) + runs[0]["updatedAt"] = NOW.strftime("%Y-%m-%dT%H:%M:%SZ") + refusal = _gate(runs) + assert refusal is not None + assert "red for 40 minutes" in refusal + assert runs[1]["url"] in refusal + assert runs[0]["url"] not in refusal + + +def test_gate_orders_runs_created_in_the_same_second_by_run_id() -> None: + runs = _runs( + ("completed", "failure", timedelta(hours=1)), + ("completed", "success", timedelta(hours=1)), + ) + assert _gate(runs) is None + + +def test_gate_allows_red_then_green() -> None: + runs = _runs( + ("completed", "failure", timedelta(hours=2)), + ("completed", "success", timedelta(hours=1)), + ) + assert _gate(runs) is None + + +def test_gate_ages_only_the_newest_failure() -> None: + runs = _runs( + ("completed", "failure", timedelta(minutes=10)), + ("completed", "failure", timedelta(minutes=50)), + ("completed", "success", timedelta(hours=3)), + ) + assert _gate(runs) is None + + +def test_gate_does_not_look_past_a_success() -> None: + runs = _runs( + ("completed", "failure", timedelta(minutes=10)), + ("completed", "success", timedelta(minutes=20)), + ("completed", "failure", timedelta(hours=2)), + ) + assert _gate(runs) is None + + +@pytest.mark.parametrize("status", ["queued", "in_progress", "waiting", "pending", "requested"]) +def test_gate_allows_when_the_newest_run_is_pending(status: str) -> None: + runs = _runs( + (status, None, timedelta(minutes=1)), + ("completed", "failure", timedelta(hours=1)), + ) + assert _gate(runs) is None + + +def test_gate_exempts_revert_branches() -> None: + assert _gate(_runs(("completed", "failure", timedelta(hours=5))), branch="fix/revert-12") is None + + +def test_main_gate_exits_nonzero_on_refusal( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(trunk_revert, "trunk_gate", lambda branch, run=None, repo="": "develop is red") + assert trunk_revert.main(["gate", "--branch", "fix/x"]) == 1 + assert "Refusing to open PR: develop is red" in capsys.readouterr().err + + +def test_main_rejects_a_malformed_repo(capsys: pytest.CaptureFixture[str]) -> None: + assert trunk_revert.main(["revert", "--pr", "3", "--repo", "not a repo"]) == 2 diff --git a/tests/unit/scripts/test_validate_submission.py b/tests/unit/scripts/test_validate_submission.py index 91f0d3cc5a..a6b109eac1 100644 --- a/tests/unit/scripts/test_validate_submission.py +++ b/tests/unit/scripts/test_validate_submission.py @@ -965,7 +965,7 @@ def test_mirror_lane_exempt(self): def test_absent_receipt_with_declared_enabled_cache_refused(self): # A receipt-capable platform declaring result_cache_enabled without a # receipt advertises cached timings with no disabling evidence. - for platform_name in ("Snowflake", "Redshift"): + for platform_name in ("Snowflake", "Redshift", "Databricks"): vr = ValidationResult("test") data = _bundle_with_cache_control(None, platform_name=platform_name, result_cache_enabled=True) _validate_bundle(data, vr) @@ -979,10 +979,10 @@ def test_absent_receipt_with_declared_disabled_cache_grandfathered(self): assert vr.ok, vr.errors def test_absent_receipt_with_enabled_cache_grandfathered_without_receipt_machinery(self): - # Platforms that never record a receipt (e.g. Databricks) keep the + # Platforms that never record a receipt (e.g. DuckDB) keep the # legacy exemption even when they declare an enabled cache. vr = ValidationResult("test") - data = _bundle_with_cache_control(None, platform_name="Databricks", result_cache_enabled=True) + data = _bundle_with_cache_control(None, platform_name="DuckDB", result_cache_enabled=True) _validate_bundle(data, vr) assert vr.ok, vr.errors @@ -1579,6 +1579,110 @@ def test_canonical_membership_allows_extra_ids(self): _validate_bundle(data, vr) assert vr.ok, vr.errors + def test_tpchavoc_requires_every_declared_variant(self): + from benchbox.validation.bundle import TPCHAVOC_CANONICAL_VARIANTS + + data = _minimal_bundle() + data["benchmark"]["id"] = "tpchavoc" + data["queries"] = [ + {"id": query_id, "ms": 100, "status": "SUCCESS"} for query_id in sorted(TPCHAVOC_CANONICAL_VARIANTS) + ] + data["summary"]["queries"] = {"total": 220, "passed": 220, "failed": 0} + vr = ValidationResult("test") + _validate_bundle(data, vr) + assert vr.ok, vr.errors + + data["queries"] = [query for query in data["queries"] if query["id"] != "12_v4"] + data["summary"]["queries"] = {"total": 219, "passed": 219, "failed": 0} + vr = ValidationResult("test") + _validate_bundle(data, vr) + assert not vr.ok + assert any("covers 21 of 22 canonical queries" in error and "12_v4" in error for error in vr.errors) + + data["queries"].append({"id": "12_v4", "ms": 100, "status": "SUCCESS", "run_type": "warmup"}) + vr = ValidationResult("test") + _validate_bundle(data, vr) + assert not vr.ok + assert any("12_v4" in error for error in vr.errors) + + def test_tpchavoc_success_without_timing_does_not_cover_variant(self): + # A SUCCESS row with ms null carries no measurement and must not + # satisfy variant coverage, even with 219 other timed rows. + from benchbox.validation.bundle import TPCHAVOC_CANONICAL_VARIANTS + + data = _minimal_bundle() + data["benchmark"]["id"] = "tpchavoc" + data["queries"] = [ + {"id": query_id, "ms": 100, "status": "SUCCESS"} for query_id in sorted(TPCHAVOC_CANONICAL_VARIANTS) + ] + data["queries"][0]["ms"] = None + data["summary"]["queries"] = {"total": 220, "passed": 220, "failed": 0} + vr = ValidationResult("test") + _validate_bundle(data, vr) + assert not vr.ok + assert any(data["queries"][0]["id"] in error for error in vr.errors) + + def test_tpchavoc_accepts_only_documented_engine_skips(self): + from benchbox.validation.bundle import TPCHAVOC_CANONICAL_VARIANTS, TPCHAVOC_DOCUMENTED_SKIPS + + data = _minimal_bundle() + data["benchmark"]["id"] = "tpchavoc" + data["platform"]["name"] = "DataFusion" + ids = TPCHAVOC_CANONICAL_VARIANTS - TPCHAVOC_DOCUMENTED_SKIPS["datafusion"] + data["queries"] = [{"id": query_id, "ms": 100, "status": "SUCCESS"} for query_id in sorted(ids)] + data["summary"]["queries"] = {"total": len(ids), "passed": len(ids), "failed": 0} + vr = ValidationResult("test") + _validate_bundle(data, vr) + assert vr.ok, vr.errors + + data["queries"] = [query for query in data["queries"] if query["id"] != "2_v1"] + data["queries"].append({"id": "FAKE", "ms": 100, "status": "SUCCESS"}) + vr = ValidationResult("test") + _validate_bundle(data, vr) + assert not vr.ok + assert any("2_v1" in error for error in vr.errors) + + def test_tpchavoc_mirror_skip_snapshot_matches_runtime_policy(self): + from benchbox.core.tpchavoc.benchmark import TPCHavocBenchmark + from benchbox.core.tpchavoc.variant_sets import VARIANT_REGISTRY + from benchbox.sql_compat.rules.execution_filter.clickhouse_tpchavoc import CLICKHOUSE_TPCHAVOC_SKIPS + from benchbox.sql_compat.rules.execution_filter.cloud_tpchavoc import CLOUD_TPCHAVOC_SKIPS + from benchbox.sql_compat.rules.execution_filter.datafusion_tpchavoc import DATAFUSION_TPCHAVOC_SKIPS + from benchbox.sql_compat.rules.execution_filter.lakesail_tpchavoc import LAKESAIL_TPCHAVOC_SKIPS + from benchbox.sql_compat.rules.execution_filter.postgres_tpchavoc import POSTGRES_TPCHAVOC_SKIPS + from benchbox.validation.bundle import ( + TPCHAVOC_CANONICAL_VARIANTS, + TPCHAVOC_DOCUMENTED_SKIPS, + _tpchavoc_documented_skips, + ) + + assert { + f"{query}_v{variant}" for query, variants in VARIANT_REGISTRY.items() for variant in variants + } == TPCHAVOC_CANONICAL_VARIANTS + assert { + "clickhouse": frozenset(CLICKHOUSE_TPCHAVOC_SKIPS), + "datafusion": frozenset(DATAFUSION_TPCHAVOC_SKIPS), + "lakesail": frozenset(LAKESAIL_TPCHAVOC_SKIPS), + "postgres": frozenset(POSTGRES_TPCHAVOC_SKIPS), + "snowflake": frozenset(CLOUD_TPCHAVOC_SKIPS["snowflake"]), + "databricks": frozenset(CLOUD_TPCHAVOC_SKIPS["databricks"]), + "bigquery": frozenset(CLOUD_TPCHAVOC_SKIPS["bigquery"]), + } == TPCHAVOC_DOCUMENTED_SKIPS + benchmark = object.__new__(TPCHavocBenchmark) + for platform in ( + "DataFusion", + "LakeSail", + "ClickHouse (Local)", + "ClickHouse Cloud", + "PG-DuckDB", + "TimescaleDB", + "DuckDB", + "Snowflake", + "Databricks", + "BigQuery", + ): + assert _tpchavoc_documented_skips(platform) == frozenset(benchmark.get_platform_skip_queries(platform)) + def test_tpcds_paired_variants_cover_all_99_logical_queries(self): data = _minimal_bundle() data["benchmark"]["id"] = "tpcds" diff --git a/tests/unit/sql_compat/test_databricks_txn_catalog_managed.py b/tests/unit/sql_compat/test_databricks_txn_catalog_managed.py new file mode 100644 index 0000000000..e910673ea0 --- /dev/null +++ b/tests/unit/sql_compat/test_databricks_txn_catalog_managed.py @@ -0,0 +1,68 @@ +"""Unit tests for the Databricks txn_staging_catalog_managed DDL_OPTIMIZE rule. + +The Transaction Primitives schema emitter declares the catalogManaged Delta +table feature on Databricks staging DDL. That behavior is governed by a +benchmark-scoped registry rule (not a bare ``dialect == "databricks"`` +branch), so the generated compatibility docs and the DDL drift check stay +accurate, and ``REGISTRY.resolve()`` keeps returning a single winner at the +platform-wide tier instead of raising ``CompatibilityRegistryConflict``. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from benchbox.sql_compat.actions import CompatAction +from benchbox.sql_compat.context import CompatibilityContext, Phase +from benchbox.sql_compat.decision import RewriteDDLPayload +from benchbox.sql_compat.registry import REGISTRY + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +_RULE_ID = "ddl_optimize.databricks.transaction_primitives.txn_staging_catalog_managed" + + +@pytest.fixture(scope="module", autouse=True) +def _load_databricks_rules(): + importlib.import_module("benchbox.sql_compat.rules.ddl_optimize.databricks_ddl_rewrites") + + +def _ctx(benchmark: str) -> CompatibilityContext: + return CompatibilityContext( + platform="databricks", + platform_version=None, + benchmark=benchmark, + query_id=None, + phase=Phase.DDL_OPTIMIZE, + mode="sql", + dialect="databricks", + ) + + +def test_txn_ctx_resolves_to_catalog_managed_rule(): + """The benchmark-scoped rule wins for transaction_primitives without conflict.""" + decision = REGISTRY.resolve(_ctx("transaction_primitives")) + + assert decision is not None + assert decision.rule_id == _RULE_ID + assert decision.action == CompatAction.REWRITE_DDL + assert isinstance(decision.payload, RewriteDDLPayload) + assert decision.payload.governance_only is True + + +def test_platform_wide_tier_still_has_single_winner(): + """Other benchmarks keep resolving to convert_to_delta_table, not a conflict.""" + decision = REGISTRY.resolve(_ctx("write_primitives")) + + assert decision is not None + assert decision.rule_id == "ddl_optimize.databricks.all.convert_to_delta_table" + + +def test_rule_carries_non_empty_description_and_reason(): + decisions = REGISTRY.resolve_all(_ctx("transaction_primitives")) + decision = next(d for d in decisions if d.rule_id == _RULE_ID) + + assert decision.payload.description + assert decision.reason diff --git a/tests/unit/sql_compat/test_sqlite_discount_bounds.py b/tests/unit/sql_compat/test_sqlite_discount_bounds.py new file mode 100644 index 0000000000..f22cef33ee --- /dev/null +++ b/tests/unit/sql_compat/test_sqlite_discount_bounds.py @@ -0,0 +1,47 @@ +"""Governance for SQLite's exact TPC-H discount-bound compilation rewrite.""" + +import importlib + +import pytest + +from benchbox.sql_compat import ( + REGISTRY, + CompatAction, + CompatibilityContext, + FailureMode, + Phase, + RewriteQueryPayload, + SupportLevel, +) + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +@pytest.mark.parametrize( + "platform, benchmark_name, query_id", + [("sqlite", "tpch", "6"), ("duckdb", "tpch", "6"), ("sqlite", "tpch", "5"), ("sqlite", "ssb", "6")], +) +def test_exact_discount_rule_scope_and_transformer(platform, benchmark_name, query_id): + importlib.import_module("benchbox.sql_compat.rules.query_compile.sqlite_tpch_discount_bounds") + context = CompatibilityContext( + platform=platform, + platform_version=None, + benchmark=benchmark_name, + query_id=query_id, + phase=Phase.QUERY_COMPILE, + mode="sql", + dialect=platform, + ) + decision = REGISTRY.resolve(context) + if (platform, benchmark_name, query_id) != ("sqlite", "tpch", "6"): + assert decision is None + return + assert decision.rule_id == "query_compile.sqlite.tpch.q6_exact_discount_bounds" + assert decision.action == CompatAction.REWRITE_QUERY + assert decision.support_level == SupportLevel.REWRITTEN + assert decision.failure_mode == FailureMode.SILENT_CORRUPTION + assert isinstance(decision.payload, RewriteQueryPayload) + module_name, name = decision.payload.transformer_id.rsplit(".", 1) + transformer = getattr(importlib.import_module(module_name), name) + query = "SELECT * FROM lineitem WHERE l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01" + assert transformer(query) == "SELECT * FROM lineitem WHERE l_discount BETWEEN 0.05 AND 0.07" diff --git a/tests/unit/test_base_advanced.py b/tests/unit/test_base_advanced.py index 317973bcf6..8ed4ba3b34 100644 --- a/tests/unit/test_base_advanced.py +++ b/tests/unit/test_base_advanced.py @@ -498,8 +498,7 @@ def test_run_benchmark_success_without_setup(self): def test_run_benchmark_all_queries_default(self): """Test benchmark execution with all queries (default).""" - with patch("time.time", side_effect=list(range(20))): # Plenty of timestamps - result = self.benchmark.run_benchmark(self.mock_connection, setup_database=False) + result = self.benchmark.run_benchmark(self.mock_connection, setup_database=False) # Should run all queries in the benchmark assert result["total_queries"] == len(self.benchmark.get_queries()) @@ -516,10 +515,7 @@ def mock_execute(query): self.mock_connection.execute.side_effect = mock_execute - with patch("time.time", side_effect=list(range(20))): - result = self.benchmark.run_benchmark( - self.mock_connection, query_ids=[1, "error_query"], setup_database=False - ) + result = self.benchmark.run_benchmark(self.mock_connection, query_ids=[1, "error_query"], setup_database=False) assert result["total_queries"] == 2 assert result["successful_queries"] == 1 @@ -539,13 +535,12 @@ def test_run_benchmark_setup_failure(self): def test_run_benchmark_with_fetch_results(self): """Test benchmark execution with result fetching.""" - with patch("time.time", side_effect=list(range(10))): - result = self.benchmark.run_benchmark( - self.mock_connection, - query_ids=[1], - fetch_results=True, - setup_database=False, - ) + result = self.benchmark.run_benchmark( + self.mock_connection, + query_ids=[1], + fetch_results=True, + setup_database=False, + ) query_result = result["query_results"][0] assert query_result["results"] == [{"result": "success"}] @@ -896,13 +891,12 @@ def test_full_benchmark_workflow(self): mock_connection.fetchall.return_value = [{"id": 1, "value": "test"}] # Run complete benchmark - with patch("time.time", side_effect=list(range(20))): - result = benchmark.run_benchmark( - mock_connection, - query_ids=[1, 2], - fetch_results=True, - setup_database=True, - ) + result = benchmark.run_benchmark( + mock_connection, + query_ids=[1, 2], + fetch_results=True, + setup_database=True, + ) # Verify complete workflow assert benchmark._data_generated @@ -932,13 +926,12 @@ def mock_execute(query): mock_connection.execute.side_effect = mock_execute mock_connection.fetchall.return_value = [{"result": "success"}] - with patch("time.time", side_effect=list(range(20))): - result = benchmark.run_benchmark( - mock_connection, - query_ids=[1, "error_query", 2], - fetch_results=True, - setup_database=True, - ) + result = benchmark.run_benchmark( + mock_connection, + query_ids=[1, "error_query", 2], + fetch_results=True, + setup_database=True, + ) assert result["total_queries"] == 3 assert result["successful_queries"] == 2 diff --git a/tests/unit/test_canary_impact.py b/tests/unit/test_canary_impact.py new file mode 100644 index 0000000000..ec7ef07a95 --- /dev/null +++ b/tests/unit/test_canary_impact.py @@ -0,0 +1,716 @@ +"""Tests for the canary-impact selector (scripts/canary_impact.py).""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest +import yaml + +from scripts import canary_impact +from scripts.canary_impact import ( + CANT_AFFECT_CANARY, + MEDIUM_WHOLE_SUITE_PATHS, + WHOLE_SUITE_PATHS, + FileDeps, + _resolve_plugin_file, + build_dependency_map, + compute_selection, + files_from_node_ids, + medium_relevant_paths, +) +from scripts.release_canary_sharding import MARKER_EXPRESSION as SHARDING_MARKER + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +# -- fixture-tree helpers ----------------------------------------------------- + + +def _write_tree(root: Path, files: dict[str, str]) -> None: + for rel, content in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _fixture_root(tmp_path: Path, files: dict[str, str]) -> Path: + """Create a minimal repo tree with an empty tests/conftest.py.""" + merged = {"tests/conftest.py": "", **files} + _write_tree(tmp_path, merged) + return tmp_path + + +def _select(root: Path, test_files: list[str], node_ids: list[str], changed: list[str]) -> dict: + dep_map, fallback, _sites = build_dependency_map(root, test_files) + assert fallback is None, fallback + return compute_selection(changed, dep_map, node_ids) + + +def _nodes(test_file: str, names: list[str]) -> list[str]: + return [f"{test_file}::{name}" for name in names] + + +# -- dependency rules ---------------------------------------------------------- + + +class TestDependencyRules: + def test_static_import_includes_parent_init(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "mypkg/__init__.py": "from . import base\n", + "mypkg/base.py": "VALUE = 1\n", + "mypkg/sub.py": "from mypkg import base\n", + "tests/test_x.py": "import mypkg.sub\n\ndef test_a(): ...\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + deps = dep_map["tests/test_x.py"].deps + assert "mypkg/sub.py" in deps + # Python executes every parent package __init__ on import. + assert "mypkg/__init__.py" in deps + + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["mypkg/__init__.py"]) + assert selection["whole_suite"] is False + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + + def test_importlib_literal_is_a_dependency(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "mypkg/__init__.py": "", + "mypkg/sub.py": "VALUE = 1\n", + "tests/test_x.py": 'import importlib\nmod = importlib.import_module("mypkg.sub")\n', + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert "mypkg/sub.py" in dep_map["tests/test_x.py"].deps + + def test_repo_root_join_is_a_dependency(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "data/spec.yaml": "version: 1\n", + "tests/test_x.py": ( + "from pathlib import Path\n" + "REPO_ROOT = Path(__file__).resolve().parents[1]\n" + 'SPEC = REPO_ROOT / "data" / "spec.yaml"\n' + ), + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert "data/spec.yaml" in dep_map["tests/test_x.py"].deps + + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["data/spec.yaml"]) + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + reasons = selection["selected"][0]["reasons"] + assert {"changed_path": "data/spec.yaml", "edge": "dependency"} in reasons + + def test_with_name_selects_sibling(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "tests/helper.py": "VALUE = 1\n", + "tests/test_x.py": ('from pathlib import Path\nHELPER = Path(__file__).with_name("helper.py")\n'), + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert "tests/helper.py" in dep_map["tests/test_x.py"].deps + + def test_directory_dependency_covers_files_under_it(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "datadir/a.txt": "a\n", + "datadir/nested/b.txt": "b\n", + "tests/test_x.py": ( + "from pathlib import Path\n" + "REPO_ROOT = Path(__file__).resolve().parents[1]\n" + 'DATA = REPO_ROOT / "datadir"\n' + ), + }, + ) + selection = _select( + root, + ["tests/test_x.py"], + _nodes("tests/test_x.py", ["test_a"]), + ["datadir/nested/b.txt"], + ) + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + + def test_pytest_plugins_apply_to_every_test(self, tmp_path: Path) -> None: + root = tmp_path + _write_tree( + root, + { + "tests/conftest.py": 'pytest_plugins = ["myplug"]\n', + "myplug.py": "import mypkg.base\n", + "mypkg/__init__.py": "", + "mypkg/base.py": "VALUE = 1\n", + "tests/test_x.py": "def test_a(): ...\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + deps = dep_map["tests/test_x.py"].deps + assert "myplug.py" in deps + assert "mypkg/base.py" in deps + assert "tests/conftest.py" in deps + + def test_same_directory_non_python_selects_importers(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "benchbox/widget/__init__.py": "", + "benchbox/widget/engine.py": "VALUE = 1\n", + "benchbox/widget/spec.yaml": "version: 1\n", + "benchbox/other/__init__.py": "", + "benchbox/other/engine.py": "VALUE = 2\n", + "benchbox/other/spec.yaml": "version: 2\n", + "tests/test_widget.py": "import benchbox.widget.engine\n", + "tests/test_other.py": "import benchbox.other.engine\n", + }, + ) + test_files = ["tests/test_widget.py", "tests/test_other.py"] + node_ids = _nodes("tests/test_widget.py", ["test_a"]) + _nodes("tests/test_other.py", ["test_b"]) + selection = _select(root, test_files, node_ids, ["benchbox/widget/spec.yaml"]) + assert selection["whole_suite"] is False + selected = {s["node_id"]: s["reasons"] for s in selection["selected"]} + assert "tests/test_widget.py::test_a" in selected + assert selected["tests/test_widget.py::test_a"] == [ + {"changed_path": "benchbox/widget/spec.yaml", "edge": "same_directory"} + ] + assert "tests/test_other.py::test_b" not in selected + + def test_transitive_imports_are_dependencies(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "mypkg/__init__.py": "", + "mypkg/leaf.py": "VALUE = 1\n", + "mypkg/mid.py": "import mypkg.leaf\n", + "tests/test_x.py": "import mypkg.mid\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert "mypkg/leaf.py" in dep_map["tests/test_x.py"].deps + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["mypkg/leaf.py"]) + assert selection["whole_suite"] is False + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + + def test_reexported_name_is_a_dependency(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "mypkg/__init__.py": "from mypkg.impl import Thing\n", + "mypkg/impl.py": "class Thing: ...\n", + "tests/test_x.py": "from mypkg import Thing\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert not dep_map["tests/test_x.py"].dynamic + assert "mypkg/impl.py" in dep_map["tests/test_x.py"].deps + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["mypkg/impl.py"]) + assert selection["whole_suite"] is False + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + + def test_string_script_import_is_a_dependency(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "mypkg/__init__.py": "", + "mypkg/sub.py": "VALUE = 1\n", + "tests/test_x.py": ( + "import subprocess\nimport sys\n" + "SCRIPT = '''\nimport mypkg.sub\nprint(mypkg.sub.VALUE)\n'''\n" + "def test_a():\n" + " subprocess.run([sys.executable, '-c', SCRIPT], check=True)\n" + ), + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert not dep_map["tests/test_x.py"].dynamic + assert "mypkg/sub.py" in dep_map["tests/test_x.py"].deps + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["mypkg/sub.py"]) + assert selection["whole_suite"] is False + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + + def test_conftest_chain_applies_per_directory(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "tests/unit/conftest.py": "import mypkg.unit_helper\n", + "mypkg/__init__.py": "", + "mypkg/unit_helper.py": "VALUE = 1\n", + "tests/unit/test_x.py": "def test_a(): ...\n", + "tests/other/test_y.py": "def test_b(): ...\n", + }, + ) + test_files = ["tests/unit/test_x.py", "tests/other/test_y.py"] + node_ids = _nodes("tests/unit/test_x.py", ["test_a"]) + _nodes("tests/other/test_y.py", ["test_b"]) + dep_map, fallback, _sites = build_dependency_map(root, test_files) + assert fallback is None + assert "tests/unit/conftest.py" in dep_map["tests/unit/test_x.py"].deps + assert "mypkg/unit_helper.py" in dep_map["tests/unit/test_x.py"].deps + assert "tests/unit/conftest.py" not in dep_map["tests/other/test_y.py"].deps + selection = _select(root, test_files, node_ids, ["mypkg/unit_helper.py"]) + assert selection["whole_suite"] is False + assert [s["node_id"] for s in selection["selected"]] == ["tests/unit/test_x.py::test_a"] + + def test_package_plugin_resolves_exact_file(self, tmp_path: Path) -> None: + root = tmp_path + _write_tree( + root, + { + "tests/conftest.py": 'pytest_plugins = ["pkg.plug"]\n', + "pkg/__init__.py": "MARKER = 1\n", + "pkg/plug.py": "import mypkg.leaf\n", + "mypkg/__init__.py": "", + "mypkg/leaf.py": "VALUE = 1\n", + "tests/test_x.py": "def test_a(): ...\n", + }, + ) + assert _resolve_plugin_file("pkg.plug", root / "tests" / "conftest.py", root) == "pkg/plug.py" + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + deps = dep_map["tests/test_x.py"].deps + # The plugin module itself, its package initializer, and the + # plugin's transitive dependencies must all be shared edges. + assert "pkg/plug.py" in deps + assert "pkg/__init__.py" in deps + assert "mypkg/leaf.py" in deps + + def test_from_import_submodule_of_regular_package(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + # The initializer deliberately does not import sub: the + # submodule edge must come from probing, not the closure. + "mypkg/__init__.py": "MARKER = 1\n", + "mypkg/sub.py": "VALUE = 1\n", + "tests/test_x.py": "from mypkg import sub\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert "mypkg/sub.py" in dep_map["tests/test_x.py"].deps + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["mypkg/sub.py"]) + assert selection["whole_suite"] is False + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + + def test_changed_canary_test_selects_itself(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + {"tests/test_x.py": "def test_a(): ...\n", "tests/test_y.py": "def test_b(): ...\n"}, + ) + test_files = ["tests/test_x.py", "tests/test_y.py"] + node_ids = _nodes("tests/test_x.py", ["test_a"]) + _nodes("tests/test_y.py", ["test_b"]) + selection = _select(root, test_files, node_ids, ["tests/test_y.py"]) + assert selection["whole_suite"] is False + selected = {s["node_id"]: s["reasons"] for s in selection["selected"]} + assert selected == {"tests/test_y.py::test_b": [{"changed_path": "tests/test_y.py", "edge": "self"}]} + + def test_no_changes_selects_nothing(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + {"tests/test_x.py": "import importlib\nmod = importlib.import_module(name)\n"}, + ) + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), []) + assert selection["whole_suite"] is False + assert selection["selected"] == [] + assert selection["selected_count"] == 0 + + +# -- fail-safe rules ------------------------------------------------------------ + + +class TestFailSafeRules: + def test_dynamic_import_always_selects(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "mypkg/__init__.py": "", + "mypkg/base.py": "VALUE = 1\n", + "tests/test_x.py": "import importlib\nimport mypkg.base\nmod = importlib.import_module(name)\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert dep_map["tests/test_x.py"].dynamic + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["mypkg/base.py"]) + assert selection["whole_suite"] is False + reasons = selection["selected"][0]["reasons"] + assert {"changed_path": "mypkg/base.py", "edge": "dependency"} in reasons + assert {"changed_path": "mypkg/base.py", "edge": "unresolvable_dynamic_edge"} in reasons + + def test_dynamic_path_from_constant_selects(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "tests/test_x.py": ( + "from pathlib import Path\n" + "REPO_ROOT = Path(__file__).resolve().parents[1]\n" + "def load(name):\n" + " return open(REPO_ROOT / name).read()\n" + ), + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert dep_map["tests/test_x.py"].dynamic + + def test_transitive_dynamic_import_is_reported_and_selects_test(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "mypkg/__init__.py": "", + "mypkg/helper.py": "import importlib\nmod = importlib.import_module(name)\n", + "tests/test_x.py": "import mypkg.helper\n", + }, + ) + dep_map, fallback, sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + # Test-specific unresolved imports select this test for changed paths + # that another test may map through a static edge. + assert dep_map["tests/test_x.py"].dynamic + assert any(site.startswith("mypkg/helper.py:") for site in sites) + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["mypkg/__init__.py"]) + assert selection["whole_suite"] is False + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + + def test_dynamic_chain_conftest_selects_test(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "tests/unit/conftest.py": "import importlib\nmod = importlib.import_module(name)\n", + "tests/unit/test_x.py": "def test_a(): ...\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/unit/test_x.py"]) + assert fallback is None + assert dep_map["tests/unit/test_x.py"].dynamic + + def test_chain_conftest_plugins_apply(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "tests/unit/conftest.py": 'pytest_plugins = ["unitplug"]\n', + "unitplug.py": "VALUE = 1\n", + "tests/unit/test_x.py": "def test_a(): ...\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/unit/test_x.py"]) + assert fallback is None + assert not dep_map["tests/unit/test_x.py"].dynamic + assert "unitplug.py" in dep_map["tests/unit/test_x.py"].deps + + def test_unresolvable_chain_plugin_selects_test(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "tests/unit/conftest.py": 'pytest_plugins = ["missing_plug"]\n', + "tests/unit/test_x.py": "def test_a(): ...\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/unit/test_x.py"]) + assert fallback is None + assert dep_map["tests/unit/test_x.py"].dynamic + + def test_unresolvable_external_import_is_ignored(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "tests/test_x.py": ( + "try:\n" + " import cupy_xyz_not_a_module\n" + " HAS_CUPY = True\n" + "except ImportError:\n" + " HAS_CUPY = False\n" + ), + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert not dep_map["tests/test_x.py"].dynamic + + def test_unresolvable_repo_named_import_selects(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + # Resolvable only through a sys.path manipulation the + # static search roots do not cover: the same-named repo + # file keeps it fail-safe. + "tests/helper_repo_named.py": "VALUE = 1\n", + "tests/test_x.py": "import helper_repo_named\n", + }, + ) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + assert dep_map["tests/test_x.py"].dynamic + + @pytest.mark.parametrize("trigger", sorted(WHOLE_SUITE_PATHS)) + def test_whole_suite_triggers(self, tmp_path: Path, trigger: str) -> None: + root = _fixture_root(tmp_path, {"tests/test_x.py": "def test_a(): ...\n"}) + node_ids = _nodes("tests/test_x.py", ["test_a", "test_b"]) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + selection = compute_selection([trigger], dep_map, node_ids) + assert selection["whole_suite"] is True + assert trigger in selection["whole_suite_reason"] + assert selection["selected_count"] == 2 + for selected in selection["selected"]: + assert {"changed_path": trigger, "edge": "whole_suite"} in selected["reasons"] + + def test_unmapped_path_runs_whole_suite(self, tmp_path: Path) -> None: + root = _fixture_root(tmp_path, {"tests/test_x.py": "def test_a(): ...\n"}) + node_ids = _nodes("tests/test_x.py", ["test_a"]) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + selection = compute_selection(["some/random/file.txt"], dep_map, node_ids) + assert selection["whole_suite"] is True + assert selection["unmapped_changed_paths"] == ["some/random/file.txt"] + assert selection["selected_count"] == 1 + + def test_safe_listed_path_selects_nothing(self, tmp_path: Path) -> None: + assert CANT_AFFECT_CANARY, "the reviewed safe list must not be empty" + root = _fixture_root(tmp_path, {"tests/test_x.py": "def test_a(): ...\n"}) + node_ids = _nodes("tests/test_x.py", ["test_a"]) + dep_map, fallback, _sites = build_dependency_map(root, ["tests/test_x.py"]) + assert fallback is None + changed = sorted((CANT_AFFECT_CANARY - {"_project/audits/"}) | {"_project/audits/2026-01-01-review.md"}) + selection = compute_selection(changed, dep_map, node_ids) + assert selection["whole_suite"] is False + assert selection["selected"] == [] + + def test_safe_listed_path_with_edge_still_selects(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "CHANGELOG.md": "notable change\n", + "tests/test_x.py": ( + "from pathlib import Path\n" + "CHANGELOG = Path(__file__).resolve().parents[1] / 'CHANGELOG.md'\n" + "def test_a():\n" + " assert CHANGELOG.is_file()\n" + ), + }, + ) + selection = _select(root, ["tests/test_x.py"], _nodes("tests/test_x.py", ["test_a"]), ["CHANGELOG.md"]) + assert selection["whole_suite"] is False + assert [s["node_id"] for s in selection["selected"]] == ["tests/test_x.py::test_a"] + + def test_json_contract_shape(self, tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "mypkg/__init__.py": "", + "mypkg/sub.py": "VALUE = 1\n", + "tests/test_x.py": "import mypkg.sub\n", + }, + ) + collection = tmp_path / "nodeids.txt" + collection.write_text("tests/test_x.py::test_a\ntests/test_x.py::test_b\n", encoding="utf-8") + output = tmp_path / "selection.json" + rc = canary_impact.main( + [ + "--repo-root", + str(root), + "--collection-file", + str(collection), + "--changed-path", + "mypkg/sub.py", + "--output", + str(output), + ] + ) + assert rc == 0 + selection = json.loads(output.read_text(encoding="utf-8")) + assert selection["marker_expression"] == canary_impact.MARKER_EXPRESSION + assert selection["whole_suite"] is False + assert selection["dynamic_library_sites"] == [] + assert selection["selected_count"] == 2 + assert selection["total_count"] == 2 + assert selection["collection"]["node_count"] == 2 + for selected in selection["selected"]: + assert selected["file"] == "tests/test_x.py" + for reason in selected["reasons"]: + assert set(reason) == {"changed_path", "edge"} + + +def test_marker_expression_matches_release_canary_workflow() -> None: + """The selector must use the canary's marker expression, not a copy.""" + workflow = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "release-canary.yml").read_text(encoding="utf-8")) + shards = workflow["jobs"]["credential-free-non-fast"] + run_text = "\n".join(str(step.get("run", "")) for step in shards["steps"]) + assert canary_impact.MARKER_EXPRESSION in run_text + assert canary_impact.MARKER_EXPRESSION == SHARDING_MARKER + + +def test_medium_preflight_ignores_non_product_paths() -> None: + changed = [ + "docs/readme.md", + ".github/workflows/pr.yml", + "tests/unit/test_canary_impact.py", + "Makefile", + "benchbox/monitoring/performance.py", + ] + assert medium_relevant_paths(changed) == ["benchbox/monitoring/performance.py"] + assert medium_relevant_paths(["tests/conftest.py", "uv.lock"]) == ["tests/conftest.py", "uv.lock"] + assert medium_relevant_paths(["examples/demo.py", "docker/compose.yml", "docs/conf.py", "docs/notes.md"]) == [ + "examples/demo.py", + "docker/compose.yml", + "docs/conf.py", + ] + + +def test_medium_preflight_treats_website_sources_as_product_paths() -> None: + changed = ["website/src/pages/index.astro", "website/package-lock.json"] + assert medium_relevant_paths(changed) == changed + + +def test_medium_preflight_skips_collection_and_mapping_without_product_paths(tmp_path: Path) -> None: + output = tmp_path / "selection.json" + rc = canary_impact.main( + [ + "--repo-root", + str(tmp_path), + "--changed-path", + "tests/test_x.py", + "--product-code-only", + "--output", + str(output), + ] + ) + assert rc == 0 + selection = json.loads(output.read_text(encoding="utf-8")) + assert selection["collection"]["source"] == "skipped_no_product_code" + assert selection["whole_suite"] is False + assert selection["selected_count"] == 0 + + +def test_medium_profile_parameters_select_only_affected_nodes() -> None: + node_ids = ["tests/test_x.py::test_a", "tests/test_y.py::test_b"] + dep_map = { + "tests/test_x.py": FileDeps(frozenset({"benchbox/x.py"}), False, ()), + "tests/test_y.py": FileDeps(frozenset({"benchbox/y.py"}), False, ()), + } + selection = compute_selection( + ["benchbox/x.py"], + dep_map, + node_ids, + marker_expression="medium", + cant_affect=frozenset(), + whole_suite_paths=MEDIUM_WHOLE_SUITE_PATHS, + ) + assert selection["marker_expression"] == "medium" + assert [entry["node_id"] for entry in selection["selected"]] == ["tests/test_x.py::test_a"] + + +def test_cant_affect_list_is_parameterized() -> None: + nodes = ["tests/test_x.py::test_a"] + dep_map = {"tests/test_x.py": FileDeps(frozenset({"benchbox/x.py"}), False, ())} + ignored = compute_selection(["docs/note.md"], dep_map, nodes, cant_affect=frozenset({"docs/"})) + assert ignored["selected_count"] == 0 + assert ignored["whole_suite"] is False + fallback = compute_selection(["docs/note.md"], dep_map, nodes, cant_affect=frozenset()) + assert fallback["whole_suite_reason"] == "unmapped_path:docs/note.md" + + +def test_shared_dynamic_import_does_not_select_every_test(tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "tests/conftest.py": "import pkg.registry\n", + "pkg/__init__.py": "", + "pkg/registry.py": "import importlib\nmod = importlib.import_module(name)\n", + "pkg/a.py": "VALUE = 1\n", + "pkg/b.py": "VALUE = 2\n", + "tests/test_a.py": "import pkg.a\n", + "tests/test_b.py": "import pkg.b\n", + }, + ) + files = ["tests/test_a.py", "tests/test_b.py"] + nodes = ["tests/test_a.py::test_a", "tests/test_b.py::test_b"] + dep_map, fallback, _sites = build_dependency_map(root, files) + assert fallback is None + selection = compute_selection(["pkg/a.py"], dep_map, nodes) + assert [entry["node_id"] for entry in selection["selected"]] == ["tests/test_a.py::test_a"] + + +def test_unresolved_library_import_selects_lazy_export_test(tmp_path: Path) -> None: + root = _fixture_root( + tmp_path, + { + "pkg/__init__.py": ( + "import importlib\n" + "def __getattr__(name):\n" + " module_name = 'backing' if name == 'Resource' else name\n" + " return getattr(importlib.import_module(f'{__name__}.{module_name}'), name)\n" + ), + "pkg/backing.py": "class Resource: ...\n", + "tests/test_lazy.py": "from pkg import Resource\n\ndef test_lazy(): ...\n", + "tests/test_direct.py": "import pkg.backing\n\ndef test_direct(): ...\n", + }, + ) + files = ["tests/test_lazy.py", "tests/test_direct.py"] + nodes = ["tests/test_lazy.py::test_lazy", "tests/test_direct.py::test_direct"] + dep_map, fallback, _sites = build_dependency_map(root, files) + assert fallback is None + assert "pkg/backing.py" not in dep_map["tests/test_lazy.py"].deps + selection = compute_selection(["pkg/backing.py"], dep_map, nodes) + assert {entry["node_id"] for entry in selection["selected"]} == set(nodes) + + +def test_medium_runner_uses_file_targets_and_scrubs_changed_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = _fixture_root( + tmp_path, + {"benchbox/x.py": "VALUE = 1\n", "tests/test_x.py": "import benchbox.x\n\ndef test_x(): ...\n"}, + ) + collection = tmp_path / "nodes.txt" + collection.write_text("tests/test_x.py::test_x\n", encoding="utf-8") + monkeypatch.setenv("BENCHBOX_MEDIUM_CHANGED_PATHS_JSON", json.dumps(["benchbox/x.py"])) + calls: list[tuple[list[str], dict[str, str]]] = [] + + def fake_run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((command, kwargs["env"])) # type: ignore[arg-type] + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(canary_impact.subprocess, "run", fake_run) + rc = canary_impact.main( + [ + "--repo-root", + str(root), + "--collection-file", + str(collection), + "--changed-json-env", + "BENCHBOX_MEDIUM_CHANGED_PATHS_JSON", + "--marker-expression", + "medium", + "--cant-affect-list", + "empty", + "--product-code-only", + "--run-selected", + "--output", + str(tmp_path / "selection.json"), + ] + ) + assert rc == 0 + assert len(calls) == 1 + command, env = calls[0] + assert "--dist=loadfile" in command + assert command[-1] == "tests/test_x.py" + assert "BENCHBOX_MEDIUM_CHANGED_PATHS_JSON" not in env diff --git a/tests/unit/test_canary_impact_live.py b/tests/unit/test_canary_impact_live.py new file mode 100644 index 0000000000..ea16aacd58 --- /dev/null +++ b/tests/unit/test_canary_impact_live.py @@ -0,0 +1,147 @@ +"""Live-tree tests for the canary-impact selector (medium tier). + +These tests collect the real canary suite and build the full dependency +map (~30s); they live in their own module so this file carries a single +top-level speed marker per the marker-strategy contract. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from scripts.canary_impact import ( + MEDIUM_WHOLE_SUITE_PATHS, + FileDeps, + _build_shared_deps, + _conftest_chain, + _parent_inits, + _rel, + analyze_python_file, + build_dependency_map, + collect_canary_node_ids, + compute_selection, + files_from_node_ids, +) + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +# Full-tree collection and dependency mapping can exceed the default 60s +# timeout when the medium suite runs with five workers. +@pytest.mark.timeout(180) +class TestLiveTree: + @pytest.fixture(scope="class") + def live_canary(self) -> tuple[list[str], dict[str, FileDeps]]: + from scripts.canary_impact import collect_canary_node_ids + + node_ids = collect_canary_node_ids(REPO_ROOT) + assert node_ids, "expected a non-empty canary collection" + dep_map, fallback, _sites = build_dependency_map(REPO_ROOT, files_from_node_ids(node_ids)) + assert fallback is None, fallback + return node_ids, dep_map + + @pytest.fixture(scope="class") + def live_medium(self) -> tuple[list[str], dict[str, FileDeps]]: + marker = "medium and not (slow or stress or resource_heavy or live_integration)" + node_ids = collect_canary_node_ids(REPO_ROOT, marker_expression=marker) + assert node_ids + dep_map, fallback, _sites = build_dependency_map(REPO_ROOT, files_from_node_ids(node_ids)) + assert fallback is None, fallback + return node_ids, dep_map + + def test_every_collected_file_has_a_per_file_edge(self, live_canary: tuple[list[str], dict[str, FileDeps]]) -> None: + """Each non-dynamic file has a dep beyond the shared conftest/plugin set. + + The shared set is added to every entry unconditionally, so asserting + non-empty deps would pass vacuously; only a per-file edge proves the + analyzer resolved something file-specific. A file whose own analysis + yields no repo edge at all is fixture-only: everything it observes + flows through the shared fixtures, so it needs no per-file edge. + Dynamic files are skipped: they are always selected by the per-test + dynamic rule. + """ + _, dep_map = live_canary + root = REPO_ROOT.resolve() + shared, shared_fallback, _ = _build_shared_deps(root, {}) + assert shared_fallback is None, shared_fallback + thin = sorted( + test_file + for test_file, file_deps in dep_map.items() + if not file_deps.dynamic + and not ( + set(file_deps.deps) + - shared + - set(_conftest_chain(test_file, root)) + - { + rel + for rel in (_rel(init, root) for init in _parent_inits(root / test_file, root)) + if rel is not None + } + ) + and not analyze_python_file(root / test_file, root).deps <= shared + ) + assert thin == [] + + @pytest.mark.parametrize( + "commit,test_file", + [ + ("68fb5a071", "tests/integration/test_tpcdi_full_benchmark.py"), + ("5e9284a2c", "tests/unit/core/flightdata/test_flightdata_benchmark_resource_heavy.py"), + ("ed3e39a9c", "tests/unit/scripts/test_validate_submission_resource_heavy.py"), + ("2384c3ee7", "tests/unit/scripts/test_validate_submission_resource_heavy.py"), + ], + ) + def test_known_regressions_are_selected( + self, live_canary: tuple[list[str], dict[str, FileDeps]], commit: str, test_file: str + ) -> None: + """Replay each known-regression commit's changed paths: the failing test must be selected.""" + node_ids, dep_map = live_canary + changed = subprocess.check_output( + ["git", "-C", str(REPO_ROOT), "diff", "--name-only", f"{commit}^", commit], + text=True, + ).split() + assert changed, f"expected changed paths for {commit}" + selection = compute_selection(changed, dep_map, node_ids) + expected = {node_id for node_id in node_ids if node_id.split("::", 1)[0] == test_file} + assert expected, f"expected {test_file} in the live collection" + selected = {s["node_id"] for s in selection["selected"]} + assert expected <= selected, f"{commit} missed {sorted(expected - selected)}" + for selected_entry in selection["selected"]: + if selected_entry["file"] == test_file: + assert selected_entry["reasons"], "every selection needs a reason" + + def test_monitoring_lazy_export_selects_resource_monitoring( + self, live_medium: tuple[list[str], dict[str, FileDeps]] + ) -> None: + marker = "medium and not (slow or stress or resource_heavy or live_integration)" + node_ids, dep_map = live_medium + selection = compute_selection( + ["benchbox/monitoring/performance.py"], + dep_map, + node_ids, + marker_expression=marker, + cant_affect=frozenset(), + whole_suite_paths=MEDIUM_WHOLE_SUITE_PATHS, + ) + expected = {node for node in node_ids if node.startswith("tests/unit/monitoring/test_resource_monitoring.py::")} + assert expected + selected = {entry["node_id"] for entry in selection["selected"]} + assert expected <= selected + + def test_medium_single_module_diff_selects_subset(self, live_medium: tuple[list[str], dict[str, FileDeps]]) -> None: + node_ids, dep_map = live_medium + selection = compute_selection( + ["benchbox/monitoring/profiler.py"], + dep_map, + node_ids, + marker_expression="medium and not (slow or stress or resource_heavy or live_integration)", + cant_affect=frozenset(), + whole_suite_paths=MEDIUM_WHOLE_SUITE_PATHS, + ) + assert selection["whole_suite"] is False + assert 0 < selection["selected_count"] < selection["total_count"] diff --git a/tests/unit/test_cli_dryrun_real_execution.py b/tests/unit/test_cli_dryrun_real_execution.py index 0bb7808dff..98e7e13937 100644 --- a/tests/unit/test_cli_dryrun_real_execution.py +++ b/tests/unit/test_cli_dryrun_real_execution.py @@ -10,6 +10,9 @@ import pytest from benchbox.cli.dryrun import DryRunExecutor +from benchbox.core.schemas import BenchmarkConfig +from benchbox.core.tpchavoc.benchmark import TPCHavocBenchmark +from benchbox.platforms.datafusion import DataFusionAdapter from benchbox.platforms.duckdb import DuckDBAdapter pytestmark = [ @@ -147,9 +150,17 @@ def test_test_mode_query_extraction_never_opens_platform_connection(self, benchm benchmark_config = Mock(name=benchmark_name, scale_factor=0.01, test_execution_type="power") benchmark_config.name = benchmark_name adapter = Mock() + adapter._get_dialect_queries.return_value = {1: "SELECT 1"} + adapter._filter_queries.return_value = {1: "SELECT 1"} adapter.create_connection.side_effect = AssertionError("query extraction opened a platform connection") assert executor._extract_queries(benchmark, benchmark_config, adapter) == {"1": "SELECT 1"} + adapter._get_dialect_queries.assert_called_once_with( + benchmark, + benchmark_slug=benchmark_name, + connection=None, + strict_translation=True, + ) adapter.create_connection.assert_not_called() adapter.enable_dry_run.assert_not_called() @@ -163,7 +174,8 @@ def test_extract_queries_with_platform_adapter(self): # Create mock platform adapter mock_adapter = Mock() - mock_adapter.translate_sql.return_value = "SELECT 1 /* translated */" + mock_adapter._get_dialect_queries.return_value = {"1": "SELECT 1 /* translated */"} + mock_adapter._filter_queries.return_value = {"1": "SELECT 1 /* translated */"} # Create mock benchmark config benchmark_config = Mock() @@ -172,8 +184,41 @@ def test_extract_queries_with_platform_adapter(self): result = executor._extract_queries(mock_benchmark, benchmark_config, mock_adapter) - # Should return standard queries without translation (fallback mode) - assert result == {"1": "SELECT 1"} + assert result == {"1": "SELECT 1 /* translated */"} + mock_adapter._get_dialect_queries.assert_called_once_with( + mock_benchmark, + benchmark_slug="tpcds", + connection=None, + strict_translation=True, + ) + + def test_invalid_query_subset_fails_instead_of_saving_empty_preview(self): + """Invalid query IDs must not be hidden as a successful empty dry run.""" + executor = DryRunExecutor() + benchmark = TPCHavocBenchmark(scale_factor=0.01) + benchmark_config = BenchmarkConfig( + name="tpchavoc", + display_name="TPC-Havoc", + scale_factor=0.01, + queries=["definitely-invalid"], + test_execution_type="power", + ) + + with pytest.raises(RuntimeError, match="Dry-run query extraction failed.*Invalid query IDs"): + executor._extract_queries(benchmark, benchmark_config, DataFusionAdapter()) + + def test_adapter_translation_fallback_is_rejected_for_dry_run(self): + """Unexpected translation failures must not emit raw benchmark SQL.""" + executor = DryRunExecutor() + benchmark = Mock() + benchmark_config = Mock() + benchmark_config.name = "tpchavoc" + benchmark_config.test_execution_type = "standard" + adapter = Mock() + adapter._get_dialect_queries.side_effect = RuntimeError("translation failed") + + with pytest.raises(RuntimeError, match="Dry-run query extraction failed: translation failed"): + executor._extract_queries(benchmark, benchmark_config, adapter) def test_extract_queries_standard_without_platform_adapter(self): """Test standard query extraction without platform adapter.""" diff --git a/tests/unit/test_conftest_git_environment.py b/tests/unit/test_conftest_git_environment.py new file mode 100644 index 0000000000..f71b2e551f --- /dev/null +++ b/tests/unit/test_conftest_git_environment.py @@ -0,0 +1,53 @@ +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +ROOT = Path(__file__).resolve().parents[2] + +_KEYS = tuple( + subprocess.run(["git", "rev-parse", "--local-env-vars"], capture_output=True, text=True, check=True).stdout.split() +) + + +def test_session_drops_inherited_repository_location() -> None: + for key in _KEYS: + assert key not in os.environ + + +@pytest.mark.parametrize("redirect", ["git_dir", "git_config"]) +def test_a_hook_environment_cannot_redirect_scratch_git_commands(tmp_path: Path, redirect: str) -> None: + outer = tmp_path / "outer" + subprocess.run(["git", "init", "-q", str(outer)], check=True) + probe = tmp_path / "probe.py" + probe.write_text( + "import os, subprocess, sys\n" + "sys.path.insert(0, sys.argv[1])\n" + "import tests.conftest # noqa: F401\n" + "scratch = sys.argv[2]\n" + "os.makedirs(scratch)\n" + "subprocess.run(['git', 'init', '-q', '.'], cwd=scratch, check=True)\n" + "subprocess.run(['git', 'config', 'user.name', 'Scratch'], cwd=scratch, check=True)\n", + encoding="utf-8", + ) + if redirect == "git_dir": + env = dict(os.environ, GIT_DIR=str(outer / ".git"), GIT_WORK_TREE=str(outer)) + else: + env = dict(os.environ, GIT_CONFIG=str(outer / ".git" / "config")) + subprocess.run( + [sys.executable, str(probe), str(ROOT), str(tmp_path / "scratch")], + check=True, + env=env, + cwd=tmp_path, + ) + outer_name = subprocess.run( + ["git", "config", "-f", str(outer / ".git" / "config"), "--get", "user.name"], + capture_output=True, + text=True, + ) + assert outer_name.stdout.strip() == "" + assert (tmp_path / "scratch" / ".git").is_dir() diff --git a/tests/unit/test_contributing_pr_open_auto_merge_docs.py b/tests/unit/test_contributing_pr_open_auto_merge_docs.py index 794f645659..3371536129 100644 --- a/tests/unit/test_contributing_pr_open_auto_merge_docs.py +++ b/tests/unit/test_contributing_pr_open_auto_merge_docs.py @@ -1,10 +1,12 @@ -"""Pin CONTRIBUTING.md to the post-#1567/#1592 auto-merge enablement policy. - -``make pr-open`` withholds auto-merge; arming is explicit via ``make pr-ready`` -or ``make pr-open READY=1`` when reusing an already-open, reviewed PR (the only arm paths — the workflow is revoke-only -since auto-merge-policy-consolidation-2026-08-06, D2). CONTRIBUTING used to -teach the opposite (``gh pr merge --auto --squash`` as part of pr-open). This -module is a cheap docs contract so that drift fails in the fast unit lane. +"""Pin CONTRIBUTING.md to the arm-the-exact-head, monitor-to-merge policy. + +A PR is done when it is merged. The author arms the exact head with +``gh pr merge --squash --match-head-commit `` and monitors the queue; a +push after arming is handled by arming the new head, not by holding PRs back. +``auto-merge-on-open.yml`` is revoke-only. CONTRIBUTING used to teach that +``make pr-open`` withholds auto-merge until a human runs ``make pr-ready``, which +turned every green PR into a hand-back. This module is a cheap docs contract so +that drift back to that wording fails in the fast unit lane. """ from __future__ import annotations @@ -37,11 +39,24 @@ def test_contributing_does_not_claim_pr_open_arms_auto_merge(contributing_text: assert "open the PR with auto-merge in one shot" not in contributing_text -def test_contributing_documents_withhold_and_reviewed_reuse_arm_path(contributing_text: str) -> None: - """Finished-branch path must stay documented: pr-ready / reviewed reuse / withhold.""" - for required in ("withhold", "pr-ready", "READY=1"): - assert required in contributing_text, f"CONTRIBUTING missing arm cue {required!r}" - assert "reuses an already-open, reviewed PR" in contributing_text - assert "newly created PR remains held until review" in contributing_text - # The workflow must be documented as revoke-only: no draft→ready arm path. - assert "revoke-only" in contributing_text +def test_contributing_documents_arming_the_exact_head_and_monitoring_to_merge(contributing_text: str) -> None: + """The finished-branch path is: arm the exact head, then monitor until merged.""" + contributing_text = " ".join(contributing_text.split()) # tolerate reflowed lines + for required in ( + "make pr-arm", + "make pr-landing-withdraw", + "refuses on a hold label", + "monitor to merge", + "You are done when the PR is merged", + "[WRITE-CLOSEOUT-001]", + ): + assert required in contributing_text, f"CONTRIBUTING missing cue {required!r}" + # The workflow is documented as revoke-only: there is no draft-to-ready arm path. + assert "only revokes and never arms" in contributing_text + + +def test_contributing_does_not_teach_holding_a_finished_pr(contributing_text: str) -> None: + """No wording that withholds auto-merge or waits for a human to mark a PR ready.""" + lowered = contributing_text.lower() + for forbidden in ("withhold", "remains held", "stays held", "when the branch is final", "marked final"): + assert forbidden not in lowered, f"CONTRIBUTING still teaches holding a finished PR: {forbidden!r}" diff --git a/tests/unit/test_correctness_gate_value_oracle.py b/tests/unit/test_correctness_gate_value_oracle.py index d8d5a398ac..74ae057998 100644 --- a/tests/unit/test_correctness_gate_value_oracle.py +++ b/tests/unit/test_correctness_gate_value_oracle.py @@ -17,7 +17,9 @@ from __future__ import annotations +import json from decimal import Decimal +from pathlib import Path from types import SimpleNamespace import pytest @@ -339,3 +341,168 @@ def get_expected_result(self, benchmark_type, query_id, scale_factor, stream_id) # At other scales the SF=1 digest is declined (row-count fallback still applies). assert matrix._stored_value_digest(validator, "tpch", "1", 0.01, 0) is None assert matrix._stored_value_digest(validator, "tpch", "1", 10.0, 0) is None + + +@pytest.fixture +def digest_reference_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + from benchbox.core.expected_results import loader + + path = tmp_path / "digest-reference.json" + monkeypatch.setattr(loader, "_TPCH_VALUE_DIGEST_REFERENCE_PATH", path) + return path + + +@pytest.mark.parametrize("seed", [0, -1, 98741, None]) +def test_digest_snapshot_seed_comes_from_metadata(digest_reference_path: Path, seed: int) -> None: + from benchbox.core.expected_results.loader import load_tpch_value_digest_seed + + digest_reference_path.write_text( + json.dumps({"benchmark": "tpch", "scale_factor": 1.0, "reference_seed": seed}), encoding="utf-8" + ) + assert load_tpch_value_digest_seed() == seed + + +@pytest.mark.parametrize("seed", [True, False, "17", 1.5, {}, []]) +def test_digest_snapshot_rejects_noninteger_seed(digest_reference_path: Path, seed) -> None: + from benchbox.core.expected_results.loader import load_tpch_value_digest_seed + + digest_reference_path.write_text( + json.dumps({"benchmark": "tpch", "scale_factor": 1.0, "reference_seed": seed}), encoding="utf-8" + ) + with pytest.raises(ValueError, match="reference_seed must be an integer or null"): + load_tpch_value_digest_seed() + + +@pytest.mark.parametrize( + "payload", + [ + {"benchmark": "tpch", "scale_factor": 1.0}, + {"benchmark": "tpcds", "scale_factor": 1.0, "reference_seed": 31}, + {"benchmark": "tpch", "scale_factor": 0.1, "reference_seed": 31}, + {"benchmark": "tpch", "scale_factor": True, "reference_seed": 31}, + [], + ], +) +def test_digest_snapshot_rejects_missing_seed_or_wrong_identity(digest_reference_path: Path, payload) -> None: + from benchbox.core.expected_results.loader import load_tpch_value_digest_seed + + digest_reference_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError): + load_tpch_value_digest_seed() + + +def test_digest_snapshot_missing_or_unreadable_metadata_fails_closed(digest_reference_path: Path) -> None: + from benchbox.core.expected_results.loader import load_tpch_value_digest_seed + + with pytest.raises(FileNotFoundError): + load_tpch_value_digest_seed() + digest_reference_path.write_text("{broken", encoding="utf-8") + with pytest.raises(ValueError): + load_tpch_value_digest_seed() + + +@pytest.mark.parametrize( + "benchmark_name,platform,scale,query_ids,strict,emit,seeded", + [ + ("tpch", "duckdb", 1.0, "1,6", True, True, True), + ("tpch", "duckdb", 1.0, "1,6", True, True, None), + ("tpch", "duckdb", 1.0, "", True, True, False), + ("tpch", "duckdb", 0.1, "1,6", True, True, False), + ("tpcds", "duckdb", 1.0, "1,6", True, True, False), + ("tpch", "datafusion", 1.0, "1,6", True, True, False), + ("tpch", "duckdb", 1.0, "1,6", False, True, False), + ("tpch", "duckdb", 1.0, "1,6", True, False, False), + ], +) +def test_only_bounded_digest_gate_binds_snapshot_seed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + digest_reference_path: Path, + benchmark_name: str, + platform: str, + scale: float, + query_ids: str, + strict: bool, + emit: bool, + seeded: bool | None, +) -> None: + """``seeded`` None means a qgen -d (null seed) snapshot: the gate runs without --seed.""" + import tests.integration.test_local_platform_benchmark_matrix as matrix + + snapshot_seed = None if seeded is None else 31 + digest_reference_path.write_text( + json.dumps( + {"benchmark": "tpch", "scale_factor": 1.0, "reference_seed": snapshot_seed, "digests": {"1": "a", "6": "b"}} + ), + encoding="utf-8", + ) + monkeypatch.setenv(matrix.CORRECTNESS_GATE_QUERY_IDS_ENV, query_ids) + monkeypatch.setenv("BENCHBOX_STRICT_EXPECTED_RESULTS", str(int(strict))) + monkeypatch.setenv("BENCHBOX_EMIT_RESULT_DIGEST", str(int(emit))) + monkeypatch.setattr(matrix, "is_platform_available", lambda _: True) + monkeypatch.setattr(matrix, "_local_sql_case_enabled", lambda *_: True) + monkeypatch.setattr(matrix, "_select_scale", lambda *_: scale) + command = [] + + def capture_cli(args, **kwargs): + command.extend(args) + raise RuntimeError("command captured") + + monkeypatch.setattr(matrix, "run_cli_command", capture_cli) + with pytest.raises(RuntimeError, match="command captured"): + matrix.test_local_platform_benchmark_matrix(tmp_path, monkeypatch, platform, benchmark_name) + assert ("--seed" in command) is bool(seeded) + if seeded: + assert command[command.index("--seed") + 1] == "31" + + +@pytest.mark.parametrize("query_ids,seed", [("1", True), ("11", 31)]) +def test_digest_gate_rejects_bad_snapshot_before_cli( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + digest_reference_path: Path, + query_ids: str, + seed, +) -> None: + import tests.integration.test_local_platform_benchmark_matrix as matrix + + digest_reference_path.write_text( + json.dumps({"benchmark": "tpch", "scale_factor": 1.0, "reference_seed": seed, "digests": {"1": "a"}}), + encoding="utf-8", + ) + monkeypatch.setenv(matrix.CORRECTNESS_GATE_QUERY_IDS_ENV, query_ids) + monkeypatch.setenv("BENCHBOX_STRICT_EXPECTED_RESULTS", "1") + monkeypatch.setenv("BENCHBOX_EMIT_RESULT_DIGEST", "1") + monkeypatch.setattr(matrix, "is_platform_available", lambda _: True) + monkeypatch.setattr( + matrix, "run_cli_command", lambda *_args, **_kwargs: pytest.fail("invalid snapshot reached CLI") + ) + with pytest.raises(ValueError): + matrix.test_local_platform_benchmark_matrix(tmp_path, monkeypatch, "duckdb", "tpch") + + +@pytest.mark.parametrize("seed", [31, None, True]) +def test_digest_regeneration_binds_validated_snapshot_seed( + monkeypatch: pytest.MonkeyPatch, digest_reference_path: Path, seed: object +) -> None: + import _project.scripts.regenerate_correctness_gate_digests as regeneration + + digest_reference_path.write_text( + json.dumps({"benchmark": "tpch", "scale_factor": 1.0, "reference_seed": seed}), encoding="utf-8" + ) + monkeypatch.setenv(regeneration.CORRECTNESS_GATE_QUERY_IDS_ENV, "1,6") + captured = [] + + def capture_gate(work_dir, query_ids, bound_seed): + captured.append((query_ids, bound_seed)) + raise RuntimeError("gate captured") + + monkeypatch.setattr(regeneration, "_run_gate", capture_gate) + if seed is None or type(seed) is int: + with pytest.raises(RuntimeError, match="gate captured"): + regeneration.main() + assert captured == [(["1", "6"], seed)] + else: + with pytest.raises(ValueError, match="reference_seed must be an integer or null"): + regeneration.main() + assert not captured diff --git a/tests/unit/test_duration_policy.py b/tests/unit/test_duration_policy.py new file mode 100644 index 0000000000..6483aa8e1f --- /dev/null +++ b/tests/unit/test_duration_policy.py @@ -0,0 +1,190 @@ +"""Tests for test-tier duration policy and JUnit artifact generation.""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from tests.duration_policy import ( + T1_BUDGET_SECONDS, + collect_junit_durations, + current_test_tier, + load_durations, + write_duration_file, +) + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +def test_current_test_tier_defaults_to_t1_and_rejects_unknown(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BENCHBOX_TEST_TIER", raising=False) + assert current_test_tier() == "t1" + + monkeypatch.setenv("BENCHBOX_TEST_TIER", "t2") + assert current_test_tier() == "t2" + + monkeypatch.setenv("BENCHBOX_TEST_TIER", "nightly") + with pytest.raises(ValueError, match="BENCHBOX_TEST_TIER"): + current_test_tier() + + +def test_load_durations_rejects_non_finite_and_boolean_values(tmp_path: Path) -> None: + for value in [True, "0.2", float("inf")]: + path = tmp_path / "durations.json" + path.write_text( + json.dumps({"schema_version": 1, "tests": {"tests/unit/test.py::test": {"p95_seconds": value}}}), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="p95_seconds|negative"): + load_durations(path) + + +def test_collect_junit_durations_computes_p95_across_reports(tmp_path: Path) -> None: + reports = [] + for index, duration in enumerate(["0.1", "0.2", "0.4", "0.8"]): + report = tmp_path / f"report-{index}.xml" + report.write_text( + f'', + encoding="utf-8", + ) + reports.append(report) + + durations = collect_junit_durations(reports) + + assert durations == {"tests/unit/test_example.py::test_case": pytest.approx(0.74)} + + +def test_write_duration_file_is_sorted_and_loadable(tmp_path: Path) -> None: + path = tmp_path / "nested" / "test_durations.json" + write_duration_file( + path, + { + "tests/z.py::test_z": T1_BUDGET_SECONDS, + "tests/a.py::test_a": 0.25, + }, + generated_at="2026-09-28T20:00:00Z", + ) + + payload = json.loads(path.read_text(encoding="utf-8")) + assert list(payload["tests"]) == ["tests/a.py::test_a", "tests/z.py::test_z"] + assert load_durations(path)["tests/a.py::test_a"] == 0.25 + + +def test_collection_hook_skips_quarantine_outside_t3(monkeypatch: pytest.MonkeyPatch) -> None: + from tests import conftest as benchbox_conftest + + marker = SimpleNamespace(kwargs={"owner": "team", "expiry": "2099-01-01", "issue": "#1"}) + + class Item: + nodeid = "tests/unit/test_example.py::test_case" + + def __init__(self) -> None: + self.markers: list[object] = [] + + def get_closest_marker(self, name: str) -> object | None: + return marker if name == "quarantine" else None + + def add_marker(self, value: object) -> None: + self.markers.append(value) + + monkeypatch.setenv("BENCHBOX_TEST_TIER", "t2") + item = Item() + monkeypatch.setattr(benchbox_conftest, "_items_require_test_databases", lambda items: False) + + benchbox_conftest.pytest_collection_modifyitems(None, None, [item]) + + assert len(item.markers) == 1 + + +def test_collection_hook_does_not_skip_quarantine_in_t3(monkeypatch: pytest.MonkeyPatch) -> None: + from tests import conftest as benchbox_conftest + + marker = SimpleNamespace(kwargs={"owner": "team", "expiry": "2099-01-01", "issue": "#1"}) + + class Item: + nodeid = "tests/unit/test_example.py::test_case" + + def get_closest_marker(self, name: str) -> object | None: + return marker if name == "quarantine" else None + + def add_marker(self, value: object) -> None: + raise AssertionError(f"unexpected skip marker: {value}") + + monkeypatch.setenv("BENCHBOX_TEST_TIER", "t3") + monkeypatch.setattr(benchbox_conftest, "_items_require_test_databases", lambda items: False) + + benchbox_conftest.pytest_collection_modifyitems(None, None, [Item()]) + + +def test_collect_junit_durations_derives_nodeid_from_classname_without_file(tmp_path: Path) -> None: + report = tmp_path / "pytest_report.xml" + report.write_text( + "" + '' + '' + '' + '' + "", + encoding="utf-8", + ) + + durations = collect_junit_durations([report]) + assert durations == { + "tests/unit/test_example.py::test_func": 0.1, + "tests/unit/test_example.py::TestClass::test_method": 0.2, + "tests/unit/skipped_module.py": 0.0, + } + + +def test_t1_budget_violations_rejects_missing_timing_record(monkeypatch: pytest.MonkeyPatch) -> None: + from tests import duration_policy + + monkeypatch.delenv("BENCHBOX_TEST_DURATION_BOOTSTRAP", raising=False) + item = SimpleNamespace( + nodeid="tests/unit/test_new.py::test_unmeasured", + get_closest_marker=lambda name: object() if name == "fast" else None, + ) + + violations = duration_policy.t1_budget_violations(item, {}, allow_missing=False) + assert len(violations) == 1 + assert "missing timing record" in violations[0] + + +def test_t1_budget_violations_bootstrap_allows_missing(monkeypatch: pytest.MonkeyPatch) -> None: + from tests import duration_policy + + monkeypatch.setenv("BENCHBOX_TEST_DURATION_BOOTSTRAP", "1") + item = SimpleNamespace( + nodeid="tests/unit/test_new.py::test_unmeasured", + get_closest_marker=lambda name: object() if name == "fast" else None, + ) + + violations = duration_policy.t1_budget_violations(item, {}, allow_missing=True) + assert violations == [] + + +def test_is_bootstrap_artifact_requires_an_empty_explicit_baseline(tmp_path: Path) -> None: + from tests.duration_policy import is_bootstrap_artifact + + empty = tmp_path / "empty.json" + empty.write_text(json.dumps({"bootstrap": True, "tests": {}}), encoding="utf-8") + assert is_bootstrap_artifact(empty) is True + + populated = tmp_path / "populated.json" + populated.write_text( + json.dumps({"bootstrap": True, "tests": {"tests/unit/test.py::test": {"p95_seconds": 0.1}}}), + encoding="utf-8", + ) + assert is_bootstrap_artifact(populated) is False + + malformed = tmp_path / "malformed.json" + malformed.write_text("invalid json", encoding="utf-8") + assert is_bootstrap_artifact(malformed) is False + + +def test_write_duration_file_rejects_non_empty_bootstrap_artifact(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="empty tests map"): + write_duration_file(tmp_path / "durations.json", {"tests/unit/test.py::test": 0.1}, bootstrap=True) diff --git a/tests/unit/test_fixed_clock.py b/tests/unit/test_fixed_clock.py new file mode 100644 index 0000000000..473d1c0aca --- /dev/null +++ b/tests/unit/test_fixed_clock.py @@ -0,0 +1,119 @@ +"""The fixed-clock helpers report a fixed instant to one module and leave every other caller alone.""" + +from __future__ import annotations + +import os +import types +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from tests.utilities.fixed_clock import FIXED_EPOCH, FIXED_NOW, fixed_datetime_class, freeze_datetime, set_mtimes + +# Medium tier: these tests would take the fast-lane count past its ceiling. +pytestmark = [pytest.mark.unit, pytest.mark.medium] + + +def test_now_utcnow_and_today_report_the_fixed_instant() -> None: + frozen = fixed_datetime_class(FIXED_NOW) + assert frozen.now() == datetime(2026, 1, 15, 12, 0, 0) + assert frozen.now(timezone.utc) == FIXED_NOW + assert frozen.utcnow() == datetime(2026, 1, 15, 12, 0, 0) + assert frozen.today() == datetime(2026, 1, 15, 12, 0, 0) + assert frozen.now().tzinfo is None + assert frozen.utcnow().tzinfo is None + + +def test_now_converts_to_the_requested_zone() -> None: + frozen = fixed_datetime_class(FIXED_NOW) + plus_two = timezone(timedelta(hours=2)) + shifted = frozen.now(plus_two) + assert shifted == FIXED_NOW + assert shifted.hour == 14 + assert shifted.utcoffset() == timedelta(hours=2) + + +def test_an_aware_instant_reports_its_own_wall_fields_when_naive() -> None: + plus_two = datetime(2026, 1, 15, 14, 0, 0, tzinfo=timezone(timedelta(hours=2))) + frozen = fixed_datetime_class(plus_two) + assert frozen.now() == datetime(2026, 1, 15, 14, 0, 0) + assert frozen.utcnow() == datetime(2026, 1, 15, 12, 0, 0) + + +def test_a_naive_instant_is_read_as_utc() -> None: + frozen = fixed_datetime_class(datetime(2026, 1, 15, 12, 0, 0)) + assert frozen.now(timezone.utc) == FIXED_NOW + assert frozen.now() == datetime(2026, 1, 15, 12, 0, 0) + + +def test_real_datetimes_still_pass_isinstance_and_construction_is_unchanged() -> None: + frozen = fixed_datetime_class(FIXED_NOW) + assert isinstance(datetime(2020, 1, 1), frozen) + assert isinstance(frozen.now(), frozen) + assert issubclass(datetime, frozen) + assert frozen(2026, 3, 4, 5, 6, 7) == datetime(2026, 3, 4, 5, 6, 7) + assert frozen.fromisoformat("2026-02-03T04:05:06") == datetime(2026, 2, 3, 4, 5, 6) + + +def test_freeze_datetime_changes_only_the_named_module_and_restores_it() -> None: + consumer = types.ModuleType("fixed_clock_consumer") + consumer.datetime = datetime + other = types.ModuleType("fixed_clock_other") + other.datetime = datetime + with pytest.MonkeyPatch.context() as patch: + frozen = freeze_datetime(patch, consumer) + assert consumer.datetime is frozen + assert consumer.datetime.now(timezone.utc) == FIXED_NOW + assert other.datetime is datetime + assert datetime.now(timezone.utc) != FIXED_NOW + assert consumer.datetime is datetime + + +def test_freeze_datetime_accepts_another_name_and_instant() -> None: + consumer = types.ModuleType("fixed_clock_consumer") + consumer.dt = datetime + later = datetime(2027, 6, 7, 8, 9, 10, tzinfo=timezone.utc) + with pytest.MonkeyPatch.context() as patch: + freeze_datetime(patch, consumer, later, attr="dt") + assert consumer.dt.now(timezone.utc) == later + + +def test_freeze_datetime_rejects_a_name_that_is_not_the_datetime_class() -> None: + import datetime as datetime_module + + consumer = types.ModuleType("fixed_clock_consumer") + consumer.datetime = datetime_module + with pytest.MonkeyPatch.context() as patch: + with pytest.raises(TypeError, match="not the datetime class"): + freeze_datetime(patch, consumer) + assert consumer.datetime is datetime_module + + +def test_set_mtimes_orders_oldest_first_and_ends_at_newest(tmp_path: Path) -> None: + files = [tmp_path / name for name in ("a.json", "b.json", "c.json")] + for path in files: + path.write_text("{}") + stamps = set_mtimes(files) + assert stamps == [FIXED_EPOCH - 200.0, FIXED_EPOCH - 100.0, FIXED_EPOCH] + assert [os.stat(path).st_mtime for path in files] == stamps + + +def test_set_mtimes_ignores_creation_order_and_speed(tmp_path: Path) -> None: + newer, older = tmp_path / "newer.json", tmp_path / "older.json" + newer.write_text("{}") + older.write_text("{}") + set_mtimes([older, newer], newest=1_000_000.0, spacing=5.0) + assert os.stat(older).st_mtime == 999_995.0 + assert os.stat(newer).st_mtime == 1_000_000.0 + assert sorted([newer, older], key=lambda path: os.stat(path).st_mtime) == [older, newer] + + +def test_set_mtimes_handles_no_files_and_rejects_a_bad_spacing(tmp_path: Path) -> None: + assert set_mtimes([]) == [] + path = tmp_path / "a.json" + path.write_text("{}") + with pytest.raises(ValueError, match="spacing"): + set_mtimes([path], spacing=0) + with pytest.raises(ValueError, match="spacing"): + set_mtimes([path], spacing=-1.0) diff --git a/tests/unit/test_leak_detector.py b/tests/unit/test_leak_detector.py new file mode 100644 index 0000000000..a396b9fd25 --- /dev/null +++ b/tests/unit/test_leak_detector.py @@ -0,0 +1,279 @@ +"""Behavioral tests for post-teardown process-state detection.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.utilities import leak_detector +from tests.utilities.paths import REPO_ROOT + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +def test_legitimate_monkeypatch_and_chdir_pass(monkeypatch, tmp_path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("BENCHBOX_LEAK_DETECTOR_PROBE", "1") + import benchbox.utils.printing as printing + + monkeypatch.setattr(printing, "_QUIET", True) + + +def test_generator_fixture_restoring_state_passes(tmp_path) -> None: + old = os.getcwd() + os.chdir(tmp_path) + try: + assert os.getcwd() == str(tmp_path.resolve()) + finally: + os.chdir(old) + + +def test_detect_and_restore_reports_and_restores(tmp_path) -> None: + import benchbox.utils.config_interface as ci + import benchbox.utils.printing as printing + + baseline = leak_detector.snapshot() + try: + os.chdir(tmp_path) + os.environ["BENCHBOX_LEAK_DETECTOR_PROBE"] = "secret-value" + printing._QUIET = not baseline["globals"]["benchbox.utils.printing._QUIET"] + ci._config_provider = object() # type: ignore[assignment] + problems = leak_detector.detect_and_restore(baseline) + assert problems == [ + "cwd", + "env[BENCHBOX_LEAK_DETECTOR_PROBE]", + "benchbox.utils.printing._QUIET", + "benchbox.utils.config_interface._config_provider", + ] + assert "secret-value" not in str(problems) + assert leak_detector.snapshot() == baseline + finally: + leak_detector.detect_and_restore(baseline) + + +def test_provider_compared_by_identity_not_equality(monkeypatch) -> None: + import benchbox.utils.config_interface as ci + + class EqualProvider: + def __eq__(self, other): + return True + + before = EqualProvider() + monkeypatch.setattr(ci, "_config_provider", before) + baseline = leak_detector.snapshot() + ci._config_provider = EqualProvider() # type: ignore[assignment] + assert leak_detector.detect_and_restore(baseline) == ["benchbox.utils.config_interface._config_provider"] + assert ci._config_provider is before + + +def test_snapshot_does_not_allocate_default_provider(monkeypatch) -> None: + import benchbox.utils.config_interface as ci + + def forbidden(): + raise AssertionError("default provider must not be allocated") + + monkeypatch.setattr(ci, "get_default_config_provider", forbidden) + assert ( + leak_detector.snapshot()["globals"]["benchbox.utils.config_interface._config_provider"] is ci._config_provider + ) + + +def test_clean_state_reports_nothing() -> None: + assert leak_detector.detect_and_restore(leak_detector.snapshot()) == [] + + +def test_unloaded_globals_use_raw_defaults_without_imports(monkeypatch) -> None: + monkeypatch.delitem(sys.modules, "benchbox.utils.printing", raising=False) + monkeypatch.delitem(sys.modules, "benchbox.utils.config_interface", raising=False) + assert leak_detector.snapshot()["globals"] == { + "benchbox.utils.printing._QUIET": False, + "benchbox.utils.config_interface._config_provider": None, + } + assert "benchbox.utils.printing" not in sys.modules + assert "benchbox.utils.config_interface" not in sys.modules + + +@pytest.mark.parametrize( + ("body", "surface"), + [ + ('os.environ["LEAKY_PROBE"] = "secret-value"', "env[LEAKY_PROBE]"), + ('os.environ["EXISTING_PROBE"] = "changed-secret"', "env[EXISTING_PROBE]"), + ('del os.environ["EXISTING_PROBE"]', "env[EXISTING_PROBE]"), + ('os.chdir("changed")', "cwd"), + ("printing._QUIET = True", "benchbox.utils.printing._QUIET"), + ("ci._config_provider = object()", "benchbox.utils.config_interface._config_provider"), + ], +) +def test_leaking_test_fails_at_teardown_and_next_test_is_clean(tmp_path: Path, body: str, surface: str) -> None: + result = _run_probe(tmp_path, body) + assert result.returncode == 1, result.stdout + result.stderr + assert "2 passed, 1 error" in result.stdout + assert "test leaked process state past teardown (restored)" in result.stdout + assert surface in result.stdout + assert "secret-value" not in result.stdout + assert "changed-secret" not in result.stdout + + +def _run_probe( + tmp_path: Path, body: str, *, teardown: str = "", clean: str = "", mark: str = "" +) -> subprocess.CompletedProcess[str]: + (tmp_path / "changed").mkdir() + (tmp_path / "pytest.ini").write_text("[pytest]\nmarkers = unit\n", encoding="utf-8") + (tmp_path / "conftest.py").write_text( + "import pytest\nfrom tests.utilities.leak_detector import restore_global\n" + "@pytest.fixture(autouse=True)\ndef reset(request, _hermetic_state):\n" + " yield\n" + ' restore_global(request.node, "benchbox.utils.printing", "_QUIET", False)\n' + ' restore_global(request.node, "benchbox.utils.config_interface", "_config_provider", None)\n', + encoding="utf-8", + ) + (tmp_path / "test_probe.py").write_text( + "import os, pytest\nimport benchbox.utils.printing as printing\n" + "import benchbox.utils.config_interface as ci\npytestmark = pytest.mark.unit\n" + "ORIGINAL_CWD = os.getcwd()\n" + "@pytest.fixture\ndef finalizer():\n yield\n" + f" {teardown or 'pass'}\n" + f"{mark}\n" + f"def test_a(finalizer, monkeypatch):\n {body}\n" + "def test_b():\n" + " assert os.getcwd() == ORIGINAL_CWD\n" + ' assert "LEAKY_PROBE" not in os.environ\n' + ' assert os.environ["EXISTING_PROBE"] == "original"\n' + " assert printing._QUIET is False\n" + " assert ci._config_provider is None\n" + f" {clean or 'pass'}\n", + encoding="utf-8", + ) + env = dict(os.environ, PYTHONPATH=str(REPO_ROOT), EXISTING_PROBE="original") + env.pop("PYTEST_CURRENT_TEST", None) + # The parent pytest controller owns the shared lock. This child collects + # only the isolated probe, with no project conftest or native workloads. + return subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-c", + str(tmp_path / "pytest.ini"), + "--confcutdir", + str(tmp_path), + "-p", + "tests.utilities.leak_detector", + "-p", + "no:cacheprovider", + "-n", + "0", + "-q", + "test_probe.py", + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + +def test_finalizer_leak_is_detected_after_all_teardown(tmp_path: Path) -> None: + result = _run_probe(tmp_path, "pass", teardown='os.environ["LEAKY_PROBE"] = "secret-value"') + assert result.returncode == 1, result.stdout + result.stderr + assert "2 passed, 1 error" in result.stdout + assert "env[LEAKY_PROBE]" in result.stdout + + +def test_leak_restored_when_another_finalizer_fails(tmp_path: Path) -> None: + result = _run_probe( + tmp_path, + "pass", + teardown='os.environ["LEAKY_PROBE"] = "secret-value"; raise RuntimeError("cleanup-failed")', + ) + assert result.returncode == 1, result.stdout + result.stderr + assert "2 passed, 1 error" in result.stdout + assert "env[LEAKY_PROBE]" in result.stdout + assert "cleanup-failed" in result.stdout + + +def test_expected_failure_cannot_hide_a_leak(tmp_path: Path) -> None: + result = _run_probe( + tmp_path, + 'os.environ["LEAKY_PROBE"] = "secret-value"', + mark='@pytest.mark.xfail(reason="must not hide a state leak")', + ) + assert result.returncode == 1, result.stdout + result.stderr + assert "env[LEAKY_PROBE]" in result.stdout + assert "1 error" in result.stdout + + +def test_restored_monkeypatch_and_generator_teardown_pass(tmp_path: Path) -> None: + result = _run_probe( + tmp_path, + 'monkeypatch.chdir("changed"); monkeypatch.setenv("LEAKY_PROBE", "temporary"); ' + 'monkeypatch.setattr(printing, "_QUIET", True); monkeypatch.setattr(ci, "_config_provider", object())', + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "2 passed" in result.stdout + + +def _run_module_fixture_probe(tmp_path: Path, last_test_body: str) -> subprocess.CompletedProcess[str]: + """Run a module whose scoped fixture sets and restores one variable around its tests.""" + (tmp_path / "pytest.ini").write_text("[pytest]\nmarkers = unit\n", encoding="utf-8") + (tmp_path / "test_probe.py").write_text( + "import os, pytest\npytestmark = pytest.mark.unit\n" + '@pytest.fixture(scope="module")\ndef module_env():\n' + ' previous = os.environ.get("MODULE_PROBE")\n' + ' os.environ["MODULE_PROBE"] = "module-value"\n' + " yield\n" + " if previous is None:\n" + ' os.environ.pop("MODULE_PROBE", None)\n' + " else:\n" + ' os.environ["MODULE_PROBE"] = previous\n' + "def test_first(module_env):\n pass\n" + f"def test_last(module_env):\n {last_test_body}\n", + encoding="utf-8", + ) + env = dict(os.environ, PYTHONPATH=str(REPO_ROOT)) + env.pop("PYTEST_CURRENT_TEST", None) + return subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-c", + str(tmp_path / "pytest.ini"), + "--confcutdir", + str(tmp_path), + "-p", + "tests.utilities.leak_detector", + "-p", + "no:cacheprovider", + "-n", + "0", + "-q", + "test_probe.py", + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + +def test_a_module_fixture_restoring_state_is_not_blamed_on_the_last_test(tmp_path: Path) -> None: + """The module fixture's teardown runs inside the last test's teardown; it is not a leak of that test.""" + result = _run_module_fixture_probe(tmp_path, "pass") + assert result.returncode == 0, result.stdout + result.stderr + assert "2 passed" in result.stdout + assert "leaked" not in result.stdout + + +def test_a_leak_by_the_last_test_is_still_detected_next_to_a_module_fixture(tmp_path: Path) -> None: + result = _run_module_fixture_probe(tmp_path, 'os.environ["LEAKY_PROBE"] = "secret-value"') + assert result.returncode == 1, result.stdout + result.stderr + assert "env[LEAKY_PROBE]" in result.stdout + assert "env[MODULE_PROBE]" not in result.stdout + assert "secret-value" not in result.stdout diff --git a/tests/unit/test_ledger_coverage.py b/tests/unit/test_ledger_coverage.py new file mode 100644 index 0000000000..b06f9c8934 --- /dev/null +++ b/tests/unit/test_ledger_coverage.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[2] +LEDGER = REPO_ROOT / "docs" / "development" / "dev-loop-property-ledger.md" + +WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows" +WORKFLOWS_TEST_DIR = REPO_ROOT / "tests" / "unit" / "workflows" +SCRIPTS_TEST_DIR = REPO_ROOT / "tests" / "unit" / "scripts" +RELEASE_TEST_DIR = REPO_ROOT / "tests" / "unit" / "release" +UNIT_DIR = REPO_ROOT / "tests" / "unit" +SCRIPTS_DIR = REPO_ROOT / "scripts" +PROJECT_SCRIPTS_DIR = REPO_ROOT / "_project" / "scripts" +HOOKS_FILE = REPO_ROOT / ".pre-commit-config.yaml" + +# Section headers in the ledger mapped to the directory their rows resolve +# against. Bare filenames in a row resolve inside that directory; entries +# containing a slash resolve from the repository root. +SECTION_DIRS = { + "### `.github/workflows/`": WORKFLOW_DIR, + "### `tests/unit/workflows/`": WORKFLOWS_TEST_DIR, + "### `tests/unit/scripts/` (complete)": SCRIPTS_TEST_DIR, + "### `tests/unit/release/`": RELEASE_TEST_DIR, + "### `tests/unit/test_auto_merge_*`": UNIT_DIR, + "### `tests/unit/test_release_*`": UNIT_DIR, + "### `scripts/`": SCRIPTS_DIR, + "### `_project/scripts/`": PROJECT_SCRIPTS_DIR, +} +# Sections whose rows name hooks or globs rather than files in a directory; +# the reverse file-existence check skips them. +NON_FILE_SECTIONS = {"### `.pre-commit-config.yaml` hooks"} + + +def _ledger_text() -> str: + assert LEDGER.is_file(), f"ledger missing: {LEDGER}" + return LEDGER.read_text(encoding="utf-8") + + +def _section_rows(text: str) -> dict[str, list[str]]: + """Map each known section header to its table row texts.""" + sections: dict[str, list[str]] = {} + current: str | None = None + for line in text.splitlines(): + if line.startswith("### "): + current = line if line in SECTION_DIRS else None + if current is not None: + sections[current] = [] + elif current is not None and line.startswith("|"): + sections[current].append(line) + return sections + + +def _covered(entry: str, section: str, sections: dict[str, list[str]]) -> bool: + rows = sections.get(section, []) + if any(entry in row for row in rows): + return True + return any("ledger-catch-all:" in row for row in rows) + + +def _workflow_files() -> list[str]: + return sorted(p.name for p in WORKFLOW_DIR.glob("*.yml")) + + +CUTOVER_FILES = REPO_ROOT / "tests" / "unit" / "ledger_cutover_files.txt" + + +def _cutover_files(directory: Path, *, recursive: bool = True, pattern: str = "*") -> list[str]: + entries = CUTOVER_FILES.read_text(encoding="utf-8").split() + selected: list[str] = [] + for entry in entries: + path = REPO_ROOT / entry + try: + relative = path.relative_to(directory) + except ValueError: + continue + if not path.is_file() or not path.match(pattern): + continue + if not recursive and len(relative.parts) != 1: + continue + selected.append(relative.as_posix()) + return sorted(selected) + + +def test_cutover_lists_every_test_with_a_ledger_row() -> None: + sections = _section_rows(_ledger_text()) + cutover = set(CUTOVER_FILES.read_text(encoding="utf-8").split()) + enrolled = [ + ("### `tests/unit/workflows/`", WORKFLOWS_TEST_DIR.glob("test_*.py"), WORKFLOWS_TEST_DIR), + ("### `tests/unit/scripts/` (complete)", SCRIPTS_TEST_DIR.rglob("test_*"), SCRIPTS_TEST_DIR), + ("### `tests/unit/release/`", RELEASE_TEST_DIR.glob("test_*.py"), RELEASE_TEST_DIR), + ("### `tests/unit/test_auto_merge_*`", UNIT_DIR.glob("test_auto_merge_*.py"), UNIT_DIR), + ("### `tests/unit/test_release_*`", UNIT_DIR.glob("test_release_*.py"), UNIT_DIR), + ] + missing = [ + path.relative_to(REPO_ROOT).as_posix() + for section, paths, directory in enrolled + for path in paths + if any(f"`{path.relative_to(directory).as_posix()}`" in row for row in sections.get(section, [])) + and path.relative_to(REPO_ROOT).as_posix() not in cutover + ] + assert not missing, f"tests with a ledger row missing from {CUTOVER_FILES.name}: {missing}" + + +def test_ledger_lists_every_workflow() -> None: + text = _ledger_text() + missing = [name for name in _workflow_files() if name not in text] + assert not missing, f"workflows missing from ledger: {missing}" + + +def test_ledger_lists_every_workflows_test() -> None: + text = _ledger_text() + names = _cutover_files(WORKFLOWS_TEST_DIR, recursive=False) + assert names, "expected enrolled tests in tests/unit/workflows/" + missing = [name for name in names if name not in text] + assert not missing, f"tests/unit/workflows files missing from ledger: {missing}" + + +def test_ledger_lists_every_scripts_test() -> None: + text = _ledger_text() + sections = _section_rows(text) + section = "### `tests/unit/scripts/` (complete)" + names = _cutover_files(SCRIPTS_TEST_DIR, pattern="test_*") + assert any("/" in name for name in names), "expected nested suites under tests/unit/scripts/" + missing = [name for name in names if not _covered(name, section, sections)] + assert not missing, f"tests/unit/scripts files missing from ledger: {missing}" + + +def test_ledger_lists_every_release_test() -> None: + text = _ledger_text() + names = _cutover_files(RELEASE_TEST_DIR, recursive=False) + assert names, "expected enrolled tests in tests/unit/release/" + missing = [name for name in names if name not in text] + assert not missing, f"tests/unit/release files missing from ledger: {missing}" + + +def test_ledger_lists_auto_merge_and_release_tests() -> None: + text = _ledger_text() + names = _cutover_files(UNIT_DIR, recursive=False, pattern="test_auto_merge_*.py") + names += _cutover_files(UNIT_DIR, recursive=False, pattern="test_release_*.py") + assert names, "expected auto-merge and release tests in tests/unit/" + missing = [name for name in names if name not in text] + assert not missing, f"auto-merge/release tests missing from ledger: {missing}" + + +def test_ledger_lists_every_script() -> None: + text = _ledger_text() + sections = _section_rows(text) + top_level = sorted(p.name for p in SCRIPTS_DIR.glob("*.py")) + project_level = sorted(p.name for p in PROJECT_SCRIPTS_DIR.glob("*.py")) + assert top_level and project_level, "expected scripts in scripts/ and _project/scripts/" + missing = [name for name in top_level if not _covered(name, "### `scripts/`", sections)] + missing += [name for name in project_level if not _covered(name, "### `_project/scripts/`", sections)] + assert not missing, f"scripts missing from ledger: {missing}" + + +def test_ledger_covers_only_existing_files() -> None: + """Reverse check: every ledger row must resolve to a file on disk. + + The forward tests catch additions missing from the ledger. This test + catches the deletion scenario: removing a guarded file while leaving + its row unchanged fails here because the row no longer resolves. + Bare filenames resolve inside their section directory; slashed entries + resolve from the repository root. + """ + text = _ledger_text() + sections = _section_rows(text) + orphaned = [] + for section, source_dir in SECTION_DIRS.items(): + if section in NON_FILE_SECTIONS: + continue + for row in sections.get(section, []): + # The file entry is the first backtick span in the row; later + # spans name guards or reasons, not files in this directory. + match = re.search(r"`([^`]+)`", row) + if not match: + continue + entry = match.group(1) + if "ledger-catch-all:" in entry: + continue + if "*" in entry: + continue + # Entries are ledger-relative: bare names and section-relative + # subpaths resolve inside the section directory; anything else + # resolves from the repository root. + section_candidate = source_dir / entry + if section_candidate.is_file(): + continue + candidate = REPO_ROOT / entry + if candidate.is_file(): + continue + orphaned.append(f"{section} {entry}") + assert not orphaned, f"ledger entries with no file on disk: {orphaned}" + + +def test_ledger_lists_every_pre_commit_hook() -> None: + text = _ledger_text() + hook_ids = re.findall(r"^\s*-\s*id:\s*(\S+)", HOOKS_FILE.read_text(encoding="utf-8"), re.M) + assert hook_ids, "expected hooks in .pre-commit-config.yaml" + missing = [hook for hook in dict.fromkeys(hook_ids) if hook not in text] + assert not missing, f"pre-commit hooks missing from ledger: {missing}" + + +def test_ledger_records_keep_traps() -> None: + text = _ledger_text() + traps = [ + "detect_self_binding.py", + "rp_scoped_check.py", + "timing_audit.py", + "timing_policy_check.py", + "test_workflow_expression_contexts.py", + "ruleset_drift_check.py", + "validate_submission.py", + ] + missing = [name for name in traps if name not in text] + assert not missing, f"KEEP traps missing from ledger: {missing}" diff --git a/tests/unit/test_marker_strategy.py b/tests/unit/test_marker_strategy.py index a927c68f5a..1f2ae1bcec 100644 --- a/tests/unit/test_marker_strategy.py +++ b/tests/unit/test_marker_strategy.py @@ -1,16 +1,17 @@ -"""Guardrails for the explicit pytest marker strategy.""" +"""Guardrails for explicit pytest markers and measured test tiers.""" from __future__ import annotations import ast import re -import shlex +from datetime import date, timedelta from pathlib import Path +from types import SimpleNamespace import pytest -import yaml from tests import conftest as benchbox_conftest +from tests.duration_policy import T1_BUDGET_SECONDS, t1_budget_violations, validate_markers pytestmark = [ pytest.mark.unit, @@ -21,47 +22,7 @@ _TESTS_ROOT = _REPO_ROOT / "tests" _FAST_MEDIUM_DECORATOR_RE = re.compile(r"(?m)^\s*@pytest\.mark\.(fast|medium)\b") _SPEED_MARKERS = {"fast", "medium", "slow"} -_SCOPE_MARKERS = {"unit", "integration", "performance"} _E2E_QUICK_INCOMPATIBLE = {"stress", "resource_heavy", "live_integration"} -# Marker expressions of the lanes that run over the whole tree (or all of -# tests/integration), i.e. the ones a module gets selected by without anyone -# naming it. Path-scoped one-off steps are handled separately by -# _explicitly_invoked_test_paths(). -# -# Split by actual runner: -# - _PR_ROUTINE_WIDE_LANES: broad lanes invoked by required PR workflows (pr.yml, test.yml) -# - _SCHEDULED_OR_RELEASE_WIDE_LANES: broad lanes invoked by scheduled or release workflows: -# - nightly.yml:99: "slow and not (stress or resource_heavy or live_integration)" -# - validate-release-pr.yml:119: "(slow or resource_heavy) and not (stress or live_integration)" -# - _AUTOMATED_WORKFLOW_WIDE_LANES: all broad lanes executed across automated workflows -_PR_ROUTINE_WIDE_LANES = ( - "fast and not (slow or stress or resource_heavy or live_integration)", - "integration and not live_integration and not stress", - "integration and not (slow or stress or resource_heavy or live_integration)", - "medium and not (slow or stress or resource_heavy or live_integration)", - "platform_smoke or (integration and fast)", -) -_SCHEDULED_OR_RELEASE_WIDE_LANES = ( - "slow and not (stress or resource_heavy or live_integration)", - "(slow or resource_heavy) and not (stress or live_integration)", -) -_AUTOMATED_WORKFLOW_WIDE_LANES = _PR_ROUTINE_WIDE_LANES + _SCHEDULED_OR_RELEASE_WIDE_LANES -_TREE_WIDE_LANES = _AUTOMATED_WORKFLOW_WIDE_LANES -_WORKFLOW_TEST_PATH_RE = re.compile(r"tests/[A-Za-z0-9_./-]+/test_[A-Za-z0-9_-]+\.py") -# Declaring one of these at MODULE level says "this whole module is opt-in and -# runs outside the automatic lanes" (credentialed cloud suites, stress runs). -_OPT_IN_LANE_MARKERS = {"live_integration", "stress"} -# Test classes that carry live_integration INSIDE a module that presents itself -# as lane-run. Each genuinely needs an external service, so no lane can run it; -# each is listed deliberately, because the same shape with a service-FREE class -# is how DuckLake's core adapter coverage silently ran nowhere (w3/w4). Adding -# an entry is the reviewed way to say "this really does need live infra". -_SERVICE_DEPENDENT_CLASSES = { - "tests/integration/test_ducklake_integration.py::TestDuckLakePostgresCatalogLive": "needs a live PostgreSQL server", - "tests/integration/test_ducklake_integration.py::TestDuckLakeS3DataPathLive": "needs a real S3 bucket + AWS creds", - "tests/integration/test_throughput_session_isolation.py" - "::TestPostgreSQLIndependentConnectionIsolatesSessions": "needs a live PostgreSQL server on :5432", -} _PERSISTENT_DATABASE_FIXTURES = { "basic_test_db", "tpch_test_db", @@ -174,12 +135,14 @@ def _function_arg_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str class _FakeCollectedItem: def __init__(self, markers: set[str]) -> None: self._markers = markers + self.nodeid = "tests/unit/test_marker_strategy.py::fake" def get_closest_marker(self, name: str) -> object | None: return object() if name in self._markers else None def test_conftest_has_no_collection_time_speed_marker_rewrite(): + """Collection may enforce quarantine and budgets, but never rewrite speed markers.""" text = (_TESTS_ROOT / "conftest.py").read_text(encoding="utf-8") assert "test_speed_buckets.json" not in text @@ -187,6 +150,46 @@ def test_conftest_has_no_collection_time_speed_marker_rewrite(): assert "Expression.compile" not in text +def test_quarantine_requires_owner_expiry_and_issue(): + marker = SimpleNamespace( + kwargs={"owner": "", "expiry": "2020-01-01", "issue": ""}, + ) + item = SimpleNamespace( + nodeid="tests/unit/test_example.py::test_case", + get_closest_marker=lambda name: marker if name == "quarantine" else None, + ) + + errors = validate_markers(item, today=date(2026, 9, 28)) + + assert any("owner" in error for error in errors) + assert any("issue" in error for error in errors) + assert any("expired" in error for error in errors) + + +def test_t1_budget_requires_exemption_for_measured_slow_fast_test(): + item = SimpleNamespace( + nodeid="tests/unit/test_example.py::test_case", + get_closest_marker=lambda name: object() if name == "fast" else None, + ) + + violations = t1_budget_violations(item, {item.nodeid: T1_BUDGET_SECONDS + 0.01}) + + assert len(violations) == 1 + assert "duration_exempt" in violations[0] + + +def test_future_duration_exemption_clears_t1_budget_violation(): + exemption = {"reason": "tracked setup cost", "expiry": (date.today() + timedelta(days=30)).isoformat()} + markers = { + "fast": SimpleNamespace(kwargs={}), + "duration_exempt": SimpleNamespace(kwargs=exemption), + } + item = SimpleNamespace(nodeid="tests/unit/test_example.py::test_case", get_closest_marker=markers.get) + + assert validate_markers(item) == [] + assert t1_budget_violations(item, {item.nodeid: T1_BUDGET_SECONDS + 0.01}) == [] + + def test_conftest_database_setup_gate_uses_collected_item_markers(monkeypatch: pytest.MonkeyPatch): calls = [] @@ -270,245 +273,3 @@ def test_starrocks_resource_heavy_smoke_stays_in_integration_smoke_lane(): assert path.exists() assert "platform_smoke" in _top_level_marker_names(path) - - -def _selects(expression: str, markers: set[str]) -> bool: - """Evaluate a pytest ``-m`` expression against one test's marker set. - - pytest marker expressions are Python boolean expressions over marker names, - so evaluating them with every present marker bound to True and every absent - one defaulting to False reproduces the selection exactly - without shelling - out to a collection run per lane per module (minutes, not milliseconds). - """ - - class _MarkerNamespace(dict): - def __missing__(self, key: str) -> bool: - return False - - return bool(eval(expression, {"__builtins__": {}}, _MarkerNamespace.fromkeys(markers, True))) # noqa: S307 - - -def _run_blocks(value): - if isinstance(value, dict): - for key, child in value.items(): - if key == "run" and isinstance(child, str): - yield child - else: - yield from _run_blocks(child) - elif isinstance(value, list): - for child in value: - yield from _run_blocks(child) - - -def _explicitly_invoked_test_paths() -> set[str]: - """Integration tests may also be run directly by an explicit pytest command - that names the file (see pr.yml's promoted-reproducer steps). Those count as - covered, so scan for them rather than reporting a false positive. - """ - paths: set[str] = set() - for workflow in sorted((_REPO_ROOT / ".github" / "workflows").glob("*.yml")): - document = yaml.safe_load(workflow.read_text(encoding="utf-8")) or {} - for command in _run_blocks(document): - try: - tokens = shlex.split(command, comments=True, posix=True) - except ValueError: - tokens = [] - pytest_positions = [index for index, token in enumerate(tokens) if token == "pytest"] - for index in pytest_positions: - for token in tokens[index + 1 :]: - path = token.split("::", 1)[0] - if _WORKFLOW_TEST_PATH_RE.fullmatch(path): - paths.add(path) - return paths - - -def _workflow_marker_expressions(workflow_filenames: tuple[str, ...] | None = None) -> set[str]: - """Extract all marker expressions passed via `-m ` to pytest in workflow files.""" - expressions: set[str] = set() - workflow_files = ( - [(_REPO_ROOT / ".github" / "workflows" / fn) for fn in workflow_filenames] - if workflow_filenames - else sorted((_REPO_ROOT / ".github" / "workflows").glob("*.yml")) - ) - for workflow in workflow_files: - if not workflow.is_file(): - continue - document = yaml.safe_load(workflow.read_text(encoding="utf-8")) or {} - for command in _run_blocks(document): - try: - tokens = shlex.split(command, comments=True, posix=True) - except ValueError: - tokens = [] - for i, token in enumerate(tokens): - if token == "-m" and i + 1 < len(tokens): - if any("pytest" in t for t in tokens[:i]): - expressions.add(tokens[i + 1]) - return expressions - - -def test_every_integration_module_is_selected_by_at_least_one_lane(): - """A module no lane selects is dead coverage that still reads as green. - - ducklake-post-merge-review-followups w4: test_ducklake_integration.py - declared ``[integration, slow]`` at module level - i.e. an ordinary non-fast - integration module - while carrying ``live_integration`` on most of its - classes. Every tree-wide lane deselects live_integration, so 8 of its 9 - tests were selected by nothing at all: the file existed, passed locally, and - gated nothing in CI. - - That mismatch is the signature this checks for. A module that declares an - opt-in marker at MODULE level (``live_integration`` for credentialed cloud - suites, ``stress``) is deliberately out of the automatic lanes and exempt; - the bug is a module that presents as lane-run while no lane runs it. - """ - uncovered: list[str] = [] - explicit_paths = _explicitly_invoked_test_paths() - - for path in _iter_test_modules(): - rel = path.relative_to(_REPO_ROOT).as_posix() - if not rel.startswith("tests/integration/"): - continue - # Declared opt-in at module level: intentional, runs via a dedicated - # credentialed/manual workflow rather than a tree-wide lane. - if _top_level_marker_names(path) & _OPT_IN_LANE_MARKERS: - continue - # Named directly by a workflow step - the sanctioned escape hatch. - if rel in explicit_paths: - continue - marker_sets = [markers for _name, markers in _iter_test_marker_sets(path)] - if not marker_sets: - continue - if not any(_selects(expression, markers) for expression in _TREE_WIDE_LANES for markers in marker_sets): - uncovered.append(rel) - - assert uncovered == [], ( - "integration modules that present as lane-run but are selected by no configured lane and " - f"named by no workflow step: {uncovered}. Either give the service-free tests a marker a real " - "lane runs, declare the opt-in marker at module level if the whole module needs live infra, " - "or add an explicit pytest step naming the file (see pr.yml's promoted-reproducer steps)." - ) - - -def test_declared_automated_workflow_lanes_occur_in_workflows(): - """Verify that every lane in _SCHEDULED_OR_RELEASE_WIDE_LANES occurs in an active workflow. - - Guards against drift where a scheduled or release lane is deleted or modified in CI workflows, - leaving _SCHEDULED_OR_RELEASE_WIDE_LANES falsely reporting dead modules as automated. - """ - workflow_expressions = _workflow_marker_expressions() - for lane in _SCHEDULED_OR_RELEASE_WIDE_LANES: - assert lane in workflow_expressions, ( - f"Declared automated lane '{lane}' does not occur in any .github/workflows/*.yml file." - ) - - -def test_declared_pr_workflow_lanes_occur_in_pr_workflows(): - """Verify that automated PR wide lanes occur in pr.yml or test.yml.""" - pr_expressions = _workflow_marker_expressions(("pr.yml", "test.yml")) - for lane in ( - "fast and not (slow or stress or resource_heavy or live_integration)", - "platform_smoke or (integration and fast)", - "integration and not (slow or stress or resource_heavy or live_integration)", - ): - assert lane in pr_expressions, f"Declared PR wide lane '{lane}' does not occur in pr.yml or test.yml." - - -def test_report_workflow_wide_lane_coverage_gaps(): - """Verify that every test module is covered by automated workflow lanes. - - Stage 5 step 1/2 of the test-suite remediation migration path: - Audit coverage across all directories (tests/unit, tests/integration, tests/e2e, tests/uat, etc.). - - Every non-opt-in, non-explicitly-named module MUST be selected by at least one - automated workflow lane (_AUTOMATED_WORKFLOW_WIDE_LANES). If any module is orphaned, - fails with an actionable diagnostic listing the orphaned paths. - - Also verifies that modules deferred from PR routine lanes are covered by scheduled - nightly observation or release validation workflows. - """ - workflow_expressions = _workflow_marker_expressions() - for lane in _SCHEDULED_OR_RELEASE_WIDE_LANES: - assert lane in workflow_expressions, ( - f"Declared automated lane '{lane}' does not occur in any .github/workflows/*.yml file." - ) - - explicit_paths = _explicitly_invoked_test_paths() - uncovered_automated: list[str] = [] - deferred_to_scheduled_or_release: list[str] = [] - - for path in _iter_test_modules(): - rel = path.relative_to(_REPO_ROOT).as_posix() - # Declared opt-in at module level: intentional, runs via dedicated workflows - if _top_level_marker_names(path) & _OPT_IN_LANE_MARKERS: - continue - # Named directly by a workflow step - if rel in explicit_paths: - continue - marker_sets = [markers for _name, markers in _iter_test_marker_sets(path)] - if not marker_sets: - continue - if not any( - _selects(expression, markers) for expression in _AUTOMATED_WORKFLOW_WIDE_LANES for markers in marker_sets - ): - uncovered_automated.append(rel) - elif not any(_selects(expression, markers) for expression in _PR_ROUTINE_WIDE_LANES for markers in marker_sets): - deferred_to_scheduled_or_release.append(rel) - - assert not uncovered_automated, ( - f"Found {len(uncovered_automated)} test module(s) outside all automated workflow lanes:\n" - + "\n".join(f" - {p}" for p in uncovered_automated) - + "\nEvery module must be covered by a PR routine lane, scheduled nightly lane, release lane, " - "or declared opt-in at module level." - ) - # Validate that PR-deferred modules are tracked (e.g. non-credentialed slow tests) - assert isinstance(deferred_to_scheduled_or_release, list) - - -def test_opt_in_markers_below_module_level_are_declared_service_dependent(): - """Catch the DuckLake shape: a lane-run module hiding uncovered classes. - - A per-module coverage check cannot see this - one lane-selected test in the - module makes the whole file look covered no matter how many of its classes - run nowhere. test_ducklake_integration.py had exactly that: its sqlite class - was lane-selected while TestDuckLakeLiveConnection, which needs no external - service at all, sat behind live_integration and ran in no lane. - - So every live_integration class inside a module that does NOT declare the - marker at module level must be justified in _SERVICE_DEPENDENT_CLASSES. - Drop the marker (and gain lane coverage) or record why the service is real. - """ - undeclared: list[str] = [] - - for path in _iter_test_modules(): - rel = path.relative_to(_REPO_ROOT).as_posix() - if not rel.startswith("tests/integration/"): - continue - if _top_level_marker_names(path) & _OPT_IN_LANE_MARKERS: - continue - tree = ast.parse(path.read_text(encoding="utf-8")) - for node in tree.body: - if not isinstance(node, ast.ClassDef) or not (node.name.startswith("Test") or node.name.endswith("Tests")): - continue - class_markers = _decorator_marker_names(node) | _pytestmark_assignment_names(node.body) - if not (class_markers & _OPT_IN_LANE_MARKERS): - continue - if f"{rel}::{node.name}" not in _SERVICE_DEPENDENT_CLASSES: - undeclared.append(f"{rel}::{node.name}") - - assert undeclared == [], ( - f"opt-in marker below module level, not declared service-dependent: {undeclared}. No lane " - "selects these, so they gate nothing. If the class needs no external service, drop the " - "marker so a real lane runs it; if it does, add it to _SERVICE_DEPENDENT_CLASSES with the " - "service it requires." - ) - - -def test_e2e_quick_does_not_select_opt_in_heavy_tests(): - offenders: list[tuple[str, list[str]]] = [] - - for path in _iter_test_modules(): - rel = path.relative_to(_REPO_ROOT).as_posix() - for test_name, markers in _iter_test_marker_sets(path): - conflicts = sorted(markers & _E2E_QUICK_INCOMPATIBLE) - if "e2e_quick" in markers and conflicts: - offenders.append((f"{rel}::{test_name}", conflicts)) - - assert offenders == [] diff --git a/tests/unit/test_optional_engines_helpers.py b/tests/unit/test_optional_engines_helpers.py new file mode 100644 index 0000000000..5a360c1204 --- /dev/null +++ b/tests/unit/test_optional_engines_helpers.py @@ -0,0 +1,338 @@ +"""Tests for the shared optional-engine usability helpers.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +import types + +import pytest + +from benchbox.platforms.clickhouse import _dependencies as clickhouse_dependencies +from tests.fixtures.utility_fixtures import pyspark_test_environment +from tests.utilities import optional_engines + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + + +@pytest.fixture(autouse=True) +def _fresh_caches(): + optional_engines.chdb_skip_reason.cache_clear() + optional_engines.pyspark_skip_reason.cache_clear() + yield + optional_engines.chdb_skip_reason.cache_clear() + optional_engines.pyspark_skip_reason.cache_clear() + + +def _patch_import_chdb(monkeypatch: pytest.MonkeyPatch, result: object) -> None: + def fake() -> object: + if isinstance(result, BaseException): + raise result + return result + + monkeypatch.setattr(clickhouse_dependencies, "import_chdb", fake) + + +# --------------------------------------------------------------------------- # +# chDB # +# --------------------------------------------------------------------------- # +class ProbeConnection: + def __init__(self, *, fail_on: str = "", result: str = "1\n", close_error: bool = False): + self.fail_on = fail_on + self.result = result + self.close_error = close_error + self.statements: list[str] = [] + self.closed = False + + def query(self, sql: str, output_format: str) -> str: + assert output_format == "CSV" + self.statements.append(sql) + if self.fail_on and sql.startswith(self.fail_on): + raise RuntimeError("native runtime rejected the statement\ninternal detail") + return self.result if sql.startswith("SELECT") else "" + + def close(self) -> None: + self.closed = True + if self.close_error: + raise RuntimeError("native connection cleanup failed") + + +def _patch_chdb_connection(monkeypatch: pytest.MonkeyPatch, connection: ProbeConnection) -> list[str]: + calls: list[str] = [] + + def connect(path: str) -> ProbeConnection: + calls.append(path) + return connection + + _patch_import_chdb(monkeypatch, types.SimpleNamespace(connect=connect)) + return calls + + +def test_chdb_usable_when_the_connection_can_execute_sql(monkeypatch: pytest.MonkeyPatch) -> None: + connection = ProbeConnection() + calls = _patch_chdb_connection(monkeypatch, connection) + assert optional_engines.chdb_skip_reason() is None + assert optional_engines.chdb_usable() + assert calls == [":memory:"] + assert connection.closed + assert connection.statements == [ + "SELECT 1", + "CREATE TABLE benchbox_usability_probe (value UInt8) ENGINE=Memory", + "INSERT INTO benchbox_usability_probe VALUES (1)", + "SELECT value FROM benchbox_usability_probe", + ] + + +@pytest.mark.parametrize("statement", ["SELECT 1", "CREATE TABLE", "INSERT INTO", "SELECT value"]) +def test_chdb_runtime_failure_is_cached_and_closes_connection(monkeypatch, statement): + connection = ProbeConnection(fail_on=statement) + calls = _patch_chdb_connection(monkeypatch, connection) + reason = optional_engines.chdb_skip_reason() + assert "SQL runtime is unusable" in reason + assert "internal detail" not in reason + assert optional_engines.chdb_skip_reason() == reason + assert calls == [":memory:"] + assert connection.closed + + +def test_chdb_connection_failure_is_unusable(monkeypatch): + def connect(path): + raise OSError("native connection cannot start") + + _patch_import_chdb(monkeypatch, types.SimpleNamespace(connect=connect)) + assert "native connection cannot start" in optional_engines.chdb_skip_reason() + + +def test_chdb_unexpected_sql_result_closes_connection(monkeypatch): + connection = ProbeConnection(result="0\n") + _patch_chdb_connection(monkeypatch, connection) + assert "unexpected result" in optional_engines.chdb_skip_reason() + assert connection.closed + + +def test_chdb_connection_cleanup_failure_is_unusable(monkeypatch): + connection = ProbeConnection(close_error=True) + _patch_chdb_connection(monkeypatch, connection) + assert "cleanup failed" in optional_engines.chdb_skip_reason() + assert connection.closed + + +def test_chdb_missing_package_reports_not_installed(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_import_chdb(monkeypatch, ModuleNotFoundError("No module named 'chdb'", name="chdb")) + assert optional_engines.chdb_skip_reason() == "chDB not installed" + + +def test_chdb_native_load_failure_is_reported_as_unusable(monkeypatch: pytest.MonkeyPatch) -> None: + error = ImportError("dlopen(/x/chdb/_chdb.abi3.so, 0x0002): mis-aligned LINKEDIT string pool\nmore detail") + _patch_import_chdb(monkeypatch, error) + reason = optional_engines.chdb_skip_reason() + assert reason is not None + assert "installed but its native library cannot be loaded" in reason + assert "mis-aligned LINKEDIT string pool" in reason + assert "more detail" not in reason + + +def test_chdb_os_error_from_the_loader_is_unusable(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_import_chdb(monkeypatch, OSError("cannot open shared object file")) + assert "native library cannot be loaded" in (optional_engines.chdb_skip_reason() or "") + + +def test_chdb_missing_transitive_dependency_is_not_reported_as_not_installed(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_import_chdb(monkeypatch, ModuleNotFoundError("No module named 'pyarrow'", name="pyarrow")) + reason = optional_engines.chdb_skip_reason() or "" + assert "dependency is missing" in reason + assert reason != "chDB not installed" + + +def test_chdb_partial_import_without_connect_is_unusable(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_import_chdb(monkeypatch, types.SimpleNamespace()) + assert "import is incomplete" in (optional_engines.chdb_skip_reason() or "") + + +def test_require_chdb_skips_with_the_reason(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_import_chdb(monkeypatch, ImportError("dlopen failed")) + with pytest.raises(pytest.skip.Exception, match="native library cannot be loaded"): + optional_engines.require_chdb() + + +def test_long_reasons_are_truncated(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_import_chdb(monkeypatch, ImportError("x" * 1000)) + reason = optional_engines.chdb_skip_reason() or "" + assert len(reason) < 400 + assert reason.endswith("...") + + +# --------------------------------------------------------------------------- # +# PySpark # +# --------------------------------------------------------------------------- # +def _patch_pyspark(monkeypatch: pytest.MonkeyPatch, *, available: bool = True, java_reason: str = "") -> None: + import benchbox.platforms.pyspark as pyspark_platform + + monkeypatch.setattr(pyspark_platform, "PYSPARK_AVAILABLE", available) + monkeypatch.setattr(pyspark_platform, "get_java_skip_reason", lambda: java_reason) + monkeypatch.setattr(sys, "platform", "linux") + + +def test_pyspark_not_installed(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_pyspark(monkeypatch, available=False) + assert optional_engines.pyspark_skip_reason() == "PySpark not installed" + + +def test_pyspark_unsupported_java_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_pyspark(monkeypatch, java_reason="PySpark 4.x not compatible with Java 26") + monkeypatch.setenv("PYSPARK_PYTHON", sys.executable) + assert optional_engines.pyspark_skip_reason() == "PySpark 4.x not compatible with Java 26" + assert not optional_engines.pyspark_usable() + + +def test_pyspark_usable_with_supported_java(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_pyspark(monkeypatch) + monkeypatch.setenv("PYSPARK_PYTHON", sys.executable) + assert optional_engines.pyspark_skip_reason() is None + + +def test_pyspark_windows_is_skipped_with_a_reason(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_pyspark(monkeypatch) + monkeypatch.setattr(sys, "platform", "win32") + assert "Windows" in (optional_engines.pyspark_skip_reason() or "") + + +def test_pyspark_probe_does_not_set_the_worker_interpreter(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_pyspark(monkeypatch) + monkeypatch.delenv("PYSPARK_PYTHON", raising=False) + optional_engines.pyspark_skip_reason() + assert "PYSPARK_PYTHON" not in os.environ + + +def test_pyspark_worker_setting_is_not_overridden(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_pyspark(monkeypatch) + monkeypatch.setenv("PYSPARK_PYTHON", "/custom/python") + optional_engines.pyspark_skip_reason() + assert os.environ["PYSPARK_PYTHON"] == "/custom/python" + + +def test_require_pyspark_skips_with_the_reason(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_pyspark(monkeypatch, java_reason="Java not found") + with pytest.raises(pytest.skip.Exception, match="Java not found"): + optional_engines.require_pyspark() + + +def _set_environment(monkeypatch, key, value): + if value is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, value) + + +@pytest.mark.parametrize("original", [None, "", "/original/java"]) +@pytest.mark.parametrize("failure", [False, True]) +def test_pyspark_probe_restores_java_even_when_selection_fails(monkeypatch, original, failure): + import benchbox.platforms.pyspark as platform + + _patch_pyspark(monkeypatch) + _set_environment(monkeypatch, "JAVA_HOME", original) + calls = [] + + def select_java(): + calls.append(True) + os.environ["JAVA_HOME"] = "/compatible/java" + if failure: + raise RuntimeError("Java inspection failed") + return "" + + monkeypatch.setattr(platform, "get_java_skip_reason", select_java) + if failure: + with pytest.raises(RuntimeError, match="inspection failed"): + optional_engines.pyspark_skip_reason() + else: + assert optional_engines.pyspark_skip_reason() is None + assert optional_engines.pyspark_skip_reason() is None + assert len(calls) == 1 + assert os.environ.get("JAVA_HOME") == original + + +@pytest.mark.parametrize("java_home", [None, "", "/original/java"]) +@pytest.mark.parametrize("worker", [None, "", "/custom/python"]) +def test_spark_fixture_scopes_java_and_worker_settings(monkeypatch, java_home, worker): + import benchbox.platforms.pyspark as platform + + _patch_pyspark(monkeypatch) + _set_environment(monkeypatch, "JAVA_HOME", java_home) + _set_environment(monkeypatch, "PYSPARK_PYTHON", worker) + + def select_java(): + os.environ["JAVA_HOME"] = "/compatible/java" + return "" + + monkeypatch.setattr(platform, "get_java_skip_reason", select_java) + fixture = pyspark_test_environment.__wrapped__() + try: + next(fixture) + assert os.environ["JAVA_HOME"] == "/compatible/java" + assert os.environ["PYSPARK_PYTHON"] == (sys.executable if worker is None else worker) + finally: + fixture.close() + assert os.environ.get("JAVA_HOME") == java_home + assert os.environ.get("PYSPARK_PYTHON") == worker + + +@pytest.mark.parametrize("failure", ["unsupported", "exception", "test-body"]) +def test_spark_fixture_restores_environment_on_skip_or_failure(monkeypatch, failure): + import benchbox.platforms.pyspark as platform + + _patch_pyspark(monkeypatch) + monkeypatch.delenv("JAVA_HOME", raising=False) + monkeypatch.delenv("PYSPARK_PYTHON", raising=False) + # A previously cached successful probe does not own the later live selection. + assert optional_engines.pyspark_skip_reason() is None + + def select_java(): + os.environ["JAVA_HOME"] = "/selected/java" + if failure == "exception": + raise RuntimeError("selector failed") + return "Java not found" if failure == "unsupported" else "" + + monkeypatch.setattr(platform, "get_java_skip_reason", select_java) + fixture = pyspark_test_environment.__wrapped__() + try: + if failure == "unsupported": + with pytest.raises(pytest.skip.Exception, match="Java not found"): + next(fixture) + elif failure == "exception": + with pytest.raises(RuntimeError, match="selector failed"): + next(fixture) + else: + next(fixture) + with pytest.raises(RuntimeError, match="test failed"): + fixture.throw(RuntimeError("test failed")) + finally: + fixture.close() + assert "JAVA_HOME" not in os.environ + assert "PYSPARK_PYTHON" not in os.environ + + +def test_importing_optional_helpers_does_not_load_engines_or_change_environment(): + code = textwrap.dedent(""" + import importlib.abc + import os + import sys + + class BlockEngines(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in {'chdb', 'pyspark'}: + raise AssertionError('engine imported: ' + fullname) + + sys.meta_path.insert(0, BlockEngines()) + before = dict(os.environ) + cwd = os.getcwd() + import tests.utilities.optional_engines + import tests.fixtures.utility_fixtures + assert dict(os.environ) == before + assert os.getcwd() == cwd + assert 'chdb' not in sys.modules + assert 'pyspark' not in sys.modules + """) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/unit/test_pr_preflight_medium_tier.py b/tests/unit/test_pr_preflight_medium_tier.py deleted file mode 100644 index b41fcb6dd4..0000000000 --- a/tests/unit/test_pr_preflight_medium_tier.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Tests for the receipt-bound medium-tier preflight stage (ci-dedupe-05). - -`make pr-preflight-medium-tests` runs the exact CI medium selection -(`make test-medium`, no local-only subset) through local_validation's -medium-tier gate when the path classifier says the diff needs code CI, -and skips otherwise. Failure propagates: the stage fails pr-preflight -and leaves no receipt behind. -""" - -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path - -import pytest - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -ROOT = Path(__file__).resolve().parents[2] -MAKEFILE = ROOT / "Makefile" -CLASSIFIER = [sys.executable, str(ROOT / "scripts" / "path_filter_decision.py")] -LOCAL_VALIDATION = [sys.executable, str(ROOT / "scripts" / "local_validation.py")] - -GATE = "medium-tier" - - -def _decision(tmp_path: Path, *, code: bool, skill_only: bool = False) -> Path: - payload = { - "skill_integrity_needed": skill_only, - "content_guard_needed": not code and not skill_only, - "skill_integrity_only": skill_only, - "needs_code_ci": code, - } - path = tmp_path / f"decision-{code}-{skill_only}.json" - path.write_text(json.dumps(payload), encoding="utf-8") - return path - - -def _check(decision: Path) -> int: - return subprocess.run( - [*CLASSIFIER, "--json-in", str(decision), "--check", "needs-code-ci"], - capture_output=True, - text=True, - ).returncode - - -def test_code_routed_diff_needs_medium_tier(tmp_path: Path) -> None: - assert _check(_decision(tmp_path, code=True)) == 0 - - -def test_content_only_diff_skips_medium_tier(tmp_path: Path) -> None: - assert _check(_decision(tmp_path, code=False)) == 1 - - -def test_skill_only_diff_skips_medium_tier(tmp_path: Path) -> None: - assert _check(_decision(tmp_path, code=False, skill_only=True)) == 1 - - -def _makefile_target_body(target_name: str) -> str: - lines = MAKEFILE.read_text(encoding="utf-8").splitlines(keepends=True) - start = next( - (index for index, line in enumerate(lines) if line.split(":", 1)[0] == target_name), - None, - ) - assert start is not None, f"Makefile target not found: {target_name}" - body: list[str] = [] - for line in lines[start + 1 :]: - if line.strip() and not line.startswith(("\t", " ")): - break - body.append(line) - return "".join(body) - - -def test_medium_stage_uses_classifier_and_receipt_bound_ci_selection() -> None: - body = _makefile_target_body("pr-preflight-medium-tests") - assert "scripts/path_filter_decision.py" in body - assert "--check needs-code-ci" in body - assert "local-validation GATE=medium-tier" in body - # Same marker selection as CI: the target must invoke make test-medium, - # never its own pytest subset. - assert 'CMD="make test-medium"' in body - assert "pytest -m" not in body - - -def test_medium_stage_scrubs_control_vars_from_gate_env() -> None: - # GNU make exports command-line variables to recipe environments and - # smuggles their assignments inside MAKEFLAGS. A leaked PATH_DECISION - # makes test-spawned makes take the caller-supplied branch with no - # lists dir ("PATH_LISTS is required"); a leaked SKIP_FAST_TESTS flips - # the route into its skip branch inside the suite. - body = _makefile_target_body("pr-preflight-medium-tests") - assert "env -u PATH_DECISION" in body - assert "-u PATH_LISTS" in body - assert "-u SKIP_FAST_TESTS" in body - assert "MAKEFLAGS=" in body - - -def test_medium_stage_failure_is_not_swallowed() -> None: - body = _makefile_target_body("pr-preflight-medium-tests") - assert "set -eu" in body - for mask in ("|| true", "|| exit 0", "|| true;"): - assert mask not in body - assert "\n\t-" not in body - - -def test_required_preflight_runs_medium_stage() -> None: - body = _makefile_target_body("pr-preflight-uncached") - assert "pr-preflight-medium-tests" in body - - -def test_failing_gate_command_fails_and_leaves_no_receipt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - receipts = tmp_path / "receipts" - monkeypatch.setenv("BENCHBOX_VALIDATION_RECEIPTS_DIR", str(receipts)) - gate = "probe-failing-medium-tier" - proc = subprocess.run( - [*LOCAL_VALIDATION, "run", "--gate", gate, "--", sys.executable, "-c", "raise SystemExit(3)"], - capture_output=True, - text=True, - cwd=tmp_path, - ) - assert proc.returncode != 0 - stored = list(receipts.rglob("*")) if receipts.exists() else [] - assert [path for path in stored if path.is_file() and gate in path.name] == [] - - -def test_skip_paths_exit_zero_without_running_tests(tmp_path: Path) -> None: - for name, decision in ( - ("content", _decision(tmp_path, code=False)), - ("skill", _decision(tmp_path, code=False, skill_only=True)), - ): - proc = subprocess.run( - ["make", "-s", "pr-preflight-medium-tests", f"PATH_DECISION={decision}"], - capture_output=True, - text=True, - cwd=ROOT, - ) - assert proc.returncode == 0, f"{name} skip path failed: {proc.stderr[-2000:]}" - assert "skipping medium tier" in proc.stdout diff --git a/tests/unit/test_reference_seed.py b/tests/unit/test_reference_seed.py index 123ed44467..c9732e225a 100644 --- a/tests/unit/test_reference_seed.py +++ b/tests/unit/test_reference_seed.py @@ -1,22 +1,12 @@ -"""Tests for TPC-H reference seed and qgen defaults mode parity. - -Validates that: -1. The TPCH_SF1_REFERENCE_SEED constant matches the octal literal -2. qgen defaults mode (-d flag) produces parameters matching answer files -3. Seed-based mode produces different parameters (confirming -d is distinct) -4. TPCHPowerTestConfig defaults to seed=None (defaults mode) - -Copyright 2026 Joe Harris / BenchBox Project - -TPC Benchmark™ H (TPC-H) - Copyright © Transaction Processing Performance Council -This implementation is based on the TPC-H specification. - -Licensed under the MIT License. See LICENSE file in the project root for details. -""" - import pytest -from benchbox.core.tpch.benchmark import TPCH_SF1_REFERENCE_SEED +from benchbox.core.tpch import benchmark as tpch_benchmark +from benchbox.core.tpch.benchmark import ( + binds_answer_set_parameters, + describe_query_parameters, + has_answer_set, + power_stream_seed, +) from benchbox.core.tpch.power_test import TPCHPowerTestConfig pytestmark = [ @@ -26,17 +16,31 @@ @pytest.mark.unit -class TestReferenceSeedConstant: - """Test the TPCH_SF1_REFERENCE_SEED constant.""" +class TestAnswerSetParameters: + def test_only_no_seed_binds_answer_set(self): + assert binds_answer_set_parameters(None) is True + for seed in (0, 1, 42, 17039360, 101000000): + assert binds_answer_set_parameters(seed) is False - def test_reference_seed_matches_octal(self): - """Verify octal 0o0101000000 equals 17039360 decimal.""" - assert TPCH_SF1_REFERENCE_SEED == 17039360 - assert TPCH_SF1_REFERENCE_SEED == 0o0101000000 + def test_reference_seed_constant_is_gone(self): + assert not hasattr(tpch_benchmark, "TPCH_SF1_REFERENCE_SEED") + assert not hasattr(tpch_benchmark, "get_reference_seed") - def test_reference_seed_is_not_16843008(self): - """Ensure the old incorrect decimal value is not used.""" - assert TPCH_SF1_REFERENCE_SEED != 16843008 + def test_answer_set_exists_only_at_sf1(self): + assert has_answer_set(1.0) is True + assert has_answer_set(1) is True + assert has_answer_set(0.01) is False + assert has_answer_set(10.0) is False + + def test_power_stream_seed(self): + assert power_stream_seed(None, 0) is None + assert power_stream_seed(None, 3) is None + assert power_stream_seed(17039360, 0) == 17039360 + assert power_stream_seed(17039360, 1) == 17040360 + + def test_describe_query_parameters(self): + assert describe_query_parameters(None) == "qgen -d (TPC-H default substitution parameters)" + assert describe_query_parameters(7) == "qgen -r (7 + 1000 * stream_id)" @pytest.mark.unit @@ -53,9 +57,6 @@ def test_explicit_seed_preserved(self): config = TPCHPowerTestConfig(seed=42) assert config.seed == 42 - config_ref = TPCHPowerTestConfig(seed=TPCH_SF1_REFERENCE_SEED) - assert config_ref.seed == TPCH_SF1_REFERENCE_SEED - @pytest.mark.unit @pytest.mark.tpch @@ -100,3 +101,17 @@ def test_defaults_mode_q5_asia(self, qgen): """qgen -d for Q5 should produce ASIA region (matching answer files).""" sql = qgen.generate(query_id=5, seed=None, scale_factor=1.0) assert "ASIA" in sql.upper(), f"Expected ASIA in Q5 defaults output, got: {sql[:200]}" + + @pytest.mark.parametrize( + ("query_id", "answer_rows"), + [(11, 1048), (18, 57), (20, 186)], + ) + def test_answer_files_match_defaults_not_old_reference_seed(self, qgen, query_id, answer_rows): + from pathlib import Path + + answers = Path(__file__).resolve().parents[2] / "_sources/tpc-h/dbgen/answers" / f"q{query_id}.out" + if not answers.exists(): + pytest.skip("TPC-H answer files not available") + data_lines = [line for line in answers.read_text().splitlines()[1:] if line.strip()] + assert len(data_lines) == answer_rows + assert qgen.generate(query_id, seed=None) != qgen.generate(query_id, seed=17039360) diff --git a/tests/unit/test_release_canary_sharding.py b/tests/unit/test_release_canary_sharding.py index 65a5883611..cec5c5ac76 100644 --- a/tests/unit/test_release_canary_sharding.py +++ b/tests/unit/test_release_canary_sharding.py @@ -3,6 +3,7 @@ from __future__ import annotations import itertools +import json import re from pathlib import Path @@ -12,9 +13,12 @@ from scripts.release_canary_sharding import ( DEFAULT_SHARD_COUNT, MARKER_EXPRESSION, + MEDIUM_MARKER_EXPRESSION, + MEDIUM_SHARD_COUNT, collect_node_ids, parse_collection_output, partition_node_ids, + verify_medium_shards, write_shard, ) @@ -116,6 +120,131 @@ def test_collection_and_shard_manifests_conserve_node_ids(tmp_path: Path): assert sorted(assigned) == sorted(nodeids.read_text(encoding="utf-8").splitlines()) +def _medium_artifacts(root: Path, sha: str) -> list[str]: + collection = root / f"t2-medium-nodeids-{sha}" + collection.mkdir() + raw = collection / "raw.txt" + ids = [f"tests/unit/test_a.py::test_{index}" for index in range(6)] + raw.write_text("\n".join(ids) + "\n6/100 tests collected\n") + collect_node_ids( + raw, + collection / "medium-nodeids.txt", + collection / "medium-collection.json", + expected_count=6, + shard_count=MEDIUM_SHARD_COUNT, + checked_sha=sha, + workflow="ci.yml", + job="medium-collect", + marker_expression=MEDIUM_MARKER_EXPRESSION, + ) + for index in range(MEDIUM_SHARD_COUNT): + shard = root / f"t2-medium-shard-{index}-{sha}" + shard.mkdir() + write_shard( + collection / "medium-nodeids.txt", + shard / "assigned.txt", + shard / "summary.json", + shard_index=index, + shard_count=MEDIUM_SHARD_COUNT, + workflow="ci.yml", + job="medium-test", + marker_expression=MEDIUM_MARKER_EXPRESSION, + collection_summary=collection / "medium-collection.json", + checked_sha=sha, + ) + assigned = partition_node_ids(ids, index, MEDIUM_SHARD_COUNT) + (shard / f"shard-{index}-execution.json").write_text( + json.dumps( + { + "commit_sha": sha, + "complete": True, + "pytest_exit_status": 0, + "assigned_node_ids": assigned, + "executed_node_ids": assigned, + "collected_node_ids": [assigned, assigned], + "node_outcomes": [ + { + "node_id": node_id, + "reports": [ + {"phase": phase, "outcome": "passed", "skip_reason": None, "xfail_reason": None} + for phase in ("setup", "call", "teardown") + ], + } + for node_id in assigned + ], + } + ) + ) + return ids + + +def test_medium_artifact_union_covers_the_exact_collection(tmp_path: Path) -> None: + _medium_artifacts(tmp_path, "a" * 40) + verify_medium_shards(tmp_path, "a" * 40) + + +@pytest.mark.parametrize( + "defect", ["missing", "wrong-sha", "failure", "incomplete", "deselected", "duplicated", "wrong-collection"] +) +def test_medium_shard_evidence_refuses_missing_or_changed_coverage(tmp_path: Path, defect: str) -> None: + sha = "a" * 40 + _medium_artifacts(tmp_path, sha) + path = tmp_path / f"t2-medium-shard-1-{sha}" / "shard-1-execution.json" + data = json.loads(path.read_text()) + if defect == "missing": + path.unlink() + else: + if defect == "wrong-sha": + data["commit_sha"] = "b" * 40 + elif defect == "failure": + data["pytest_exit_status"] = 1 + elif defect == "incomplete": + data["complete"] = False + elif defect == "deselected": + data["executed_node_ids"].pop() + elif defect == "duplicated": + data["executed_node_ids"].append(data["executed_node_ids"][0]) + else: + data["collected_node_ids"][1].pop() + path.write_text(json.dumps(data)) + with pytest.raises((ValueError, FileNotFoundError)): + verify_medium_shards(tmp_path, sha) + + +@pytest.mark.parametrize( + "field,value", + [ + ("commit_sha", "b" * 40), + ("node_ids_sha256", "wrong"), + ("shard_count", 3), + ("total_count", 5), + ("marker_expression", MARKER_EXPRESSION), + ("workflow", "other.yml"), + ], +) +def test_medium_partition_refuses_a_changed_collection(tmp_path: Path, field: str, value: object) -> None: + sha = "a" * 40 + _medium_artifacts(tmp_path, sha) + collection = tmp_path / f"t2-medium-nodeids-{sha}" + summary = collection / "medium-collection.json" + data = json.loads(summary.read_text()) + data[field] = value + summary.write_text(json.dumps(data)) + with pytest.raises(ValueError, match="does not match"): + write_shard( + collection / "medium-nodeids.txt", + tmp_path / "ids", + tmp_path / "summary", + shard_index=0, + shard_count=MEDIUM_SHARD_COUNT, + workflow="ci.yml", + job="medium-test", + marker_expression=MEDIUM_MARKER_EXPRESSION, + collection_summary=summary, + checked_sha=sha, + ) + + def test_release_canary_workflow_uses_collection_artifact_and_six_single_threaded_shards(): workflow = _workflow() jobs = workflow["jobs"] @@ -153,6 +282,47 @@ def test_release_canary_workflow_uses_collection_artifact_and_six_single_threade assert "release_canary_sharding.py" in shard_text assert "mapfile -t node_ids" in shard_text assert "-n 0" in shard_text + assert "--maxfail=5" in shard_text + # Every shard publishes per-test durations and setup/test timing without + # changing selection, sharding, or the pass/fail signal. + assert '--junitxml="$junit_xml"' in shard_text + assert "shard-${SHARD_INDEX}-junit.xml" in shard_text + assert ".canary-setup-start-mono" in shard_text + assert ".canary-tests-start-mono" in shard_text + assert ".canary-tests-end-mono" in shard_text + assert '"setup_seconds": "${setup_seconds}"' in shard_text + assert '"test_seconds": "${test_seconds}"' in shard_text + assert '"junit_present": ${junit_present}' in shard_text + # Durations are monotonic, so an NTP correction during the job cannot + # publish a skewed or negative setup_seconds or test_seconds. These stamps + # are read from the kernel rather than from benchbox.utils.clock because + # the setup stamp is taken before Python and uv are installed. + assert "/proc/uptime" in shard_text + assert "date +%s" not in shard_text + # GitHub runs `shell: bash` under errexit. Without `set +e` a failing pytest + # aborts the step at the pipeline and skips the exit-code capture, the end + # stamp, the JUnit fallback, and the capture marker. + assert "set +e" in shard_text + # A missing JUnit file falls back to a marked empty report; the exit code + # still comes only from pytest (or the log tee). The fallback report is + # present but holds no durations, so the summary reports capture state + # separately: junit_captured is set false on the fallback path, carried + # between steps on a marker file, and re-checked against "true" before it + # is published. A consumer sizing shards must never read junit_present + # alone as evidence that per-test durations exist. + assert 'junit_captured="true"' in shard_text + assert 'junit_captured="false"' in shard_text + assert ".canary-junit-captured" in shard_text + assert '"junit_captured": ${junit_captured}' in shard_text + assert '[ "$junit_captured" != "true" ]' in shard_text + assert 'name="junit_captured" value="false"' in shard_text + # pytest exits 5 when it collected nothing and still writes a well-formed, + # non-empty report with no in it. A size check alone would + # publish that as a capture, so capture state also requires a real + # testcase. pytest escapes captured output, so this cannot be forged by a + # test that prints the tag. + assert 'grep -q " None: + assert (REPO_ROOT / "pyproject.toml").is_file() + assert (REPO_ROOT / "tests" / "utilities" / "paths.py").is_file() + + +def test_paths_module_is_cwd_independent_and_import_free(tmp_path) -> None: + script = ( + "import runpy, sys\n" + f"ns = runpy.run_path({str(REPO_ROOT / 'tests' / 'utilities' / 'paths.py')!r})\n" + "print(ns['REPO_ROOT'])\n" + "print(sorted(m for m in sys.modules if m.split('.')[0] in {'benchbox', 'tests'}))\n" + ) + result = subprocess.run( + [sys.executable, "-I", "-c", script], cwd=tmp_path, capture_output=True, text=True, check=True, timeout=30 + ) + root_line, modules_line = result.stdout.splitlines() + assert root_line == str(REPO_ROOT) + assert modules_line == "[]" + + +def test_normal_paths_import_does_not_load_native_helpers(tmp_path) -> None: + script = ( + "import sys\n" + f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" + "from tests.utilities.paths import REPO_ROOT\n" + "assert 'tests.utilities.test_helpers' not in sys.modules\n" + "assert 'duckdb' not in sys.modules\n" + "assert not any(m.startswith('benchbox') for m in sys.modules)\n" + "print(REPO_ROOT)\n" + ) + result = subprocess.run( + [sys.executable, "-I", "-c", script], cwd=tmp_path, capture_output=True, text=True, check=True, timeout=30 + ) + assert result.stdout.strip() == str(REPO_ROOT) + + +def test_lazy_public_helpers_preserve_package_api() -> None: + import tests.utilities as utilities + from tests.utilities import test_helpers + + for name in utilities.__all__: + assert getattr(utilities, name) is getattr(test_helpers, name) + with pytest.raises(AttributeError, match="has no attribute"): + _ = utilities.unknown_helper diff --git a/tests/unit/test_required_local_cases.py b/tests/unit/test_required_local_cases.py new file mode 100644 index 0000000000..e952c0b6ed --- /dev/null +++ b/tests/unit/test_required_local_cases.py @@ -0,0 +1,253 @@ +"""Conservation and value-proof checks for the required local-engine cases. + +Copyright 2026 Joe Harris / BenchBox Project + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +from __future__ import annotations + +import subprocess +import sys +from collections import Counter +from decimal import Decimal +from pathlib import Path + +import pytest + +from tests import required_local_cases as required + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +MATRIX = required.MATRIX_MODULE +ROOT = Path(__file__).resolve().parents[2] + + +def test_inventory_is_exact_original_four_plus_sqlite() -> None: + assert ( + f"{MATRIX}::test_local_platform_benchmark_matrix[tpch-duckdb]", + f"{MATRIX}::test_local_platform_benchmark_matrix[tpcds-duckdb]", + f"{MATRIX}::test_local_platform_benchmark_matrix[tpch-datafusion]", + f"{MATRIX}::test_local_dataframe_platform_benchmark_matrix[tpch-polars-df]", + f"{MATRIX}::test_sqlite_tpch_fixed_seed_value_parity", + ) == required.REQUIRED_LOCAL_CASES + required.check_inventory(required.REQUIRED_LOCAL_CASES) + + +def test_every_required_case_is_a_node_pytest_collects() -> None: + """The inventory is compared with pytest's own collection, not with a copy of itself.""" + result = subprocess.run( + [sys.executable, "-m", "pytest", "--collect-only", "-q", "-o", "addopts=", "-p", "no:cacheprovider", MATRIX], + cwd=ROOT, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + required.check_collected(line.strip() for line in result.stdout.splitlines() if "::" in line) + + +@pytest.mark.parametrize( + "mutation", + [ + lambda nodes: [node for node in nodes if "[tpch-duckdb]" not in node], + lambda nodes: [node.replace("[tpch-polars-df]", "[tpch-polars]") for node in nodes], + lambda nodes: [ + node.replace("::test_sqlite_tpch_fixed_seed_value_parity", "::test_sqlite_renamed") for node in nodes + ], + ], + ids=["removed", "reparametrized", "renamed"], +) +def test_a_removed_renamed_or_reparametrized_case_is_rejected(mutation) -> None: + required.check_collected(required.REQUIRED_LOCAL_CASES) + with pytest.raises(required.RequiredCaseError, match="not collected"): + required.check_collected(mutation(required.REQUIRED_LOCAL_CASES)) + + +@pytest.mark.parametrize("compressed", [False, True], ids=["tbl", "tbl-zst"]) +def test_duckdb_reference_loader_accepts_apostrophe_path(tmp_path: Path, compressed: bool) -> None: + import zstandard + + from benchbox.core.tpch.schema import TABLES, DataType + from tests.integration import test_local_platform_benchmark_matrix as matrix + + datagen_dir = tmp_path / "case's data" / "datagen" + datagen_dir.mkdir(parents=True) + values = { + DataType.INTEGER: "1", + DataType.DECIMAL: "1.25", + DataType.CHAR: "A", + DataType.VARCHAR: "reference row", + DataType.DATE: "1997-06-01", + } + for table in TABLES: + row = ("|".join(values[column.data_type] for column in table.columns) + "|\n").encode() + suffix = ".tbl.zst" if compressed else ".tbl" + if compressed: + row = zstandard.ZstdCompressor().compress(row) + (datagen_dir / f"{table.name}{suffix}").write_bytes(row) + + connection = matrix._load_duckdb_from_generated_files(datagen_dir) + try: + counts = { + table.name: connection.execute(f"SELECT COUNT(*) FROM {table.name}").fetchone()[0] for table in TABLES + } + assert counts == dict.fromkeys(required.TPCH_TABLE_NAMES, 1) + assert connection.execute("SELECT l_extendedprice, l_discount FROM lineitem").fetchall() == [ + (Decimal("1.25"), Decimal("1.25")) + ] + assert connection.execute("SELECT r_comment FROM region").fetchall() == [("reference row",)] + finally: + connection.close() + + +def test_sqlite_case_rejects_checkout_owned_output(monkeypatch: pytest.MonkeyPatch) -> None: + from tests.integration import test_local_platform_benchmark_matrix as matrix + + monkeypatch.setattr(matrix, "is_platform_available", lambda platform: True) + checkout = Path(matrix.__file__).resolve().parents[2] + with pytest.raises(AssertionError, match="outside the checkout"): + matrix.test_sqlite_tpch_fixed_seed_value_parity(checkout) + + +def test_sqlite_case_pins_workload_and_owns_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from tests.integration import test_local_platform_benchmark_matrix as matrix + + class CapturedCommand(Exception): + pass + + captured: dict = {} + + def capture(args, **kwargs): + captured.update(args=args, **kwargs) + raise CapturedCommand + + monkeypatch.setattr(matrix, "is_platform_available", lambda platform: True) + monkeypatch.setattr(matrix, "run_cli_command", capture) + monkeypatch.setenv("BENCHBOX_OUTPUT_DIR", str(tmp_path / "unrelated")) + with pytest.raises(CapturedCommand): + matrix.test_sqlite_tpch_fixed_seed_value_parity(tmp_path) + args = captured["args"] + for flag, value in { + "--platform": "sqlite", + "--benchmark": "tpch", + "--scale": "0.01", + "--phases": "generate,load,power", + "--queries": "1,6,14", + "--seed": "42", + "--iterations": "3", + }.items(): + assert args[args.index(flag) + 1] == value + case_dir = tmp_path / "sqlite_tpch_value_parity" + assert captured["cwd"] == case_dir + assert captured["env"]["BENCHBOX_OUTPUT_DIR"] == str(case_dir / "benchmark_runs") + assert not (case_dir / "benchmark_runs").exists() + + +@pytest.mark.parametrize( + "mutate", + [ + pytest.param(lambda nodes: nodes[:-1], id="missing"), + pytest.param(lambda nodes: [*nodes, f"{MATRIX}::test_extra"], id="extra"), + pytest.param(lambda nodes: [*nodes, nodes[0]], id="duplicate"), + pytest.param(lambda nodes: [n.replace("tpch-duckdb", "tpch-sqlite") for n in nodes], id="substituted"), + pytest.param(lambda nodes: [], id="empty"), + ], +) +def test_inventory_rejects_non_conserving_sets(mutate) -> None: + with pytest.raises(required.RequiredCaseError): + required.check_inventory(mutate(list(required.REQUIRED_LOCAL_CASES))) + + +def _measurements(ids: list[str], status: str = "SUCCESS") -> list[dict]: + return [{"id": query_id, "run_type": "measurement", "status": status} for query_id in ids] + + +def test_expected_multiset_is_three_iterations_of_q1_q6_q14() -> None: + assert required.expected_measurement_multiset() == Counter({"1": 3, "6": 3, "14": 3}) + + +def test_measurement_multiset_accepts_exact_and_ignores_warmup() -> None: + queries = [{"id": "1", "run_type": "warmup", "status": "SUCCESS"}, *_measurements(["1", "6", "14"] * 3)] + required.check_measurement_multiset(queries, required.expected_measurement_multiset()) + + +@pytest.mark.parametrize( + "ids", + [ + pytest.param(["1", "6", "14"] * 2, id="missing-iteration"), + pytest.param(["1", "6", "14"] * 3 + ["6"], id="extra-duplicate"), + pytest.param(["1", "1", "6", "14", "6", "14", "1", "6", "6"], id="same-size-wrong-mix"), + pytest.param(["1", "6", "15"] * 3, id="wrong-id"), + pytest.param([], id="empty"), + ], +) +def test_measurement_multiset_rejects_non_conserving_runs(ids: list[str]) -> None: + with pytest.raises(required.RequiredCaseError): + required.check_measurement_multiset(_measurements(ids), required.expected_measurement_multiset()) + + +def test_measurement_multiset_rejects_failed_row_even_when_count_restored() -> None: + queries = [*_measurements(["1", "6", "14"] * 3), *_measurements(["6"], status="FAILED")] + with pytest.raises(required.RequiredCaseError, match="non-successful"): + required.check_measurement_multiset(queries, required.expected_measurement_multiset()) + + +def test_population_covers_all_eight_authoritative_tables() -> None: + assert sorted(required.TPCH_TABLE_NAMES) == sorted( + ["region", "nation", "supplier", "part", "partsupp", "customer", "orders", "lineitem"] + ) + required.check_tables_populated(dict.fromkeys(required.TPCH_TABLE_NAMES, 5)) + + +@pytest.mark.parametrize("table", ["region", "lineitem", "partsupp"]) +def test_population_rejects_empty_table(table: str) -> None: + counts = dict.fromkeys(required.TPCH_TABLE_NAMES, 5) + counts[table] = 0 + with pytest.raises(required.RequiredCaseError, match=table): + required.check_tables_populated(counts) + + +def test_population_rejects_missing_table() -> None: + counts = dict.fromkeys(required.TPCH_TABLE_NAMES, 5) + del counts["nation"] + with pytest.raises(required.RequiredCaseError, match="nation"): + required.check_tables_populated(counts) + + +REFERENCE = [("A", "F", Decimal("37734107.00"), 56586554400.73), ("N", "O", Decimal("991417.00"), 1487504710.38)] + + +def test_rows_match_accepts_reordered_and_decimal_vs_float_within_tolerance() -> None: + actual = [("N", "O", 991417.0, 1487504710.38), ("A", "F", 37734107.0, 56586554400.73 * (1 + 1e-12))] + required.check_rows_match(REFERENCE, actual, "1") + + +def test_rows_match_rejects_same_cardinality_wrong_value() -> None: + actual = [REFERENCE[0], ("N", "O", Decimal("991417.00"), 1487504710.38 * (1 + 1e-6))] + with pytest.raises(required.RequiredCaseError, match="Q1"): + required.check_rows_match(REFERENCE, actual, "1") + + +def test_rows_match_rejects_q6_boundary_exclusion_shape() -> None: + # A single-row aggregate whose predicate lost boundary rows has the same + # cardinality but a smaller value. + with pytest.raises(required.RequiredCaseError, match="Q6"): + required.check_rows_match([(1272913.9338,)], [(771253.5606,)], "6") + + +def test_rows_match_rejects_wrong_string_and_row_count() -> None: + with pytest.raises(required.RequiredCaseError): + required.check_rows_match(REFERENCE, [("A", "X", *REFERENCE[0][2:]), REFERENCE[1]], "1") + with pytest.raises(required.RequiredCaseError): + required.check_rows_match(REFERENCE, REFERENCE[:1], "1") + + +def test_rows_match_rejects_vacuous_empty_reference() -> None: + with pytest.raises(required.RequiredCaseError, match="vacuous"): + required.check_rows_match([], [], "14") + + +def test_tolerance_is_pinned() -> None: + assert required.VALUE_TOLERANCE == 1e-10 diff --git a/tests/unit/test_session_isolation.py b/tests/unit/test_session_isolation.py new file mode 100644 index 0000000000..53be2b1d49 --- /dev/null +++ b/tests/unit/test_session_isolation.py @@ -0,0 +1,286 @@ +"""Prove HOME isolation covers collection without bypassing the shared lock.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import click +import pytest +from click.testing import CliRunner + +from scripts.local_validation import _close_lock, wait_for_lock, write_holder +from tests.utilities.paths import REPO_ROOT + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +def test_unit_home_is_owned_and_empty(_unit_home: Path) -> None: + assert Path.home() == _unit_home + assert os.environ["USERPROFILE"] == str(_unit_home) + assert not (_unit_home / ".benchbox").exists() + + +def test_cli_invocation_owns_quiet_and_provider_state() -> None: + import benchbox.utils.config_interface as ci + import benchbox.utils.printing as printing + + before = (printing._QUIET, ci._config_provider) + installed = object() + + @click.command() + def command(): + printing.set_quiet(True) + ci._config_provider = installed # type: ignore[assignment] + assert printing.is_quiet() + assert ci._config_provider is installed + raise click.ClickException("intentional failure") + + result = CliRunner().invoke(command) + assert result.exit_code == 1 + assert (printing._QUIET, ci._config_provider) == before + + +@pytest.mark.parametrize("collect_only", [False, True]) +def test_home_is_isolated_before_conftest_and_collection(tmp_path: Path, collect_only: bool) -> None: + real_home = tmp_path / "caller-home" + real_config = real_home / ".benchbox" / "config.yaml" + real_config.parent.mkdir(parents=True) + poison = "poisoned-real-home-config\n" + real_config.write_text(poison, encoding="utf-8") + (tmp_path / "pytest.ini").write_text("[pytest]\n", encoding="utf-8") + (tmp_path / "conftest.py").write_text( + "import os\nfrom pathlib import Path\n" + 'assert str(Path.home()) != os.environ["PROBE_CALLER_HOME"]\n' + 'assert not (Path.home() / ".benchbox" / "config.yaml").exists()\n' + 'assert os.environ["USERPROFILE"] == str(Path.home())\n', + encoding="utf-8", + ) + (tmp_path / "test_probe.py").write_text( + "from pathlib import Path\n" + 'assert not (Path.home() / ".benchbox").exists()\n' + "def test_home():\n" + ' (Path.home() / ".benchbox").mkdir()\n' + ' (Path.home() / ".benchbox" / "config.yaml").write_text("owned")\n', + encoding="utf-8", + ) + env = dict( + os.environ, + HOME=str(real_home), + USERPROFILE=str(real_home), + PROBE_CALLER_HOME=str(real_home), + PYTHONPATH=str(REPO_ROOT), + ) + command = [ + sys.executable, + "-m", + "pytest", + "-c", + str(tmp_path / "pytest.ini"), + "--confcutdir", + str(tmp_path), + "-p", + "_benchbox_pytest_xdist_safety", + "-n", + "0", + "-q", + "test_probe.py", + ] + if collect_only: + command.append("--collect-only") + result = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stdout + result.stderr + assert real_config.read_text(encoding="utf-8") == poison + + +def test_contended_lock_prevents_home_setup(tmp_path: Path) -> None: + lock_path = tmp_path / "lock" / "test.lock" + fd = wait_for_lock(lock_path, 0) + write_holder(fd, lock_path, phase="test", gate="isolation-negative") + env = dict( + os.environ, + PYTHONPATH=str(REPO_ROOT), + BENCHBOX_TEST_LOCK_DIR=str(lock_path.parent), + BENCHBOX_TEST_LOCK_WAIT_SECONDS="0", + ) + env.pop("BENCHBOX_TEST_SESSION_OWNER", None) + script = ( + "import os\nfrom tests.utilities import session_isolation as isolation\n" + "before = dict(os.environ)\n" + 'def forbidden(*a, **k):\n raise AssertionError("HOME created before lock")\n' + "isolation.tempfile.TemporaryDirectory = forbidden\n" + "try:\n isolation.start()\n" + "except TimeoutError:\n assert dict(os.environ) == before\n" + 'else:\n raise AssertionError("contended lock bypassed")\n' + ) + try: + result = subprocess.run([sys.executable, "-c", script], env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert f"pid:{os.getpid()} " in lock_path.read_text(encoding="utf-8") + finally: + _close_lock(fd) + + +def test_session_finish_restores_caller_environment(tmp_path: Path) -> None: + env = dict(os.environ, PYTHONPATH=str(REPO_ROOT)) + script = ( + "import os\nfrom pathlib import Path\nfrom tests.utilities import session_isolation as isolation\n" + "before = dict(os.environ)\n" + "isolation.start()\nowned_home = Path.home()\n" + 'assert owned_home.is_dir()\nassert str(owned_home) != before["HOME"]\n' + "isolation.finish()\nisolation.finish()\n" + "assert dict(os.environ) == before\nassert not owned_home.exists()\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30 + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def _run_session_probe( + tmp_path: Path, + test_source: str, + *pytest_args: str, + extra_env: dict[str, str] | None = None, + lock_held: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run a probe module under the early plugin, optionally while this process holds the shared lock.""" + caller_home = tmp_path / "caller-home" + caller_home.mkdir() + (tmp_path / "pytest.ini").write_text("[pytest]\nmarkers =\n live_integration: live\n", encoding="utf-8") + (tmp_path / "test_probe.py").write_text(test_source, encoding="utf-8") + lock_path = tmp_path / "lock" / "test.lock" + fd = wait_for_lock(lock_path, 0) if lock_held else None + if fd is not None: + write_holder(fd, lock_path, phase="test", gate="isolation-negative") + env = dict( + os.environ, + HOME=str(caller_home), + USERPROFILE=str(caller_home), + PROBE_CALLER_HOME=str(caller_home), + PYTHONPATH=str(REPO_ROOT), + BENCHBOX_TEST_LOCK_DIR=str(lock_path.parent), + BENCHBOX_TEST_LOCK_WAIT_SECONDS="0", + **(extra_env or {}), + ) + for inherited in ( + "BENCHBOX_TEST_SESSION_OWNER", + "BENCHBOX_SKIP_TEST_LOCK", + *[k for k in env if k.startswith("PYTEST_XDIST_")], + ): + env.pop(inherited, None) # the probe is a fresh controller, not a worker of this run + env.update(extra_env or {}) + try: + return subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-c", + str(tmp_path / "pytest.ini"), + "--confcutdir", + str(tmp_path), + "-p", + "_benchbox_pytest_xdist_safety", + "-p", + "no:cacheprovider", + "-q", + *pytest_args, + "test_probe.py", + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=120, + ) + finally: + if fd is not None: + _close_lock(fd) + + +HOME_PROBE = ( + "import os\nfrom pathlib import Path\n" + "def test_home():\n" + ' assert str(Path.home()) != os.environ["PROBE_CALLER_HOME"]\n' +) + + +def test_a_serial_run_does_not_wait_for_the_shared_lock(tmp_path: Path) -> None: + result = _run_session_probe(tmp_path, HOME_PROBE, "-n", "0", lock_held=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_a_parallel_run_still_waits_for_the_shared_lock(tmp_path: Path) -> None: + result = _run_session_probe(tmp_path, HOME_PROBE, "-n", "1", lock_held=True) + assert result.returncode != 0, result.stdout + result.stderr + assert "TimeoutError" in result.stdout + result.stderr or "lock" in (result.stdout + result.stderr).lower() + + +def test_the_documented_lock_bypass_is_honored_by_a_parallel_run(tmp_path: Path) -> None: + result = _run_session_probe( + tmp_path, HOME_PROBE, "-n", "1", extra_env={"BENCHBOX_SKIP_TEST_LOCK": "1"}, lock_held=True + ) + assert result.returncode == 0, result.stdout + result.stderr + + +LIVE_PROBE = ( + "import os\nimport pytest\nfrom pathlib import Path\n" + "@pytest.mark.live_integration\n" + "def test_live_keeps_the_caller_home():\n" + ' assert str(Path.home()) == os.environ["PROBE_CALLER_HOME"]\n' + "def test_other_is_isolated():\n" + ' assert str(Path.home()) != os.environ["PROBE_CALLER_HOME"]\n' +) + + +def test_a_run_that_selects_live_tests_keeps_the_callers_home(tmp_path: Path) -> None: + result = _run_session_probe(tmp_path, LIVE_PROBE, "-n", "0", "-m", "live_integration") + assert result.returncode == 0, result.stdout + result.stderr + assert "1 passed" in result.stdout + + +def test_a_run_that_deselects_live_tests_is_isolated(tmp_path: Path) -> None: + result = _run_session_probe(tmp_path, LIVE_PROBE, "-n", "0", "-m", "not live_integration") + assert result.returncode == 0, result.stdout + result.stderr + assert "1 passed" in result.stdout + + +class _EarlyConfig: + def __init__(self) -> None: + self.cleanups: list[object] = [] + + def add_cleanup(self, func: object) -> None: + self.cleanups.append(func) + + +@pytest.mark.parametrize( + ("worker", "args", "expected_lock"), + [ + (None, ["-n", "2"], True), + (None, ["-n", "0"], False), + ("gw0", ["-n", "2"], False), + ], + ids=["parallel-controller", "serial", "xdist-worker"], +) +def test_only_a_parallel_controller_takes_the_lock( + monkeypatch: pytest.MonkeyPatch, worker: str | None, args: list[str], expected_lock: bool +) -> None: + """A worker is covered by its controller's lock; on Windows it cannot verify that hold, so it must not try.""" + import _benchbox_pytest_xdist_safety as plugin + + calls: list[bool] = [] + monkeypatch.setattr( + plugin.session_isolation, "start", lambda acquire_lock=True: calls.append(acquire_lock) or False + ) + monkeypatch.delenv("BENCHBOX_SKIP_TEST_LOCK", raising=False) + if worker is None: + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + else: + monkeypatch.setenv("PYTEST_XDIST_WORKER", worker) + hook = plugin.pytest_load_initial_conftests(_EarlyConfig(), None, list(args)) + next(hook) + hook.close() + assert calls == [expected_lock] diff --git a/tests/unit/test_sqlglot_generator_nightly_contract.py b/tests/unit/test_sqlglot_generator_nightly_contract.py index 4f4ed0423a..c198fa2b62 100644 --- a/tests/unit/test_sqlglot_generator_nightly_contract.py +++ b/tests/unit/test_sqlglot_generator_nightly_contract.py @@ -3,12 +3,13 @@ from pathlib import Path import pytest +import yaml pytestmark = [pytest.mark.unit, pytest.mark.medium] REPO_ROOT = Path(__file__).resolve().parents[2] NIGHTLY = REPO_ROOT / ".github/workflows/nightly.yml" -PR_WORKFLOW = REPO_ROOT / ".github/workflows/pr.yml" +PR_WORKFLOW = REPO_ROOT / ".github/workflows/ci.yml" def _job_block(workflow: str, job: str, next_job: str) -> str: @@ -54,3 +55,14 @@ def test_nightly_pilot_is_bounded_and_uploads_replay_evidence() -> None: assert '--seed "${GITHUB_RUN_ID}"' in job assert "sqlglot-generator-summary.json" in job assert "sqlglot-generator-failure.json" in job + + +def test_ruleset_drift_runs_trusted_develop_code_with_the_token() -> None: + workflow = yaml.safe_load(NIGHTLY.read_text(encoding="utf-8")) + job = workflow["jobs"]["ruleset-drift"] + + assert "needs" not in job + assert job["continue-on-error"] is True + checkout = next(step for step in job["steps"] if str(step.get("uses", "")).startswith("actions/checkout@")) + assert checkout["with"]["ref"] == "develop" + assert checkout["with"]["persist-credentials"] is False diff --git a/tests/unit/test_standardized_test_commands.py b/tests/unit/test_standardized_test_commands.py index d33efa6446..af7a5e87e3 100644 --- a/tests/unit/test_standardized_test_commands.py +++ b/tests/unit/test_standardized_test_commands.py @@ -19,6 +19,7 @@ LOCAL_SQL_STABLE_MATRIX, _validate_against_expected_results, ) +from tests.utilities.paths import REPO_ROOT pytestmark = [ pytest.mark.unit, @@ -47,6 +48,10 @@ def _makefile_target_body(makefile_content: str, target_name: str) -> str: return "".join(body) +# Gates whose backends run as more than one Make target and CI step to stay within budget. +_SPLIT_CROSS_SURFACE_TARGETS = {"tpcds": ("tpcds-pandas-cross-surface-equivalence-report",)} + + def _cross_surface_make_target(gate_name: str) -> str: return f"{gate_name.replace('_', '-')}-cross-surface-equivalence-report" @@ -63,45 +68,6 @@ def _load_ini_section(path: Path, section: str) -> dict[str, str]: return dict(parser[section]) -def _run_skill_integrity_preflight_route( - tmp_path: Path, decision: dict[str, bool] -) -> tuple[subprocess.CompletedProcess[str], list[str]]: - decision_path = tmp_path / "decision.json" - decision_path.write_text(json.dumps(decision), encoding="utf-8") - lists_path = tmp_path / "lists" - lists_path.mkdir() - trace_path = tmp_path / "make-trace.txt" - fake_make = tmp_path / "fake-make" - fake_make.write_text('#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$PREFLIGHT_TRACE"\n', encoding="utf-8") - fake_make.chmod(0o755) - result = subprocess.run( - [ - "make", - "--no-print-directory", - ".pr-preflight-route", - f"PATH_DECISION={decision_path}", - f"PATH_LISTS={lists_path}", - f"MAKE={fake_make}", - ], - cwd=Path.cwd(), - capture_output=True, - text=True, - timeout=60, - env={**os.environ, "PREFLIGHT_TRACE": str(trace_path)}, - ) - calls = trace_path.read_text(encoding="utf-8").splitlines() if trace_path.exists() else [] - return result, calls - - -def _skill_preflight_decision(*, skill: bool, skill_only: bool, code: bool, content: bool = False) -> dict[str, bool]: - return { - "skill_integrity_needed": skill, - "content_guard_needed": content, - "skill_integrity_only": skill_only, - "needs_code_ci": code, - } - - def _marker_names(path: Path) -> set[str]: marker_lines = _load_ini_section(path, "pytest")["markers"].splitlines() return { @@ -114,58 +80,8 @@ def _marker_names(path: Path) -> set[str]: class TestStandardizedTestCommands: """Test the standardized test command system.""" - def test_pytest_marker_system_works(self): - env = {**subprocess.os.environ, "BENCHBOX_SKIP_TEST_LOCK": "1"} - result = subprocess.run( - [sys.executable, "-m", "pytest", "--collect-only", "-q"], - cwd=Path.cwd(), - capture_output=True, - text=True, - timeout=120, - env=env, - ) - - assert result.returncode in (0, 2), f"Unexpected exit code: {result.returncode}" - assert "test_" in result.stdout or "collected" in result.stdout.lower() - - def test_fast_marker_functionality(self): - env = {**subprocess.os.environ, "BENCHBOX_SKIP_TEST_LOCK": "1"} - result = subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "-m", - "fast and not (slow or stress or resource_heavy or live_integration)", - "--collect-only", - "-q", - ], - cwd=Path.cwd(), - capture_output=True, - text=True, - timeout=120, - env=env, - ) - - assert result.returncode in (0, 2), f"Unexpected exit code: {result.returncode}" - assert "test_" in result.stdout or "collected" in result.stdout.lower() - - def test_unit_marker_functionality(self): - env = {**subprocess.os.environ, "BENCHBOX_SKIP_TEST_LOCK": "1"} - result = subprocess.run( - [sys.executable, "-m", "pytest", "-m", "unit", "--collect-only", "-q"], - cwd=Path.cwd(), - capture_output=True, - text=True, - timeout=120, - env=env, - ) - - assert result.returncode in (0, 2), f"Unexpected exit code: {result.returncode}" - assert "test_" in result.stdout or "collected" in result.stdout.lower() - def test_makefile_commands_exist(self): - makefile_path = Path.cwd() / "Makefile" + makefile_path = Path(__file__).resolve().parent.parent.parent / "Makefile" assert makefile_path.exists(), "Makefile should exist" makefile_content = makefile_path.read_text() @@ -177,11 +93,11 @@ def test_makefile_commands_exist(self): assert "run_tests.py" not in makefile_content def test_no_legacy_run_tests_references(self): - run_tests_path = Path.cwd() / "run_tests.py" + run_tests_path = Path(__file__).resolve().parent.parent.parent / "run_tests.py" assert not run_tests_path.exists(), "run_tests.py should not exist in project root" def test_pytest_configuration_is_valid(self): - pytest_ini_path = Path.cwd() / "pytest.ini" + pytest_ini_path = Path(__file__).resolve().parent.parent.parent / "pytest.ini" assert pytest_ini_path.exists(), "pytest.ini should exist" pytest_ini_content = pytest_ini_path.read_text() @@ -192,7 +108,7 @@ def test_pytest_configuration_is_valid(self): assert "not slow and not stress and not live_integration and not resource_heavy" in pytest_ini_content def test_coverage_commands_use_pytest(self): - makefile_path = Path.cwd() / "Makefile" + makefile_path = Path(__file__).resolve().parent.parent.parent / "Makefile" makefile_content = makefile_path.read_text() coverage_section = False @@ -210,7 +126,7 @@ class TestMakefileCommands: """Test that Makefile commands work as expected.""" def test_makefile_test_targets_defined(self): - makefile_path = Path.cwd() / "Makefile" + makefile_path = Path(__file__).resolve().parent.parent.parent / "Makefile" makefile_content = makefile_path.read_text() expected_targets = [ @@ -243,7 +159,7 @@ def test_makefile_test_unlock_expands_user_lock_dir_without_uv(self, tmp_path): result = subprocess.run( ["make", "--no-print-directory", "test-unlock"], - cwd=Path.cwd(), + cwd=REPO_ROOT, capture_output=True, text=True, timeout=30, @@ -259,20 +175,20 @@ def test_makefile_test_unlock_expands_user_lock_dir_without_uv(self, tmp_path): assert lock_path.read_text(encoding="utf-8") == "" def test_makefile_test_all_splits_parallel_and_serial_lanes_explicitly(self): - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() assert "test-all:" in makefile_content assert '-m "not (slow or stress or resource_heavy or live_integration)"' in makefile_content assert '-m "(slow or resource_heavy) and not (stress or live_integration)" -n 0' in makefile_content def test_makefile_test_fast_excludes_heavy_and_opt_in_lanes(self): - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() assert "test-fast:" in makefile_content assert '-m "fast and not (slow or stress or resource_heavy or live_integration)" --tb=short' in makefile_content def test_default_test_lanes_arm_explicit_timeouts(self): - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() expected = { "test-fast": "--timeout=120", "test-medium": "--timeout=60", @@ -290,7 +206,7 @@ def test_default_test_lanes_arm_explicit_timeouts(self): assert "--timeout=1200" in test_all def test_pytest_ini_declares_baseline_timeout(self): - repo_root = Path.cwd() + repo_root = Path(__file__).resolve().parent.parent.parent pytest_ini = (repo_root / "pytest.ini").read_text(encoding="utf-8") pytest_ci_ini = (repo_root / "pytest-ci.ini").read_text(encoding="utf-8") @@ -323,79 +239,6 @@ def test_pytest_timeout_kills_hanging_test(self, tmp_path: Path): or "Timeout" in result.stderr ) - def test_skill_integrity_preflight_pure_skill_selects_only_focused_lane(self, tmp_path: Path): - result, calls = _run_skill_integrity_preflight_route( - tmp_path, - _skill_preflight_decision(skill=True, skill_only=True, code=False), - ) - - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "Selected preflight lanes: skill-integrity" - assert calls == ["-s skill-integrity-check"] - - def test_skill_integrity_preflight_delete_only_skill_stays_focused(self, tmp_path: Path): - result, calls = _run_skill_integrity_preflight_route( - tmp_path, - _skill_preflight_decision(skill=True, skill_only=True, code=False), - ) - - assert result.returncode == 0, result.stderr - assert calls == ["-s skill-integrity-check"] - assert all("ci-lint" not in call and "pr-preflight-fast-tests" not in call for call in calls) - - def test_skill_integrity_preflight_mixed_diff_runs_both_lanes(self, tmp_path: Path): - result, calls = _run_skill_integrity_preflight_route( - tmp_path, - _skill_preflight_decision(skill=True, skill_only=False, code=True, content=True), - ) - - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "Selected preflight lanes: product content skill-integrity" - assert calls[0] == "ci-lint" - assert calls[1] == "-s skill-integrity-check" - assert calls[2].startswith("-s pr-preflight-fast-tests ") - - def test_skill_integrity_preflight_skill_plus_content_runs_both_narrow_lanes(self, tmp_path: Path): - result, calls = _run_skill_integrity_preflight_route( - tmp_path, - _skill_preflight_decision(skill=True, skill_only=True, code=False, content=True), - ) - - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "Selected preflight lanes: skill-integrity content" - assert calls == ["-s skill-integrity-check", f"-s pr-content-guard PATH_LISTS={tmp_path / 'lists'}"] - assert all("ci-lint" not in call and "pr-preflight-fast-tests" not in call for call in calls) - - @pytest.mark.parametrize("case", ["product", "unknown", "empty"]) - def test_skill_integrity_preflight_non_skill_cases_retain_full_contract(self, tmp_path: Path, case: str): - result, calls = _run_skill_integrity_preflight_route( - tmp_path, - _skill_preflight_decision(skill=False, skill_only=False, code=True), - ) - - assert result.returncode == 0, f"{case}: {result.stderr}" - assert result.stdout.strip() == "Selected preflight lanes: product" - assert calls[0] == "ci-lint" - assert calls[1].startswith("-s pr-preflight-fast-tests ") - assert all("skill-integrity-check" not in call for call in calls) - - def test_skill_integrity_preflight_invalid_decision_fails_closed(self, tmp_path: Path): - result, calls = _run_skill_integrity_preflight_route(tmp_path, {"skill_integrity_needed": True}) - - assert result.returncode != 0 - assert "invalid preflight decision" in result.stderr - assert calls == [] - - def test_skill_integrity_preflight_contradictory_decision_fails_closed(self, tmp_path: Path): - result, calls = _run_skill_integrity_preflight_route( - tmp_path, - _skill_preflight_decision(skill=True, skill_only=True, code=True), - ) - - assert result.returncode != 0 - assert "contradictory preflight decision" in result.stderr - assert calls == [] - def test_skill_integrity_preflight_missing_trusted_tool_fails_instead_of_skipping(self, tmp_path: Path): tool_bin = tmp_path / "bin" tool_bin.mkdir() @@ -408,7 +251,7 @@ def test_skill_integrity_preflight_missing_trusted_tool_fails_instead_of_skippin result = subprocess.run( [make, "--no-print-directory", "skill-integrity-check"], - cwd=Path.cwd(), + cwd=REPO_ROOT, capture_output=True, text=True, timeout=60, @@ -419,42 +262,6 @@ def test_skill_integrity_preflight_missing_trusted_tool_fails_instead_of_skippin assert "git" in result.stderr.lower() assert "skipping" not in (result.stdout + result.stderr).lower() - def test_skill_integrity_preflight_selected_lane_failure_propagates(self, tmp_path: Path): - tool_bin = tmp_path / "bin" - tool_bin.mkdir() - for command in ("mktemp", "rm", "mkdir"): - executable = shutil.which(command) - assert executable is not None - (tool_bin / command).symlink_to(executable) - (tool_bin / "git").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - (tool_bin / "git").chmod(0o755) - (tool_bin / "uv").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - (tool_bin / "uv").chmod(0o755) - trace = tmp_path / "trace.txt" - fake_make = tmp_path / "fake-make" - fake_make.write_text( - '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$PREFLIGHT_TRACE"\n' - 'case "$*" in *".pr-preflight-route"*) exit 23;; esac\n', - encoding="utf-8", - ) - fake_make.chmod(0o755) - make = shutil.which("make") - assert make is not None - - result = subprocess.run( - [make, "--no-print-directory", "pr-preflight-uncached", f"MAKE={fake_make}"], - cwd=Path.cwd(), - capture_output=True, - text=True, - timeout=60, - env={**os.environ, "PATH": str(tool_bin), "PREFLIGHT_TRACE": str(trace)}, - ) - calls = trace.read_text(encoding="utf-8").splitlines() - - assert result.returncode != 0 - assert any(".pr-preflight-route" in call for call in calls) - assert all("uat-artifact-hygiene" not in call for call in calls) - def test_pr_preflight_fast_tests_creates_classifier_artifacts_when_absent(self, tmp_path: Path): tool_bin = tmp_path / "bin" tool_bin.mkdir() @@ -499,7 +306,7 @@ def test_pr_preflight_fast_tests_creates_classifier_artifacts_when_absent(self, result = subprocess.run( [make, "--no-print-directory", "pr-preflight-fast-tests", f"MAKE={fake_make}"], - cwd=Path.cwd(), + cwd=REPO_ROOT, capture_output=True, text=True, timeout=60, @@ -512,28 +319,35 @@ def test_pr_preflight_fast_tests_creates_classifier_artifacts_when_absent(self, assert any("pr-content-guard" in call for call in calls) assert "No code changes detected; skipping fast tests." in result.stdout - def test_skill_integrity_preflight_consumes_one_classifier_artifact_without_path_globs(self): - makefile_content = (Path.cwd() / "Makefile").read_text(encoding="utf-8") - preflight_body = _makefile_target_body(makefile_content, "pr-preflight") - uncached_body = _makefile_target_body(makefile_content, "pr-preflight-uncached") - route_body = _makefile_target_body(makefile_content, ".pr-preflight-route") + def test_pr_preflight_fast_tests_consumes_one_classifier_artifact(self): + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text(encoding="utf-8") fast_tests_body = _makefile_target_body(makefile_content, "pr-preflight-fast-tests") - assert "scripts/local_validation.py ordered" in preflight_body - assert 'PATH_DECISION="$$DECISION"' in uncached_body - assert "scripts/path_filter_decision.py --base-ref origin/develop" in uncached_body - assert "path_filter_decision.py --base-ref" not in route_body - assert ".claude/skills" not in route_body - assert "skill-sync.yaml" not in route_body - assert "skill-sync.lock" not in route_body assert 'if [ -n "$(PATH_DECISION)" ] || [ -n "$(PATH_LISTS)" ]; then' in fast_tests_body assert "scripts/path_filter_decision.py --base-ref origin/develop" in fast_tests_body + def test_pr_preflight_runs_only_changed_file_lint_and_serial_tests(self): + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text(encoding="utf-8") + preflight_body = _makefile_target_body(makefile_content, "pr-preflight") + + assert "_project/scripts/preflight_targets.py python" in preflight_body + assert "_project/scripts/preflight_targets.py tests" in preflight_body + assert "ruff check --force-exclude" in preflight_body + assert "ruff format --check --force-exclude" in preflight_body + assert "-q -n 0" in preflight_body + assert "--ff" in preflight_body + assert "-u \\1" in preflight_body + assert "--lf" not in preflight_body + assert "CI runs the fast lane" in preflight_body + assert "local_validation" not in preflight_body + assert "pr-preflight-fast-tests" not in preflight_body + assert "ci-lint" not in preflight_body + def test_pr_preflight_fast_tests_uses_required_ci_marker_expression(self): - repo_root = Path.cwd() + repo_root = Path(__file__).resolve().parent.parent.parent makefile_content = (repo_root / "Makefile").read_text() preflight_body = _makefile_target_body(makefile_content, "pr-preflight-fast-tests") - develop_pr_run_text = _workflow_job_run_text(repo_root / ".github" / "workflows" / "pr.yml", "code-test") + develop_pr_run_text = _workflow_job_run_text(repo_root / ".github" / "workflows" / "ci.yml", "code-test") main_pr_run_text = _workflow_job_run_text(repo_root / ".github" / "workflows" / "test.yml", "test") assert f'-m "{CI_FAST_EXPRESSION}"' in preflight_body @@ -547,7 +361,7 @@ def test_pr_preflight_fast_tests_uses_required_ci_marker_expression(self): assert "coverage remains CI-only" in makefile_content def test_test_ci_is_maintained_broad_local_profile(self): - repo_root = Path.cwd() + repo_root = Path(__file__).resolve().parent.parent.parent makefile_content = (repo_root / "Makefile").read_text() pytest_ci_config = ConfigParser() pytest_ci_config.read(repo_root / "pytest-ci.ini") @@ -579,9 +393,9 @@ def test_test_ci_is_maintained_broad_local_profile(self): ) def test_coverage_threshold_policy_distinguishes_blocking_and_advisory_thresholds(self): - repo_root = Path.cwd() + repo_root = Path(__file__).resolve().parent.parent.parent makefile_content = (repo_root / "Makefile").read_text() - pr_workflow = (repo_root / ".github" / "workflows" / "pr.yml").read_text() + pr_workflow = (repo_root / ".github" / "workflows" / "ci.yml").read_text() test_workflow = (repo_root / ".github" / "workflows" / "test.yml").read_text() conftest_content = (repo_root / "tests" / "conftest.py").read_text() @@ -594,7 +408,7 @@ def test_coverage_threshold_policy_distinguishes_blocking_and_advisory_threshold assert "pytest.fail" not in conftest_content.split("def pytest_terminal_summary", maxsplit=1)[1] def test_makefile_test_medium_uses_five_workers(self): - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() assert "test-medium:" in makefile_content assert ( @@ -603,11 +417,11 @@ def test_makefile_test_medium_uses_five_workers(self): ) def test_medium_marker_policy_is_documented_with_pr_routing(self): - repo_root = Path.cwd() + repo_root = Path(__file__).resolve().parent.parent.parent readme_content = (repo_root / "tests" / "README.md").read_text() makefile_content = (repo_root / "Makefile").read_text() - assert "The `medium-test` job in `.github/workflows/pr.yml` runs `make test-medium`" in readme_content + assert "The `medium-test` job in `.github/workflows/ci.yml` runs `make test-medium`" in readme_content assert "only when the heavy tier is needed" in readme_content assert "Ordinary\ncode-change PRs skip it" in readme_content assert "Product-critical tests that need a different" in readme_content @@ -629,12 +443,11 @@ def test_every_speed_marker_tier_has_a_ci_consumer(self): from tests.unit.test_marker_strategy import _E2E_QUICK_INCOMPATIBLE, _SPEED_MARKERS known_markers = sorted(_SPEED_MARKERS | _E2E_QUICK_INCOMPATIBLE) - repo_root = Path.cwd() + repo_root = Path(__file__).resolve().parent.parent.parent workflow_paths = [ - repo_root / ".github" / "workflows" / "pr.yml", + repo_root / ".github" / "workflows" / "ci.yml", repo_root / ".github" / "workflows" / "test.yml", repo_root / ".github" / "workflows" / "nightly.yml", - repo_root / ".github" / "workflows" / "develop-post-merge.yml", ] combined_text = "\n".join(path.read_text(encoding="utf-8") for path in workflow_paths) @@ -647,7 +460,7 @@ def test_every_speed_marker_tier_has_a_ci_consumer(self): ) def test_makefile_correctness_gate_runs_expected_results_backed_matrix_slice(self): - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() gate_body = _makefile_target_body(makefile_content, "test-correctness-gate") assert "BENCHBOX_STRICT_EXPECTED_RESULTS=1" in gate_body @@ -675,7 +488,7 @@ def test_makefile_digest_regen_shares_gate_query_id_source(self): cardinality answer cannot hide behind a digest reference generated from a different query set than the gate validates. """ - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() regen_body = _makefile_target_body(makefile_content, "correctness-gate-digests-regen") assert "BENCHBOX_CORRECTNESS_GATE_QUERY_IDS=$(CORRECTNESS_GATE_QUERY_IDS)" in regen_body assert "regenerate_correctness_gate_digests.py" in regen_body @@ -706,7 +519,7 @@ def test_correctness_gate_nodeid_collects_under_stress(self): "stress", CORRECTNESS_GATE_NODEID, ], - cwd=Path.cwd(), + cwd=REPO_ROOT, capture_output=True, text=True, timeout=300, @@ -719,23 +532,23 @@ def test_correctness_gate_nodeid_collects_under_stress(self): assert CORRECTNESS_GATE_NODEID in proc.stdout, f"Gate node-id not present in collection output:\n{proc.stdout}" def test_develop_pr_invokes_bounded_correctness_gate(self): - repo_root = Path.cwd() - workflow = yaml.safe_load((repo_root / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8")) + repo_root = Path(__file__).resolve().parent.parent.parent + workflow = yaml.safe_load((repo_root / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")) job = workflow["jobs"]["correctness-gate"] - aggregate = workflow["jobs"]["ci-required-result"] + aggregate = workflow["jobs"]["core"] assert job["needs"] == "ci-paths" # Heavy-tier queue-only: correctness-gate is in the moved set, so it # gates on heavy-needed (merge queue or carve-out), not needs-code-ci. assert "heavy-needed == 'true'" in job["if"] assert "make test-correctness-gate" in _workflow_job_run_text( - repo_root / ".github" / "workflows" / "pr.yml", + repo_root / ".github" / "workflows" / "ci.yml", "correctness-gate", ) assert "correctness-gate" in aggregate["needs"] - assert "CORRECTNESS_RESULT" in _workflow_job_run_text( - repo_root / ".github" / "workflows" / "pr.yml", - "ci-required-result", + assert "needs" in _workflow_job_run_text( + repo_root / ".github" / "workflows" / "ci.yml", + "core", ) def test_correctness_gate_job_runs_value_level_equivalence_steps(self): @@ -747,8 +560,8 @@ def test_correctness_gate_job_runs_value_level_equivalence_steps(self): SQL<->DataFrame equivalence gates, all of which compare full result VALUES. Pin that composition so dropping a value-level step is caught. """ - repo_root = Path.cwd() - run_text = _workflow_job_run_text(repo_root / ".github" / "workflows" / "pr.yml", "correctness-gate") + repo_root = Path(__file__).resolve().parent.parent.parent + run_text = _workflow_job_run_text(repo_root / ".github" / "workflows" / "ci.yml", "correctness-gate") # Row-count + value-digest TPC-H gate. assert "make test-correctness-gate" in run_text @@ -772,10 +585,10 @@ def test_cross_surface_gates_are_pinned_in_make_and_ci(self): """ from benchbox.core.equivalence.cross_surface import GATES - repo_root = Path.cwd() + repo_root = Path(__file__).resolve().parent.parent.parent makefile_content = (repo_root / "Makefile").read_text() correctness_gate_run_text = _workflow_job_run_text( - repo_root / ".github" / "workflows" / "pr.yml", + repo_root / ".github" / "workflows" / "ci.yml", "correctness-gate", ) missing_make_targets: list[str] = [] @@ -797,8 +610,26 @@ def test_cross_surface_gates_are_pinned_in_make_and_ci(self): assert not missing_make_targets, f"GATES entries missing Make targets: {missing_make_targets}" assert not missing_ci_steps, f"GATES entries missing correctness-gate CI steps: {missing_ci_steps}" + def test_split_cross_surface_targets_cover_every_gate_backend(self): + """A gate split across Make targets by backend must still run every gated backend in CI.""" + from benchbox.core.equivalence.cross_surface import GATES + + repo_root = Path(__file__).resolve().parent.parent.parent + makefile_content = (repo_root / "Makefile").read_text() + run_text = _workflow_job_run_text(repo_root / ".github" / "workflows" / "ci.yml", "correctness-gate") + for gate_name, extra_targets in _SPLIT_CROSS_SURFACE_TARGETS.items(): + covered: set[str] = set() + for target in (_cross_surface_make_target(gate_name), *extra_targets): + body = _makefile_target_body(makefile_content, target) + assert f"--benchmark {gate_name}" in body, f"{target} must run --benchmark {gate_name}" + assert f"make {target}" in run_text, f"correctness-gate CI is missing make {target}" + covered.update(re.findall(r"--backend (\S+)", body)) + assert covered == set(GATES[gate_name].backends), ( + f"{gate_name} Make targets run {sorted(covered)}, gate backends are {sorted(GATES[gate_name].backends)}" + ) + def test_main_release_required_includes_bounded_correctness_gate(self): - repo_root = Path.cwd() + repo_root = Path(__file__).resolve().parent.parent.parent workflow = yaml.safe_load((repo_root / ".github" / "workflows" / "test.yml").read_text(encoding="utf-8")) job = workflow["jobs"]["correctness-gate"] aggregate = workflow["jobs"]["release-required-result"] @@ -811,24 +642,28 @@ def test_main_release_required_includes_bounded_correctness_gate(self): assert "correctness-gate" in aggregate["needs"] def test_lint_markers_runs_marker_strategy_policy_explicitly(self): - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() lint_body = _makefile_target_body(makefile_content, "lint-markers") assert "--collect-only" in lint_body assert "tests/unit/test_marker_strategy.py -q" in lint_body def test_makefile_test_slow_runs_serially(self): - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() assert "test-slow:" in makefile_content assert '-m "slow and not (stress or live_integration)" -n 0 --tb=short -v' in makefile_content def test_runtime_no_longer_depends_on_generated_bucket_files(self): - assert not (Path.cwd() / "_project" / "config" / "test_speed_buckets.json").exists() - assert not (Path.cwd() / "_project" / "scripts" / "generate_test_speed_buckets.py").exists() + assert not ( + Path(__file__).resolve().parent.parent.parent / "_project" / "config" / "test_speed_buckets.json" + ).exists() + assert not ( + Path(__file__).resolve().parent.parent.parent / "_project" / "scripts" / "generate_test_speed_buckets.py" + ).exists() def test_makefile_uses_pytest_consistently(self): - makefile_content = (Path.cwd() / "Makefile").read_text() + makefile_content = (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text() test_lines = [line for line in makefile_content.split("\n") if line.startswith("\tuv run -- python -m pytest")] @@ -853,7 +688,7 @@ class TestCorrectnessGateOracle: These guard the *oracle* (query-set discrimination, strict-mode arming, no-skip), not just the command spelling. They stay in the module-default ``unit``/``medium`` tier: the correctness gate itself runs as a dedicated required CI job (``correctness-gate`` in - ``.github/workflows/pr.yml``) on every code-impacting PR, so these meta-guards do not + ``.github/workflows/ci.yml``) on every code-impacting PR, so these meta-guards do not also need the fast lane -- they prevent local config regression and run via the medium lane and the explicit gate verification command. """ @@ -910,7 +745,9 @@ def test_gate_target_enforces_no_skip(self): pass without running anything. The target must emit a JUnit report and assert exactly one test ran with zero skips/errors/failures. """ - gate_body = _makefile_target_body((Path.cwd() / "Makefile").read_text(), "test-correctness-gate") + gate_body = _makefile_target_body( + (Path(__file__).resolve().parent.parent.parent / "Makefile").read_text(), "test-correctness-gate" + ) assert "--junitxml=" in gate_body, "gate must emit a JUnit report to verify the node actually ran" # The guard parses that report (no brittle stdout scraping) and must assert the exact # ran/skip condition. Pin the condition tokens so a disarmed guard (e.g. relaxing to diff --git a/tests/unit/test_tpcds_file_format_fix.py b/tests/unit/test_tpcds_file_format_fix.py index d4730fee92..6a9e859012 100644 --- a/tests/unit/test_tpcds_file_format_fix.py +++ b/tests/unit/test_tpcds_file_format_fix.py @@ -14,6 +14,7 @@ import pytest +from benchbox.core.tpcds.c_tools import tpcds_option from benchbox.core.tpcds.generator import TPCDSDataGenerator pytestmark = [ @@ -49,10 +50,10 @@ def mock_popen(cmd, **kwargs): process.stderr = None # Check if this is a streaming compression call (has -FILTER Y) - if "-FILTER" in cmd and "Y" in cmd: + if tpcds_option("filter") in cmd and "Y" in cmd: # Simulate dsdgen behavior: only chunk 1 has data for call_center - if "call_center" in cmd and "-child" in cmd: - chunk_id_idx = cmd.index("-child") + 1 + if "call_center" in cmd and tpcds_option("child") in cmd: + chunk_id_idx = cmd.index(tpcds_option("child")) + 1 if chunk_id_idx < len(cmd): chunk_id = int(cmd[chunk_id_idx]) if chunk_id == 1: diff --git a/tests/unit/test_tpchavoc.py b/tests/unit/test_tpchavoc.py index d138dcd917..76a3042356 100644 --- a/tests/unit/test_tpchavoc.py +++ b/tests/unit/test_tpchavoc.py @@ -17,6 +17,7 @@ import pytest +from benchbox.sql_compat.rules.execution_filter.cloud_tpchavoc import CLOUD_TPCHAVOC_SKIPS from benchbox.tpchavoc import TPCHavoc pytestmark = [ @@ -120,6 +121,50 @@ def test_get_query_with_invalid_seed_type(self, havoc): with pytest.raises(TypeError, match="seed must be an integer"): havoc.get_query(1, seed="invalid") + def test_get_query_forwards_dialect(self, havoc): + """Test get_query forwards dialect and base_dialect to implementation.""" + havoc._impl.get_query.return_value = "SELECT" + + havoc.get_query(1, dialect="bigquery", base_dialect="netezza") + + havoc._impl.get_query.assert_called_once_with( + 1, seed=None, scale_factor=None, dialect="bigquery", base_dialect="netezza" + ) + + def test_get_query_dialect_defaults_to_none(self, havoc): + """Test get_query forwards None dialects without breaking existing callers.""" + havoc._impl.get_query.return_value = "SELECT" + + havoc.get_query(1) + + havoc._impl.get_query.assert_called_once_with(1, seed=None, scale_factor=None, dialect=None, base_dialect=None) + + +class TestGetPlatformSkipQueries: + """Tests for get_platform_skip_queries delegation.""" + + @pytest.fixture + def havoc(self, tmp_path): + """Create a TPCHavoc instance for testing.""" + with patch("benchbox.tpchavoc.TPCHavocBenchmark"): + return TPCHavoc(scale_factor=1.0, output_dir=tmp_path) + + def test_delegates_to_impl(self, havoc): + """Test platform skips delegate to the implementation.""" + havoc._impl.get_platform_skip_queries.return_value = ["1_v6"] + + result = havoc.get_platform_skip_queries("bigquery") + + assert result == ["1_v6"] + havoc._impl.get_platform_skip_queries.assert_called_once_with("bigquery") + + def test_cloud_and_unknown_platforms_through_facade(self, tmp_path): + """Test the facade exposes real cloud skips and [] for unknown platforms.""" + havoc = TPCHavoc(scale_factor=0.1, output_dir=tmp_path) + + assert set(havoc.get_platform_skip_queries("bigquery")) == set(CLOUD_TPCHAVOC_SKIPS["bigquery"]) + assert havoc.get_platform_skip_queries("unknown-platform") == [] + class TestGetQueryVariant: """Tests for get_query_variant method.""" diff --git a/tests/unit/test_xdist_safety.py b/tests/unit/test_xdist_safety.py index 72529bb022..9cdf10732b 100644 --- a/tests/unit/test_xdist_safety.py +++ b/tests/unit/test_xdist_safety.py @@ -113,3 +113,84 @@ def test_suppress_xdist_worker_title_ignores_non_exec_frames(monkeypatch: pytest # The key invariant: THIS frame's worker_title must never be patched. _suppress_xdist_worker_title() assert globals()["worker_title"] is original_title_fn + + +class _IniConfig: + """The slice of ``pytest.Config`` the faulthandler guard reads.""" + + def __init__(self, ini: dict[str, object], *, worker: bool = False) -> None: + self._ini = ini + if worker: + self.workerinput = {"workerid": "gw0"} + + def getini(self, name: str) -> object: + if name not in self._ini: + raise ValueError(f"unknown configuration value: {name!r}") + return self._ini[name] + + +@pytest.mark.parametrize("value", ["120", "0.5", 30.0]) +def test_configure_refuses_faulthandler_timeout(value: object) -> None: + with pytest.raises(pytest.exit.Exception) as excinfo: + safety_plugin.pytest_configure(_IniConfig({"faulthandler_timeout": value})) + + assert excinfo.value.returncode == pytest.ExitCode.USAGE_ERROR + assert "faulthandler_timeout" in str(excinfo.value.msg) + assert "pytest-timeout" in str(excinfo.value.msg) + + +@pytest.mark.parametrize("ini", [{"faulthandler_timeout": 0.0}, {"faulthandler_timeout": ""}, {}]) +def test_configure_allows_unset_or_unregistered_faulthandler_timeout( + ini: dict[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: + # A controller run continues into the cap report, which pops these variables. Under a capped xdist + # run the worker inherits them from its controller, so the test owns them for its own duration. + monkeypatch.delenv("BENCHBOX_XDIST_CAP_REQUESTED", raising=False) + monkeypatch.delenv("BENCHBOX_XDIST_CAP_EFFECTIVE", raising=False) + + safety_plugin.pytest_configure(_IniConfig(ini)) + + +def test_configure_leaves_the_refusal_to_the_controller() -> None: + safety_plugin.pytest_configure(_IniConfig({"faulthandler_timeout": "120"}, worker=True)) + + +def test_pytest_run_with_faulthandler_timeout_exits_as_usage_error(tmp_path) -> None: + """The guard fires on a real run before any test starts, so the run cannot hang in the watchdog.""" + import subprocess + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + probe = tmp_path / "test_probe.py" + probe.write_text("def test_probe():\n pass\n") + completed = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + "-n", + "0", + "-p", + "no:cacheprovider", + "-m", + "", + "--rootdir", + str(root), + "-c", + str(root / "pytest.ini"), + "-o", + "faulthandler_timeout=120", + str(probe), + ], + cwd=root, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + assert completed.returncode == pytest.ExitCode.USAGE_ERROR, completed.stdout + completed.stderr + assert "faulthandler_timeout is not supported" in completed.stdout + completed.stderr + assert "1 passed" not in completed.stdout diff --git a/tests/unit/tpcds/test_generate_with_parameters.py b/tests/unit/tpcds/test_generate_with_parameters.py new file mode 100644 index 0000000000..df84ae9350 --- /dev/null +++ b/tests/unit/tpcds/test_generate_with_parameters.py @@ -0,0 +1,103 @@ +"""Tests for ``DSQGenBinary.generate_with_parameters``: rendering a query with explicit values. + +Values are keyed by the names dsqgen's ``-LOG`` writes (``YEAR.01``). The round-trip test renders +a seed for every one of the 99 queries and every variant template, reads its ``-LOG`` values, renders again from those values +with a different seed, and requires the same SQL, so it pins that the explicit values fully determine +the substitutions. +""" + +import pytest + +pytestmark = [ + pytest.mark.unit, + pytest.mark.medium, + pytest.mark.tpcds, +] + + +@pytest.fixture(scope="module") +def dsqgen(): + from benchbox.core.tpcds.c_tools import DSQGenBinary, TPCDSError + + try: + return DSQGenBinary() + except (TPCDSError, FileNotFoundError, RuntimeError) as exc: + pytest.skip(f"dsqgen binary or templates unavailable: {exc}") + + +def _squash(sql): + return " ".join(sql.split()) + + +def test_explicit_values_win_over_the_seed(dsqgen): + for seed in (1, 77): + sql = _squash(dsqgen.generate_with_parameters(39, {"YEAR.01": 2001, "MONTH.01": 1}, seed=seed)) + + assert "d_year =2001" in sql + assert "inv1.d_moy=1" in sql + assert "inv2.d_moy=1+1" in sql # the template's [MONTH]+1 keeps its expression + + +def test_text_values_and_dependent_defines(dsqgen): + q93 = _squash(dsqgen.generate_with_parameters(93, {"REASON.01": "reason 28"})) + q44 = _squash(dsqgen.generate_with_parameters(44, {"STORE.01": 4, "NULLCOLSS.01": "ss_addr_sk"})) + + assert "r_reason_desc = 'reason 28'" in q93 + assert "ss_store_sk = 4" in q44 + assert "ss_addr_sk is null" in q44 + + +def test_a_list_of_four_hundred_values(dsqgen): + zips = {f"ZIP.{index:03d}": str(10000 + index) for index in range(1, 401)} + + sql = _squash(dsqgen.generate_with_parameters(8, {**zips, "YEAR.01": 2001, "QOY.01": 2})) + + assert all(f"'{10000 + index}'" in sql for index in (1, 200, 400)) + assert "d_year = 2001" in sql + + +def test_multi_part_variant_selects_its_part(dsqgen): + sql = dsqgen.generate_with_parameters("39a", {"YEAR.01": 2001, "MONTH.01": 1}) + + assert "d_year =2001" in _squash(sql) + assert sql != dsqgen.generate_with_parameters("39b", {"YEAR.01": 2001, "MONTH.01": 1}) + + +VARIANT_QUERY_IDS = ["5a", "10a", "14a", "18a", "22a", "27a", "35a", "36a", "51a", "67a", "70a", "77a", "80a", "86a"] + + +@pytest.mark.parametrize("query_id", [*range(1, 100), *VARIANT_QUERY_IDS]) +def test_values_from_a_seed_reproduce_that_seed(dsqgen, query_id): + values = dsqgen.generate_parameter_log(query_id, seed=7, scale_factor=1.0).substitutions + reference = dsqgen.generate(query_id, seed=7) + + rendered = dsqgen.generate_with_parameters(query_id, values, seed=99) + + assert _squash(rendered) == _squash(reference) + + +@pytest.mark.parametrize("name", ["YEAR", "YEAR.", "YEAR.x", ".01"]) +def test_malformed_names_are_rejected(dsqgen, name): + with pytest.raises(ValueError, match="Parameter name"): + dsqgen.generate_with_parameters(39, {name: 1}) + + +@pytest.mark.parametrize("name", ["NOPE.01", "year.01"]) +def test_a_name_the_template_does_not_define_is_rejected(dsqgen, name): + # Names are case-sensitive. + with pytest.raises(ValueError, match=f"no substitution for parameter '{name}'"): + dsqgen.generate_with_parameters(39, {name: 1}) + + +def test_a_defined_value_the_template_does_not_use_is_accepted(dsqgen): + # Q10 defines ten COUNTY values (and dsqgen -LOG reports ten) but its SQL uses only five. + used = _squash(dsqgen.generate_with_parameters(10, {"COUNTY.01": "Aa County"})) + with_unused = _squash(dsqgen.generate_with_parameters(10, {"COUNTY.01": "Aa County", "COUNTY.10": "Zz County"})) + + assert "Aa County" in used + assert with_unused == used + + +def test_invalid_query_id_is_rejected(dsqgen): + with pytest.raises(ValueError): + dsqgen.generate_with_parameters(100, {"YEAR.01": 2001}) diff --git a/tests/unit/tpcds/test_get_queries_scale.py b/tests/unit/tpcds/test_get_queries_scale.py new file mode 100644 index 0000000000..9b6d486e4f --- /dev/null +++ b/tests/unit/tpcds/test_get_queries_scale.py @@ -0,0 +1,43 @@ +"""``get_queries`` must render the same SQL as ``get_query`` for the data's scale factor. + +dsqgen derives some values from the scale (row-count thresholds, for example), so rendering at a +different scale gives SQL that does not match the generated data. Q9 and Q44 are two of the +templates whose SQL changes with scale. +""" + +import pytest + +pytestmark = [ + pytest.mark.unit, + pytest.mark.fast, + pytest.mark.tpcds, +] + +SCALE_FACTOR = 10.0 +SCALE_DEPENDENT_QUERY_IDS = (9, 44) + + +@pytest.fixture(scope="module") +def benchmark_and_queries(tmp_path_factory): + from benchbox.core.tpcds.benchmark import TPCDSBenchmark + from benchbox.core.tpcds.c_tools import TPCDSError + + try: + bench = TPCDSBenchmark(scale_factor=SCALE_FACTOR, output_dir=tmp_path_factory.mktemp("tpcds")) + return bench, bench.get_queries() + except (TPCDSError, FileNotFoundError, RuntimeError) as exc: + pytest.skip(f"dsqgen binary or templates unavailable: {exc}") + + +@pytest.mark.parametrize("query_id", SCALE_DEPENDENT_QUERY_IDS) +def test_get_queries_matches_get_query_at_the_data_scale(benchmark_and_queries, query_id): + bench, queries = benchmark_and_queries + + assert queries[str(query_id)] == bench.get_query(query_id) + + +@pytest.mark.parametrize("query_id", SCALE_DEPENDENT_QUERY_IDS) +def test_get_queries_is_not_rendered_at_scale_factor_one(benchmark_and_queries, query_id): + bench, queries = benchmark_and_queries + + assert queries[str(query_id)] != bench.get_query(query_id, scale_factor=1.0) diff --git a/tests/unit/tpcds/test_parameter_log.py b/tests/unit/tpcds/test_parameter_log.py new file mode 100644 index 0000000000..70f5229493 --- /dev/null +++ b/tests/unit/tpcds/test_parameter_log.py @@ -0,0 +1,208 @@ +"""Tests for the dsqgen ``-LOG`` parameter parser and ``DSQGenBinary.generate_parameter_log``. + +The parser tests use logs captured from the bundled dsqgen (seed 7, scale 1). +The binary tests assert behaviour that must hold on every platform (same seed +repeats, a different seed changes the values, the stream is reported) and do not +pin values, because cross-platform value identity is checked separately. + +Copyright 2026 Joe Harris / BenchBox Project + +TPC Benchmark(TM) DS (TPC-DS) - Copyright (c) Transaction Processing Performance Council + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +import pytest + +from benchbox.core.tpcds.parameter_log import TemplateParameters, parse_dsqgen_parameter_log + +pytestmark = [ + pytest.mark.unit, + pytest.mark.fast, + pytest.mark.tpcds, +] + +Q39_LOG = ( + "BEGIN STREAM 0\nTemplate: query39.tpl\n\t__LIMITA.01 = \n\tYEAR.01 = 2002\n\tMONTH.01 = 4\n" + "\t_END.01 = \n\t__LIMITC.01 = limit %d\n\t__LIMITB.01 = \n\nEND STREAM 0\n" +) + +Q70_LOG = ( + "BEGIN STREAM 0\nTemplate: query70.tpl\n\t__LIMITA.01 = \n\t_LIMITC.01 = limit 100\n\t_LIMITB.01 = \n" + "\t_LIMITA.01 = \n\t_LIMIT.01 = 100\n\tDMS.01 = 1176\n\t_END.01 = \n\t__LIMITC.01 = limit %d\n" + "\t__LIMITB.01 = \n\nEND STREAM 0\n" +) + +# Excerpt of the Q8 log: the real log lists ZIP.01 through ZIP.400 before YEAR.01. +Q8_LOG_EXCERPT = ( + "BEGIN STREAM 0\nTemplate: query8.tpl\n\t__LIMITA.01 = \n\t_LIMITC.01 = limit 100\n\t_LIMITB.01 = \n" + "\t_LIMITA.01 = \n\t_LIMIT.01 = 100\n\tZIP.01 = 37650\n\tZIP.02 = 11903\n\tZIP.03 = 52277\n" + "\tQOY.01 = 2\n\tYEAR.01 = 2001\n\t_END.01 = \n\t__LIMITC.01 = limit %d\n\t__LIMITB.01 = \n\nEND STREAM 0\n" +) + +MULTI_STREAM_LOG = ( + "BEGIN STREAM 0\nTemplate: query39.tpl\n\tYEAR.01 = 2002\n\tMONTH.01 = 4\n\nEND STREAM 0\n" + "BEGIN STREAM 1\nTemplate: query39.tpl\n\tYEAR.01 = 1999\n\tMONTH.01 = 2\n\nEND STREAM 1\n" +) + + +class TestParseDsqgenParameterLog: + def test_q39_names_and_values(self): + streams = parse_dsqgen_parameter_log(Q39_LOG) + + assert list(streams) == [0] + (template,) = streams[0] + assert (template.stream, template.query_id, template.variant) == (0, 39, None) + assert template.substitutions == {"YEAR.01": "2002", "MONTH.01": "4"} + assert template.values["_END.01"] == "" + assert template.values["__LIMITC.01"] == "limit %d" + + def test_q70_names_and_values(self): + (template,) = parse_dsqgen_parameter_log(Q70_LOG)[0] + + assert template.query_id == 70 + assert template.substitutions == {"DMS.01": "1176"} + assert template.values["_LIMIT.01"] == "100" + assert template.values["_LIMITC.01"] == "limit 100" + + def test_q8_keeps_repeated_indexed_names_in_order(self): + (template,) = parse_dsqgen_parameter_log(Q8_LOG_EXCERPT)[0] + + assert list(template.substitutions) == ["ZIP.01", "ZIP.02", "ZIP.03", "QOY.01", "YEAR.01"] + assert template.substitutions["ZIP.01"] == "37650" + assert template.substitutions["YEAR.01"] == "2001" + + def test_stream_number_is_part_of_the_result(self): + streams = parse_dsqgen_parameter_log(MULTI_STREAM_LOG) + + assert sorted(streams) == [0, 1] + assert streams[0][0].stream == 0 + assert streams[1][0].stream == 1 + assert streams[0][0].substitutions == {"YEAR.01": "2002", "MONTH.01": "4"} + assert streams[1][0].substitutions == {"YEAR.01": "1999", "MONTH.01": "2"} + + def test_template_variant_suffix_is_parsed(self): + log = "BEGIN STREAM 0\nTemplate: query14a.tpl\n\tYEAR.01 = 1999\n\nEND STREAM 0\n" + + (template,) = parse_dsqgen_parameter_log(log)[0] + + assert (template.query_id, template.variant) == (14, "a") + + @pytest.mark.parametrize( + "template", ["../query_variants/query5a.tpl", "..\\query_variants\\query5a.tpl", "query_templates/query5a.tpl"] + ) + def test_template_path_is_parsed(self, template): + log = f"BEGIN STREAM 0\nTemplate: {template}\n\tYEAR.01 = 2002\n\nEND STREAM 0\n" + + (parsed,) = parse_dsqgen_parameter_log(log)[0] + + assert (parsed.query_id, parsed.variant) == (5, "a") + + def test_value_may_contain_spaces_and_equals(self): + log = "BEGIN STREAM 0\nTemplate: query1.tpl\n\tREASON.01 = reason 28\n\tX.01 = a=b\n\nEND STREAM 0\n" + + (template,) = parse_dsqgen_parameter_log(log)[0] + + assert template.substitutions == {"REASON.01": "reason 28", "X.01": "a=b"} + + @pytest.mark.parametrize( + "log", + [ + "Template: query1.tpl\n\tYEAR.01 = 2000\n", + "BEGIN STREAM 0\nTemplate: query1.tpl\n\tYEAR.01 = 2000\n", + "BEGIN STREAM 0\n\tYEAR.01 = 2000\nEND STREAM 0\n", + "BEGIN STREAM 0\nTemplate: query1.tpl\n\tYEAR.01 = 2000\n\tYEAR.01 = 2001\nEND STREAM 0\n", + "BEGIN STREAM 0\nBEGIN STREAM 0\nEND STREAM 0\n", + "BEGIN STREAM 0\nTemplate: query1.tpl\n\tnot a parameter line\nEND STREAM 0\n", + "BEGIN STREAM 0\nTemplate: query1.tpl\nEND STREAM 1\n", + "BEGIN STREAM 0\nTemplate: query1.tpl\n\tYEAR.01 = 2000\nBEGIN STREAM 1\nTemplate: query1.tpl\nEND STREAM 1\n", + ], + ) + def test_malformed_logs_raise(self, log): + with pytest.raises(ValueError): + parse_dsqgen_parameter_log(log) + + def test_empty_log_has_no_streams(self): + assert parse_dsqgen_parameter_log("") == {} + + +def _binary_or_skip(): + from benchbox.core.tpcds.c_tools import DSQGenBinary, TPCDSError + + try: + return DSQGenBinary() + except (TPCDSError, FileNotFoundError, RuntimeError) as exc: + pytest.skip(f"dsqgen binary or templates unavailable: {exc}") + + +@pytest.fixture(scope="module") +def dsqgen(): + return _binary_or_skip() + + +class TestGenerateParameterLog: + def test_same_seed_repeats_and_different_seed_differs(self, dsqgen): + first = dsqgen.generate_parameter_log(39, seed=7) + again = dsqgen.generate_parameter_log(39, seed=7) + others = [dsqgen.generate_parameter_log(39, seed=seed) for seed in range(8, 16)] + + assert isinstance(first, TemplateParameters) + assert first == again + assert any(other.values != first.values for other in others) + + def test_stream_is_reported_and_independent_of_stream_count(self, dsqgen): + stream0 = dsqgen.generate_parameter_log(39, seed=7) + stream2 = dsqgen.generate_parameter_log(39, seed=7, stream_id=2) + stream3 = dsqgen.generate_parameter_log(39, seed=7, stream_id=3) + + assert (stream0.stream, stream2.stream, stream3.stream) == (0, 2, 3) + assert stream2 == dsqgen.generate_parameter_log(39, seed=7, stream_id=2) + assert stream0.substitutions.keys() == stream2.substitutions.keys() == stream3.substitutions.keys() + + def test_logged_year_appears_in_generated_sql(self, dsqgen): + params = dsqgen.generate_parameter_log(39, seed=7) + sql = dsqgen.generate(39, seed=7) + + squashed = "".join(sql.split()) + assert f"d_year={params.substitutions['YEAR.01']}" in squashed + assert f"inv1.d_moy={params.substitutions['MONTH.01']}" in squashed + + def test_generate_does_not_request_a_log(self, dsqgen, monkeypatch): + seen: list[list[str]] = [] + real_run = __import__("subprocess").run + + def spy(cmd, *args, **kwargs): + seen.append(list(cmd)) + return real_run(cmd, *args, **kwargs) + + monkeypatch.setattr("benchbox.core.tpcds.c_tools.subprocess.run", spy) + dsqgen.clear_cache() + dsqgen.generate(3, seed=11) + + assert seen + assert not any(arg.upper().lstrip("-/") == "LOG" for cmd in seen for arg in cmd) + assert not any(arg.upper().lstrip("-/") == "STREAMS" for cmd in seen for arg in cmd) + + @pytest.mark.parametrize("query_id", ["14a", "14b", "23a", "23b", "24a", "24b", "39a", "39b"]) + @pytest.mark.parametrize("stream_id", [0, 1]) + def test_multi_part_variants_resolve_to_their_template(self, dsqgen, query_id, stream_id): + params = dsqgen.generate_parameter_log(query_id, seed=7, stream_id=stream_id) + + assert params.query_id == int(query_id[:-1]) + assert params.stream == stream_id + + def test_scale_dependent_parameters_follow_the_requested_scale(self, dsqgen): + # Q44 draws its STORE value from rowcount(...), which depends on scale. + small = dsqgen.generate_parameter_log(44, seed=7, scale_factor=1) + large = dsqgen.generate_parameter_log(44, seed=7, scale_factor=10) + + assert small.substitutions != large.substitutions + + @pytest.mark.parametrize("query_id", [0, 100]) + def test_invalid_query_id_raises(self, dsqgen, query_id): + with pytest.raises(ValueError): + dsqgen.generate_parameter_log(query_id) + + def test_negative_stream_raises(self, dsqgen): + with pytest.raises(ValueError): + dsqgen.generate_parameter_log(39, stream_id=-1) diff --git a/tests/unit/tpchavoc/test_cloud_compat.py b/tests/unit/tpchavoc/test_cloud_compat.py new file mode 100644 index 0000000000..124b5a9ec8 --- /dev/null +++ b/tests/unit/tpchavoc/test_cloud_compat.py @@ -0,0 +1,156 @@ +"""Cloud TPC-Havoc variants use executable SQL and declared skips.""" + +from __future__ import annotations + +import duckdb +import pytest +import sqlglot + +from benchbox.core.tpch.benchmark import TPCHBenchmark +from benchbox.core.tpchavoc.benchmark import TPCHavocBenchmark +from benchbox.core.tpchavoc.cloud_compat import BIGQUERY_FILTER_IDS, rewrite_cloud_variant +from benchbox.sql_compat.rules.execution_filter.cloud_tpchavoc import CLOUD_TPCHAVOC_SKIPS +from benchbox.tpchavoc import TPCHavoc + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + + +def test_bigquery_rewrites_all_filtered_aggregates_and_known_subqueries(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + translated = TPCHBenchmark.get_queries(benchmark, dialect="bigquery") + queries = benchmark.get_queries(dialect="bigquery") + + assert len(queries) == 220 + assert {query_id for query_id, query in translated.items() if " FILTER(" in query} == BIGQUERY_FILTER_IDS + assert all(" FILTER(" not in query for query in queries.values()) + assert "COUNTIF(" in queries["1_v6"] + assert "COUNT(DISTINCT IF(" in queries["16_v7"] + for query_id in ("7_v8", "12_v8", "13_v8", "18_v4"): + assert "FROM UNNEST([1])" in queries[query_id] + assert "SUM(`combined`.`revenue`)" in queries["5_v4"] + assert "SUM(`combined`.`value`)" in queries["11_v4"] + assert "OVER (PARTITION BY `l_orderkey` ORDER BY `l_orderkey` NULLS LAST)" not in queries["3_v9"] + assert " FILTER(" not in benchmark.get_query("1_v6", dialect="BigQuery") + + +def test_filtered_aggregate_rewrite_preserves_null_and_distinct_semantics(): + source = """ + SELECT SUM(x) FILTER(WHERE keep), AVG(x) FILTER(WHERE keep), + MIN(x) FILTER(WHERE keep), COUNT(*) FILTER(WHERE keep), + COUNT(x) FILTER(WHERE keep), COUNT(DISTINCT x) FILTER(WHERE keep) + FROM (VALUES (1, TRUE), (1, TRUE), (NULL, TRUE), (2, FALSE), (3, NULL)) AS t(x, keep) + """ + expected = duckdb.sql(source).fetchall() + translated = sqlglot.transpile(source, read="duckdb", write="bigquery")[0] + rewritten = rewrite_cloud_variant("1_v6", translated, "bigquery") + executable = sqlglot.transpile(rewritten, read="bigquery", write="duckdb")[0] + + assert " FILTER(" not in rewritten + assert duckdb.sql(executable).fetchall() == expected + + +def test_q1_v7_native_array_rewrite_parses_but_stays_skipped_until_live_compile(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + queries = {dialect: benchmark.get_query("1_v7", dialect=dialect) for dialect in CLOUD_TPCHAVOC_SKIPS} + + # The BigQuery/Databricks native-array rewrite exists and parses, but a + # parse check cannot catch engine-side limits (see Snowflake 2_v2 002031), + # so the 1_v7 skip stays in both sets until the rewrite compiles live on + # each cloud engine. The Snowflake leg is unimplemented (no native array + # REDUCE exists), so Snowflake input falls through unchanged. + for dialect, query in queries.items(): + assert "1_v7" in CLOUD_TPCHAVOC_SKIPS[dialect] + assert sqlglot.parse_one(query, read=dialect) + for dialect in ("bigquery", "databricks"): + assert "LIST_SUM" not in queries[dialect].upper() + assert "LIST_ZIP" not in queries[dialect].upper() + + # Snowflake has no proven native array reduction: the query must fall + # through unchanged (no REDUCE, no rewritten projections). + assert "REDUCE(" not in queries["snowflake"] + assert "ARRAY_AGG(" in queries["bigquery"] + assert "ORDER BY l_orderkey, l_linenumber" in queries["bigquery"] + assert "FROM UNNEST(" in queries["bigquery"] + assert "SAFE_OFFSET(" in queries["bigquery"] + assert "COLLECT_LIST(" in queries["databricks"] + assert "SORT_ARRAY(" in queries["databricks"] + assert "AGGREGATE(" in queries["databricks"] + assert "TRANSFORM(" in queries["databricks"] + assert "ZIP_WITH(" in queries["databricks"] + + +def test_snowflake_2_v2_stays_skipped_until_live_compile_passes(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + query = benchmark.get_query("2_v2", dialect="snowflake") + + # Live Snowflake compilation rejects the correlated scalar subquery + # (002031/42601, verified 2026-09-26); a sqlglot parse check cannot catch + # that engine-side limit, so the skip stays in both the runtime and + # validator sets until a rewrite compiles live on Snowflake. + assert "2_v2" in CLOUD_TPCHAVOC_SKIPS["snowflake"] + assert sqlglot.parse_one(query, read="snowflake") + assert "SELECT MIN(ps_supplycost)" in query + assert "GROUP BY ps_partkey" in query + + +def test_snowflake_empty_group_rewrite_is_targeted(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + snowflake = benchmark.get_queries(dialect="snowflake") + + assert len(snowflake) == 220 + assert "GROUP BY ()" not in snowflake["6_v2"] + assert "GROUP BY ()" not in snowflake["14_v2"] + assert "HAVING SUM(" in snowflake["6_v2"] + assert benchmark.get_queries(dialect="duckdb")["6_v2"].count("GROUP BY ()") == 1 + + +def test_cloud_skips_flow_through_benchmark_runtime_mapping(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + + for platform in ("BigQuery", "Snowflake", "Databricks"): + assert set(benchmark.get_platform_skip_queries(platform)) == set(CLOUD_TPCHAVOC_SKIPS[platform.lower()]) + assert benchmark.get_platform_skip_queries("DuckDB") == [] + + +def test_public_facade_get_query_forwards_bigquery_dialect(tmp_path): + facade = TPCHavoc(scale_factor=0.1, output_dir=tmp_path) + + translated = facade.get_query("1_v6", dialect="bigquery") + untranslated = facade.get_query("1_v6") + + assert translated != untranslated + assert "COUNTIF(" in translated + + +def test_databricks_reuses_spark_variant_rewrites(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + queries = benchmark.get_queries(dialect="databricks") + + assert len(queries) == 220 + assert "GROUP BY ()" not in queries["6_v2"] + assert "GROUP BY ()" not in queries["14_v2"] + assert "FIRST(" in queries["1_v1"] + assert "dual_col" in queries["17_v4"] + + +def test_registry_describes_runtime_rewrites(): + import benchbox.sql_compat.rules.query_adapter.cloud_tpchavoc_rewrites # noqa: F401 + import benchbox.sql_compat.rules.query_adapter.spark_tpchavoc_rewrites # noqa: F401 + from benchbox.sql_compat.context import Phase + from benchbox.sql_compat.registry import REGISTRY + + registered = { + platform: {key[3] for key, _ in REGISTRY.all_rules() if key[:3] == (Phase.QUERY_ADAPTER, platform, "tpchavoc")} + for platform in ("bigquery", "snowflake", "databricks") + } + assert registered["bigquery"] == BIGQUERY_FILTER_IDS | { + "7_v8", + "12_v8", + "13_v8", + "18_v4", + "5_v4", + "11_v4", + "3_v9", + } + assert registered["snowflake"] == {"6_v2", "14_v2"} + assert len(registered["databricks"]) == 11 diff --git a/tests/unit/tpchavoc/test_dialect_compat.py b/tests/unit/tpchavoc/test_dialect_compat.py new file mode 100644 index 0000000000..f3ecee6d3b --- /dev/null +++ b/tests/unit/tpchavoc/test_dialect_compat.py @@ -0,0 +1,217 @@ +"""Cross-dialect TPC-Havoc query rewrite tests.""" + +from __future__ import annotations + +import pytest +import sqlglot +from sqlglot import exp + +from benchbox.core.dryrun import DryRunExecutor +from benchbox.core.schemas import BenchmarkConfig +from benchbox.core.tpchavoc.benchmark import TPCHavocBenchmark +from benchbox.core.tpchavoc.dialect_compat import ( + CLICKHOUSE_FILTER_VARIANT_IDS, + DATAFUSION_EMPTY_GROUP_VARIANT_IDS, + POSTGRES_ALIAS_VARIANT_IDS, + POSTGRES_DUAL_VARIANT_IDS, + POSTGRES_QUALIFIED_COLUMNS, +) +from benchbox.platforms.clickhouse_local import ClickHouseLocalAdapter +from benchbox.platforms.datafusion import DataFusionAdapter +from benchbox.sql_compat.rules.execution_filter.clickhouse_tpchavoc import CLICKHOUSE_TPCHAVOC_SKIPS +from benchbox.sql_compat.rules.execution_filter.datafusion_tpchavoc import DATAFUSION_TPCHAVOC_SKIPS +from benchbox.sql_compat.rules.execution_filter.postgres_tpchavoc import POSTGRES_TPCHAVOC_SKIPS + +pytestmark = [pytest.mark.unit, pytest.mark.medium] + + +def test_postgres_alias_variants_inline_having_and_where_references(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + + for query_id in POSTGRES_ALIAS_VARIANT_IDS: + query = benchmark.get_query(query_id, dialect="postgres") + tree = sqlglot.parse_one(query, read="postgres") + + assert query_id not in POSTGRES_TPCHAVOC_SKIPS + for select in tree.find_all(exp.Select): + aliases = {projection.alias for projection in select.expressions if isinstance(projection, exp.Alias)} + for clause_name in ("having", "where"): + clause = select.args.get(clause_name) + if clause is None: + continue + assert ( + not { + column.name + for column in clause.find_all(exp.Column) + if not column.table and column.find_ancestor(exp.Select) is select + } + & aliases + ) + + +def test_postgres_alias_rules_cover_each_family_platform(): + import benchbox.sql_compat.rules.query_adapter.postgres_tpchavoc_rewrites # noqa: F401 + from benchbox.sql_compat.context import Phase + from benchbox.sql_compat.registry import REGISTRY + + for platform in ("pg-duckdb", "pg-mooncake", "timescaledb"): + alias_rules = { + key[3] + for key, _ in REGISTRY.all_rules() + if key[:3] == (Phase.QUERY_ADAPTER, platform, "tpchavoc") and key[3] in POSTGRES_ALIAS_VARIANT_IDS + } + qualified_rules = { + key[3] + for key, _ in REGISTRY.all_rules() + if key[:3] == (Phase.QUERY_ADAPTER, platform, "tpchavoc") and key[3] in POSTGRES_QUALIFIED_COLUMNS + } + dual_rules = { + key[3] + for key, _ in REGISTRY.all_rules() + if key[:3] == (Phase.QUERY_ADAPTER, platform, "tpchavoc") and key[3] in POSTGRES_DUAL_VARIANT_IDS + } + assert alias_rules == POSTGRES_ALIAS_VARIANT_IDS + assert qualified_rules == set(POSTGRES_QUALIFIED_COLUMNS) + assert dual_rules == POSTGRES_DUAL_VARIANT_IDS + + +def test_postgres_ambiguous_columns_are_qualified(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + + for query_id, expected in POSTGRES_QUALIFIED_COLUMNS.items(): + query = benchmark.get_query(query_id, dialect="postgres") + tree = sqlglot.parse_one(query, read="postgres") + + assert query_id not in POSTGRES_TPCHAVOC_SKIPS + assert not {column.name for column in tree.find_all(exp.Column) if not column.table and column.name in expected} + for column_name, table in expected.items(): + assert any(column.name == column_name and column.table == table for column in tree.find_all(exp.Column)) + + +def test_postgres_dual_variant_uses_values_source(): + query = TPCHavocBenchmark(scale_factor=0.1).get_query("17_v4", dialect="postgres") + + assert "17_v4" not in POSTGRES_TPCHAVOC_SKIPS + assert "(VALUES (1)) AS dual(dual_col)" in query + assert "FROM (SELECT 1) AS dual" not in query + assert sqlglot.parse_one(query, read="postgres") + + +def test_postgres_alias_rewrite_preserves_order_by_aliases(): + query = TPCHavocBenchmark(scale_factor=0.1).get_query("3_v7", dialect="postgres") + + assert "HAVING SUM(" in query + assert "ORDER BY revenue DESC" in query + + +def test_clickhouse_filtered_aggregates_use_native_combinators(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + + for query_id in CLICKHOUSE_FILTER_VARIANT_IDS: + query = benchmark.get_query(query_id, dialect="clickhouse") + + assert " FILTER(" not in query + assert query_id not in CLICKHOUSE_TPCHAVOC_SKIPS + assert sqlglot.parse_one(query, read="clickhouse") + + q1 = benchmark.get_query("1_v6", dialect="clickhouse") + q12 = benchmark.get_query("12_v7", dialect="clickhouse") + assert "sumOrNullIf(" in q1 + assert "avgOrNullIf(" in q1 + assert "countIf(" in q1 + assert q12.count("countIf(") == 2 + + +def test_clickhouse_filter_rewrite_rejects_unhandled_aggregate_shapes(): + from benchbox.core.tpchavoc.dialect_compat import _rewrite_clickhouse_filters + + # Only COUNT(*), SUM, and AVG have ClickHouse combinator mappings. Any + # other FILTER shape (COUNT(column), MIN, MAX, ...) must fail loudly so a + # future scope extension cannot silently ship an unrewritten FILTER. + for aggregate in ("COUNT(l_orderkey)", "MIN(l_orderkey)", "MAX(l_orderkey)"): + with pytest.raises(NotImplementedError, match="ClickHouse FILTER rewrite handles only"): + _rewrite_clickhouse_filters(f"SELECT {aggregate} FILTER (WHERE l_orderkey > 0) FROM lineitem") + + +def test_clickhouse_filter_rules_cover_each_deployment_mode(): + import benchbox.sql_compat.rules.query_adapter.clickhouse_tpchavoc_rewrites # noqa: F401 + from benchbox.sql_compat.context import Phase + from benchbox.sql_compat.registry import REGISTRY + + for platform in ("clickhouse-local", "clickhouse-server", "clickhouse-cloud"): + registered = { + key[3] + for key, _ in REGISTRY.all_rules() + if key[:3] == (Phase.QUERY_ADAPTER, platform, "tpchavoc") and key[3] in CLICKHOUSE_FILTER_VARIANT_IDS + } + assert registered == CLICKHOUSE_FILTER_VARIANT_IDS + + +def test_datafusion_empty_group_variants_drop_grouping_and_unskip(): + benchmark = TPCHavocBenchmark(scale_factor=0.1) + + for query_id in DATAFUSION_EMPTY_GROUP_VARIANT_IDS: + query = benchmark.get_query(query_id, dialect="datafusion") + + assert "GROUP BY ()" not in query + assert " HAVING " in query + assert query_id not in DATAFUSION_TPCHAVOC_SKIPS + assert sqlglot.parse_one(query, read="postgres") + + +def test_datafusion_empty_group_rules_cover_both_variants(): + import benchbox.sql_compat.rules.query_adapter.datafusion_tpchavoc_rewrites # noqa: F401 + from benchbox.sql_compat.context import Phase + from benchbox.sql_compat.registry import REGISTRY + + registered = { + key[3] + for key, _ in REGISTRY.all_rules() + if key[:3] == (Phase.QUERY_ADAPTER, "datafusion", "tpchavoc") and key[3] in DATAFUSION_EMPTY_GROUP_VARIANT_IDS + } + assert registered == DATAFUSION_EMPTY_GROUP_VARIANT_IDS + + +@pytest.mark.parametrize( + ("adapter", "query_ids", "required_fragments", "forbidden_fragment"), + [ + ( + DataFusionAdapter(), + ["6_v2", "14_v2"], + [" HAVING "], + "GROUP BY ()", + ), + ( + ClickHouseLocalAdapter(), + ["1_v6", "12_v7"], + ["sumOrNullIf(", "countIf("], + " FILTER(", + ), + ], +) +def test_dry_run_uses_production_dialect_rewrites_and_query_subset( + adapter, + query_ids: list[str], + required_fragments: list[str], + forbidden_fragment: str, +): + benchmark = TPCHavocBenchmark(scale_factor=0.01) + config = BenchmarkConfig( + name="tpchavoc", + display_name="TPC-Havoc", + scale_factor=0.01, + queries=query_ids, + test_execution_type="power", + ) + + queries = DryRunExecutor()._extract_queries( + benchmark, + config, + platform_adapter=adapter, + execution_mode="sql", + ) + + assert list(queries) == query_ids + rendered = "\n".join(queries.values()) + assert forbidden_fragment not in rendered + assert all(fragment in rendered for fragment in required_fragments) diff --git a/tests/unit/utils/format_converters/test_vortex_converter_coverage.py b/tests/unit/utils/format_converters/test_vortex_converter_coverage.py index 416712a949..f0cda4b456 100644 --- a/tests/unit/utils/format_converters/test_vortex_converter_coverage.py +++ b/tests/unit/utils/format_converters/test_vortex_converter_coverage.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins from pathlib import Path from types import SimpleNamespace @@ -17,8 +18,12 @@ def test_get_vortex_module_raises_helpful_error(monkeypatch): converter = VortexConverter() - def _raise(*_args, **_kwargs): - raise ImportError("missing vortex") + real_import = builtins.__import__ + + def _raise(name, *args, **kwargs): + if name == "vortex": + raise ImportError("missing vortex") + return real_import(name, *args, **kwargs) monkeypatch.setattr("builtins.__import__", _raise) diff --git a/tests/unit/utils/test_binary_manifest.py b/tests/unit/utils/test_binary_manifest.py new file mode 100644 index 0000000000..629bdabbc9 --- /dev/null +++ b/tests/unit/utils/test_binary_manifest.py @@ -0,0 +1,253 @@ +"""Check the shipped manifest against tampered trees and distribution archives.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import stat +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from benchbox.utils.binary_manifest import ( + DEFAULT_ROOT, + MANIFEST_NAME, + build_binary_manifest, + main, + read_binary_manifest, + verify_binary_tree, +) +from scripts.verify_distribution_binaries import verify_distribution_binaries + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +@pytest.fixture +def binary_root(tmp_path: Path) -> Path: + root = tmp_path / "source" + (root / "linux").mkdir(parents=True) + (root / "linux" / "dbgen").write_bytes(b"generator bytes") + (root / "dists.dss").write_bytes(b"distributions\n") + (root / MANIFEST_NAME).write_bytes(build_binary_manifest(root)) + return root + + +def test_shipped_manifest_covers_the_real_package() -> None: + verify_binary_tree(DEFAULT_ROOT) + assert build_binary_manifest(DEFAULT_ROOT) == (DEFAULT_ROOT / MANIFEST_NAME).read_bytes() + + +def test_installed_verification_respects_quiet_output(binary_root: Path, monkeypatch, capsys) -> None: + from benchbox.utils import printing + + monkeypatch.setattr(printing, "_QUIET", True) + assert main(["--root", str(binary_root)]) == 0 + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize("case", ["missing", "extra", "tampered", "missing-manifest", "linked"]) +def test_tree_rejects_membership_and_byte_changes( + binary_root: Path, monkeypatch: pytest.MonkeyPatch, case: str +) -> None: + binary = binary_root / "linux" / "dbgen" + if case == "missing": + binary.unlink() + elif case == "extra": + (binary_root / "unexpected").write_bytes(b"extra") + elif case == "tampered": + binary.write_bytes(b"generator bytex") + elif case == "missing-manifest": + (binary_root / MANIFEST_NAME).unlink() + else: + original = Path.is_symlink + monkeypatch.setattr(Path, "is_symlink", lambda path: path == binary or original(path)) + with pytest.raises(ValueError): + verify_binary_tree(binary_root) + + +@pytest.mark.parametrize( + "payload", + [ + {"schema": True, "files": {"dbgen": "a" * 64}}, + {"schema": 2, "files": {"dbgen": "a" * 64}}, + {"schema": 1, "files": {}}, + {"schema": 1, "files": {"../dbgen": "a" * 64}}, + {"schema": 1, "files": {"/dbgen": "a" * 64}}, + {"schema": 1, "files": {"./dbgen": "a" * 64}}, + {"schema": 1, "files": {"linux//dbgen": "a" * 64}}, + {"schema": 1, "files": {"C:/dbgen": "a" * 64}}, + {"schema": 1, "files": {"linux\\dbgen": "a" * 64}}, + {"schema": 1, "files": {MANIFEST_NAME: "a" * 64}}, + {"schema": 1, "files": {"dbgen": "invalid"}}, + ], +) +def test_manifest_rejects_invalid_schema_paths_and_hashes(payload: dict) -> None: + with pytest.raises(ValueError): + read_binary_manifest(json.dumps(payload).encode()) + + +def test_manifest_rejects_duplicate_json_keys() -> None: + payload = '{"schema":1,"files":{"dbgen":"' + "a" * 64 + '","dbgen":"' + "b" * 64 + '"}}' + with pytest.raises(ValueError, match="duplicate"): + read_binary_manifest(payload.encode()) + + +def _write_distribution( + destination: Path, + files: dict[str, bytes], + link: bool = False, + duplicate: bool = False, + extra_name: str | None = None, + extra_type: int = stat.S_IFREG, + extra_payload: bytes = b"evil", + binary_mode: int = 0o644, +) -> None: + if destination.suffix == ".whl": + with zipfile.ZipFile(destination, "w") as archive: + for name, payload in files.items(): + entry = zipfile.ZipInfo("benchbox/_binaries/" + name) + entry.create_system = 3 + file_type = stat.S_IFLNK if link and name == "linux/dbgen" else stat.S_IFREG + mode = binary_mode if name == "linux/dbgen" else 0o644 + entry.external_attr = (file_type | mode) << 16 + archive.writestr(entry, payload) + if duplicate: + with pytest.warns(UserWarning, match="Duplicate name"): + archive.writestr("benchbox/_binaries/linux/dbgen", files["linux/dbgen"]) + if extra_name is not None: + entry = zipfile.ZipInfo(extra_name) + entry.create_system = 3 + entry.external_attr = extra_type << 16 + archive.writestr(entry, extra_payload) + else: + with tarfile.open(destination, "w:gz") as archive: + prefix = destination.name.removesuffix(".tar.gz") + "/benchbox/_binaries/" + for name, payload in files.items(): + entry = tarfile.TarInfo(prefix + name) + entry.size = len(payload) + entry.mode = binary_mode if name == "linux/dbgen" else 0o644 + if link and name == "linux/dbgen": + entry.type = tarfile.SYMTYPE + entry.linkname = "/outside" + archive.addfile(entry, io.BytesIO(payload)) + if duplicate: + entry = tarfile.TarInfo(prefix + "linux/dbgen") + entry.size = len(files["linux/dbgen"]) + archive.addfile(entry, io.BytesIO(files["linux/dbgen"])) + if extra_name is not None: + entry = tarfile.TarInfo(extra_name) + entry.size = len(extra_payload) + if extra_type == stat.S_IFIFO: + entry.type = tarfile.FIFOTYPE + archive.addfile(entry, io.BytesIO(extra_payload)) + + +@pytest.mark.parametrize("extension", ["whl", "tar.gz"]) +@pytest.mark.parametrize("case", ["complete", "missing", "extra", "tampered", "forged-manifest", "link", "duplicate"]) +def test_distribution_uses_trusted_source_manifest( + binary_root: Path, tmp_path: Path, extension: str, case: str +) -> None: + files = { + path.relative_to(binary_root).as_posix(): path.read_bytes() for path in binary_root.rglob("*") if path.is_file() + } + if case == "missing": + del files["linux/dbgen"] + elif case == "extra": + files["unexpected"] = b"extra" + elif case in {"tampered", "forged-manifest"}: + files["linux/dbgen"] = b"generator bytex" + if case == "forged-manifest": + manifest = json.loads(files[MANIFEST_NAME]) + manifest["files"]["linux/dbgen"] = hashlib.sha256(files["linux/dbgen"]).hexdigest() + files[MANIFEST_NAME] = json.dumps(manifest, indent=2, sort_keys=True).encode() + b"\n" + distribution = tmp_path / ("benchbox-1.2.3." + extension) + _write_distribution(distribution, files, link=case == "link", duplicate=case == "duplicate") + if case == "complete": + verify_distribution_binaries(distribution, binary_root) + else: + with pytest.raises(ValueError): + verify_distribution_binaries(distribution, binary_root) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX executable permissions require a POSIX source filesystem") +@pytest.mark.parametrize("extension", ["whl", "tar.gz"]) +@pytest.mark.parametrize("mode", [0o755, 0o644, 0o654]) +def test_distribution_preserves_generator_execute_permissions( + binary_root: Path, tmp_path: Path, extension: str, mode: int +) -> None: + binary = binary_root / "linux" / "dbgen" + binary.chmod(0o755) + files = { + path.relative_to(binary_root).as_posix(): path.read_bytes() for path in binary_root.rglob("*") if path.is_file() + } + distribution = tmp_path / ("benchbox-1.2.3." + extension) + _write_distribution(distribution, files, binary_mode=mode) + if mode == 0o755: + verify_distribution_binaries(distribution, binary_root) + else: + with pytest.raises(ValueError, match="execute permissions"): + verify_distribution_binaries(distribution, binary_root) + + +@pytest.mark.parametrize("extension", ["whl", "tar.gz"]) +@pytest.mark.parametrize( + "case", + ["dot", "slash", "backslash", "parent", "absolute", "case", "trailing-dot", "trailing-slash", "relocation", "fifo"], +) +def test_archive_rejects_alias_overwrites_and_nonregular_members( + binary_root: Path, tmp_path: Path, extension: str, case: str +) -> None: + files = { + path.relative_to(binary_root).as_posix(): path.read_bytes() for path in binary_root.rglob("*") if path.is_file() + } + distribution = tmp_path / ("benchbox-1.2.3." + extension) + prefix = "" if extension == "whl" else "benchbox-1.2.3/" + name = prefix + "benchbox/_binaries/linux/dbgen" + extra_payload = b"evil" + if case == "dot": + name = "./" + name + elif case == "slash": + name = name.replace("benchbox/", "benchbox//") + elif case == "backslash": + name = name.replace("/", "\\") + elif case == "parent": + name = prefix + "benchbox/other/../_binaries/linux/dbgen" + elif case == "absolute": + name = "/" + name + elif case == "case": + name = name.replace("benchbox/_binaries", "BENCHBOX/_BINARIES") + elif case == "trailing-dot": + name += "." + elif case == "trailing-slash": + name += "/" + extra_payload = files.pop("linux/dbgen") + elif case == "relocation": + name = prefix + "benchbox-1.2.3.data/purelib/benchbox/_binaries/linux/dbgen" + else: + files.pop("linux/dbgen") + _write_distribution( + distribution, + files, + extra_name=name, + extra_type=stat.S_IFIFO if case == "fifo" else stat.S_IFREG, + extra_payload=extra_payload, + ) + with pytest.raises(ValueError): + verify_distribution_binaries(distribution, binary_root) + + +@pytest.mark.parametrize("name,file_type", [("benchbox/", stat.S_IFREG), ("benchbox", stat.S_IFDIR)]) +def test_wheel_rejects_contradictory_directory_types( + binary_root: Path, tmp_path: Path, name: str, file_type: int +) -> None: + files = { + path.relative_to(binary_root).as_posix(): path.read_bytes() for path in binary_root.rglob("*") if path.is_file() + } + distribution = tmp_path / "benchbox-1.2.3.whl" + _write_distribution(distribution, files, extra_name=name, extra_type=file_type) + with pytest.raises(ValueError): + verify_distribution_binaries(distribution, binary_root) diff --git a/tests/unit/utils/test_cloud_staging_construction_injection.py b/tests/unit/utils/test_cloud_staging_construction_injection.py new file mode 100644 index 0000000000..af14a281c9 --- /dev/null +++ b/tests/unit/utils/test_cloud_staging_construction_injection.py @@ -0,0 +1,151 @@ +"""Benchmark constructors must not stringify cloud staging handlers. + +Several benchmarks stored the orchestrator-resolved ``output_dir`` via +``Path(...)``. A ``CloudStagingPath``/``DatabricksPath`` stringifies to its +local cache directory, so the cloud upload target resolved at construction +time was silently discarded: datagen ran locally and the adapter never +learned the remote destination. These tests pin the construction-time +injection edge: passing an already-built handler into each benchmark keeps +the handler (and its target) intact, while plain strings still become +``Path``. + +Copyright 2026 Joe Harris / BenchBox Project + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +from pathlib import Path + +import pytest + +from benchbox.utils.cloud_storage import ( + CloudStagingPath, + DatabricksPath, + normalize_output_dir, +) + +pytestmark = [ + pytest.mark.unit, + pytest.mark.fast, +] + + +class TestNormalizeOutputDir: + """The shared coercion used by the converted constructors.""" + + def test_none_stays_none(self): + """None means not configured yet; the runner rejects it later.""" + assert normalize_output_dir(None) is None + + def test_cloud_staging_handler_passes_through(self): + """The defect: Path(...) would reduce this to its local cache.""" + staging = CloudStagingPath("/tmp/cache", "s3://bucket/benchbox") + assert normalize_output_dir(staging) is staging + + def test_databricks_handler_passes_through(self): + """Same edge for the Databricks wrapper.""" + staging = DatabricksPath("/tmp/cache", "dbfs:/Volumes/c/s/v") + assert normalize_output_dir(staging) is staging + + def test_plain_path_passes_through(self): + """Local runs keep their existing behaviour.""" + local = Path("/tmp/cache") + assert normalize_output_dir(local) is local + + def test_plain_string_becomes_path(self): + """Strings still coerce to Path.""" + result = normalize_output_dir("/tmp/cache") + assert result == Path("/tmp/cache") + assert isinstance(result, Path) + + +class TestBenchmarkConstructorsPreserveHandlers: + """Each converted benchmark keeps the handler it is constructed with.""" + + @staticmethod + def _staging(): + return CloudStagingPath("/tmp/cache", "s3://bucket/benchbox") + + def test_ai_primitives(self): + """AIPrimitivesBenchmark reuses TPC-H data under cloud staging.""" + from benchbox.core.ai_primitives.benchmark import AIPrimitivesBenchmark + + staging = self._staging() + benchmark = AIPrimitivesBenchmark(scale_factor=0.01, output_dir=staging) + assert benchmark.output_dir is staging + + def test_datavault(self): + """DataVaultBenchmark must not reduce the handler to Path.""" + from benchbox.core.datavault.benchmark import DataVaultBenchmark + + staging = self._staging() + benchmark = DataVaultBenchmark(scale_factor=0.01, output_dir=staging) + assert benchmark.output_dir is staging + + def test_tpcds_obt(self): + """TPCDSOBTBenchmark keeps both the OBT and source handlers.""" + from benchbox.core.tpcds_obt.benchmark import TPCDSOBTBenchmark + + staging = self._staging() + source = CloudStagingPath("/tmp/tpcds", "s3://bucket/tpcds") + benchmark = TPCDSOBTBenchmark(scale_factor=1.0, output_dir=staging, tpcds_source_dir=source) + assert benchmark.output_dir is staging + assert benchmark.tpcds_source_dir is source + + def test_transaction_primitives(self): + """TransactionPrimitivesBenchmark forwards the handler to its generator.""" + from benchbox.core.transaction_primitives.benchmark import TransactionPrimitivesBenchmark + + staging = self._staging() + benchmark = TransactionPrimitivesBenchmark(scale_factor=0.01, output_dir=staging) + assert benchmark.output_dir is staging + + def test_write_primitives(self): + """WritePrimitivesBenchmark forwards the handler to its generator.""" + from benchbox.core.write_primitives.benchmark import WritePrimitivesBenchmark + + staging = self._staging() + benchmark = WritePrimitivesBenchmark(scale_factor=0.01, output_dir=staging) + assert benchmark.output_dir is staging + + def test_tpcdi_config(self): + """TPCDIConfig post-init must not reduce the handler to Path.""" + from benchbox.core.tpcdi.config import TPCDIConfig + + staging = self._staging() + config = TPCDIConfig(scale_factor=1.0, output_dir=staging) + assert config.output_dir is staging + + def test_tpch_skew_generator(self): + """TPCHSkewDataGenerator keeps the handler for its file layout.""" + from benchbox.core.tpch_skew.generator import TPCHSkewDataGenerator + + staging = self._staging() + generator = TPCHSkewDataGenerator(scale_factor=0.01, output_dir=staging) + assert generator.output_dir is staging + + def test_tsbs_devops_generator(self): + """TSBSDevOpsDataGenerator keeps the handler for its file layout.""" + from benchbox.core.tsbs_devops.generator import TSBSDevOpsDataGenerator + + staging = self._staging() + generator = TSBSDevOpsDataGenerator(scale_factor=0.01, output_dir=staging) + assert generator.output_dir is staging + + def test_tsbs_devops_benchmark(self): + """TSBSDevOpsBenchmark keeps the handler on both itself and its generator.""" + from benchbox.core.tsbs_devops.benchmark import TSBSDevOpsBenchmark + + staging = self._staging() + benchmark = TSBSDevOpsBenchmark(scale_factor=1.0, output_dir=staging) + assert benchmark.output_dir is staging + assert benchmark.data_generator.output_dir is staging + assert benchmark.data_generator.output_dir.cloud_target == staging.cloud_target + + def test_databricks_target_survives_construction(self): + """The Databricks target must reach the adapter, not just the type.""" + from benchbox.core.datavault.benchmark import DataVaultBenchmark + + staging = DatabricksPath("/tmp/cache", "dbfs:/Volumes/c/s/v") + benchmark = DataVaultBenchmark(scale_factor=0.01, output_dir=staging) + assert benchmark.output_dir.dbfs_target == "dbfs:/Volumes/c/s/v" diff --git a/tests/unit/utils/test_data_validation.py b/tests/unit/utils/test_data_validation.py index 0ad74943ca..da2beef20e 100644 --- a/tests/unit/utils/test_data_validation.py +++ b/tests/unit/utils/test_data_validation.py @@ -4,11 +4,12 @@ Licensed under the MIT License. See LICENSE file in the project root for details. """ -from datetime import datetime +from datetime import datetime, timezone, tzinfo from pathlib import Path import pytest +import benchbox.utils.data_validation as data_validation from benchbox.utils.data_validation import ( BenchmarkDataValidator, DataValidationResult, @@ -17,6 +18,20 @@ pytestmark = [pytest.mark.unit, pytest.mark.fast] +NOW = datetime(2026, 1, 15, 12, 0, 0) + + +@pytest.fixture(autouse=True) +def _validation_wallclock(monkeypatch: pytest.MonkeyPatch) -> None: + """Control this validator's event timestamps without stopping watchdogs.""" + + class FixedDatetime(datetime): + @classmethod + def now(cls, tz: tzinfo | None = None) -> datetime: + return NOW if tz is None else NOW.replace(tzinfo=timezone.utc).astimezone(tz) + + monkeypatch.setattr(data_validation, "datetime", FixedDatetime) + # --------------------------------------------------------------------------- # DataValidationResult dataclass @@ -27,7 +42,7 @@ class TestDataValidationResult: """Tests for the DataValidationResult dataclass.""" def test_fields_are_stored(self): - now = datetime.now() + now = NOW result = DataValidationResult( valid=True, tables_validated={"orders": True, "lineitem": True}, @@ -52,7 +67,7 @@ def test_invalid_result_with_issues(self): missing_tables=["lineitem"], row_count_mismatches={"orders": (1500000, 999)}, file_size_info={}, - validation_timestamp=datetime.now(), + validation_timestamp=NOW, issues=["Missing data files for table lineitem"], ) assert result.valid is False @@ -358,12 +373,10 @@ def test_invalid_data_triggers_regeneration(self, tmp_path): class TestValidationTimestamp: """Tests that validation timestamps are populated.""" - def test_timestamp_is_recent(self, tmp_path): - before = datetime.now() + def test_timestamp_uses_consumer_clock(self, tmp_path): v = BenchmarkDataValidator("tpch", scale_factor=0.01) result = v.validate_data_directory(tmp_path) - after = datetime.now() - assert before <= result.validation_timestamp <= after + assert result.validation_timestamp == NOW # --------------------------------------------------------------------------- @@ -393,3 +406,36 @@ def test_gigabytes(self): v = BenchmarkDataValidator("tpch") formatted = v._format_bytes(1073741824) assert "GB" in formatted + + +# --------------------------------------------------------------------------- +# Reuse boundary: a manifest that lists no tables +# --------------------------------------------------------------------------- + + +class TestEmptyManifestIsNotReusable: + """A manifest naming zero tables must force regeneration, not reuse.""" + + @staticmethod + def _write_empty_manifest(data_dir: Path, benchmark: str) -> None: + import json + + from benchbox.utils.datagen_version import current_datagen_stamp + + manifest = { + "benchmark": benchmark, + "scale_factor": 1.0, + "tables": {}, + **current_datagen_stamp(benchmark), + } + (data_dir / "_datagen_manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + @pytest.mark.parametrize("benchmark_name", ["tpch", "tpcds", "custom_bench"]) + def test_empty_manifest_with_data_files_is_invalid(self, tmp_path, benchmark_name): + (tmp_path / "customer.tbl.zst").write_bytes(b"\x28\xb5\x2f\xfd" + b"x" * 64) + self._write_empty_manifest(tmp_path, benchmark_name) + + result = BenchmarkDataValidator(benchmark_name, scale_factor=1.0).validate_data_directory(tmp_path) + + assert result.valid is False + assert any("manifest" in issue.lower() for issue in result.issues), result.issues diff --git a/tests/unit/utils/test_dependencies.py b/tests/unit/utils/test_dependencies.py index 61ee6a62be..1b57d0bac8 100644 --- a/tests/unit/utils/test_dependencies.py +++ b/tests/unit/utils/test_dependencies.py @@ -8,6 +8,8 @@ Licensed under the MIT License. See LICENSE file in the project root for details. """ +import os +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -264,6 +266,29 @@ def test_matrix_rows_align_with_scenarios(self): class TestCheckPlatformDependencies: """Test the check_platform_dependencies function.""" + @pytest.mark.parametrize("error_type", [None, ImportError, OSError]) + def test_probe_restores_import_cwd(self, tmp_path, error_type): + original_cwd = Path.cwd() + + def import_with_native_side_effect(module_name): + assert module_name == "chdb" + os.chdir(tmp_path) + if error_type is not None: + raise error_type("native library unavailable") + return MagicMock() + + try: + with patch("builtins.__import__", side_effect=import_with_native_side_effect): + if error_type is OSError: + with pytest.raises(OSError, match="native library unavailable"): + check_platform_dependencies("clickhouse-local", ["chdb"]) + else: + result = check_platform_dependencies("clickhouse-local", ["chdb"]) + assert result == (error_type is None, [] if error_type is None else ["chdb"]) + assert Path.cwd() == original_cwd + finally: + os.chdir(original_cwd) + def test_check_available_packages(self): """Test checking packages that are available.""" with patch("builtins.__import__") as mock_import: @@ -606,3 +631,22 @@ def test_get_install_command_databricks_connect_uses_replacement_extra(self): cmd = get_install_command("databricks-connect") assert cmd == 'uv pip install "benchbox[cloud-spark-databricks]"' + + @pytest.mark.parametrize("platform", ["timescaledb", "pg-duckdb", "pg-mooncake", "paradedb", "citus", "cedardb"]) + def test_get_install_command_psycopg_platforms_use_the_postgresql_extra(self, platform: str): + """These platforms only need psycopg and have no extra of their own.""" + import tomllib + + pyproject = Path(__file__).resolve().parents[3] / "pyproject.toml" + extras = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["optional-dependencies"] + assert "postgresql" in extras + assert platform not in extras + + with ( + patch("benchbox.utils.dependencies.is_development_install", return_value=True), + patch("benchbox.utils.dependencies.is_uv_tool_environment", return_value=False), + ): + assert get_install_command(platform) == "uv sync --extra postgresql" + + with patch("benchbox.utils.dependencies.is_development_install", return_value=False): + assert get_install_command(platform) == 'uv pip install "benchbox[postgresql]"' diff --git a/tests/unit/utils/test_dialect_utils.py b/tests/unit/utils/test_dialect_utils.py index 73e489d3c3..da31bffebf 100644 --- a/tests/unit/utils/test_dialect_utils.py +++ b/tests/unit/utils/test_dialect_utils.py @@ -24,6 +24,310 @@ ] +class TestSQLiteDiscountBoundaries: + def test_qualified_nested_bounds_and_translation_metadata(self): + import sqlglot + + query = ( + "SELECT 0.06 + 0.01 FROM lineitem AS l " + "WHERE l.l_discount BETWEEN (0.06 - 0.02 + 0.01) AND (0.06 + 0.02 - 0.01)" + ) + with sql_translation_context(strict=True) as outcomes: + translated = translate_sql_query(query, target_dialect="sqlite") + expected = "SELECT 0.06 + 0.01 FROM lineitem AS l WHERE l.l_discount BETWEEN 0.05 AND 0.07" + expected_tree = sqlglot.parse_one(expected, read="sqlite") + assert sqlglot.parse_one(translated, read="sqlite") == sqlglot.parse_one( + expected_tree.sql(dialect="sqlite", identify=True), read="sqlite" + ) + assert len(outcomes) == 1 + assert outcomes[0].status == "success" + assert outcomes[0].normalized_target_dialect == "sqlite" + assert outcomes[0].query_fingerprint + + @pytest.mark.parametrize("center, low, high", [("0.06", "0.05", "0.07"), ("0.07", "0.06", "0.08")]) + def test_only_discount_bounds_are_folded(self, center, low, high): + import sqlglot + from sqlglot import exp + + query = ( + f"SELECT 0.06 + 0.01 AS untouched FROM lineitem " + f"WHERE l_discount BETWEEN {center} - 0.01 AND {center} + 0.01 AND l_quantity < 25" + ) + actual = sqlglot.parse_one(translate_sql_query(query, target_dialect="sqlite"), read="sqlite") + expected = sqlglot.parse_one( + f"SELECT 0.06 + 0.01 AS untouched FROM lineitem " + f"WHERE l_discount BETWEEN {low} AND {high} AND l_quantity < 25", + read="sqlite", + ) + assert actual == sqlglot.parse_one(expected.sql(dialect="sqlite", identify=True), read="sqlite") + between = actual.find(exp.Between) + assert isinstance(between.args["low"], exp.Literal) + assert isinstance(between.args["high"], exp.Literal) + + @pytest.mark.parametrize( + "predicate", + [ + "l_discount BETWEEN center - 0.01 AND 0.06 + 0.01", + "l_discount BETWEEN 0.06 - 0.01 AND center + 0.01", + "other_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01", + "l_discount > 0.06 + 0.01", + "l_discount BETWEEN '0.06' - 0.01 AND '0.06' + 0.01", + "l_discount BETWEEN 0.06 * 0.01 AND 0.06 + 0.01", + "l_discount BETWEEN 0.05 AND 0.06 + 0.01", + "l_discount BETWEEN 0.06 - 0.01 AND 0.07", + "l_discount BETWEEN 1 - 1 AND 1 + 1", + "l_discount BETWEEN 0.06 - 1 AND 0.06 + 1", + "l_discount BETWEEN (9223372036854775807 + 1 - 9223372036854775807) AND (1 + 1)", + "l_discount BETWEEN 0.01 - 0.06 AND 0.06 + 0.01", + "l_discount BETWEEN 6e-2 - 1e-2 AND 6e-2 + 1e-2", + "l_discount BETWEEN (10000000000000000000000000000.0 + 9.0 - 10000000000000000000000000000.0) AND (20.0 + 0.0)", + "l_discount BETWEEN (100000000.0 + 0.0000000001 - 100000000.0) AND (0.06 + 0.01)", + f"l_discount BETWEEN (1{'0' * 400}.0 - 1{'0' * 400}.0) AND (0.06 + 0.01)", + "l_discount BETWEEN 0.01 + 0.01 + 0.01 + 0.01 + 0.01 AND 0.06 + 0.01", + "l_discount BETWEEN 0.0000001 + 0.0000001 AND 0.06 + 0.01", + "l_discount BETWEEN 100.0 - 99.0 AND 0.06 + 0.01", + ], + ) + def test_unrelated_or_nonliteral_expressions_are_unchanged(self, predicate): + from benchbox.utils.dialect_utils import _fix_sqlite_unsupported_syntax + + query = f"SELECT 0.06 + 0.01 FROM lineitem WHERE {predicate}" + assert _fix_sqlite_unsupported_syntax(query) == query + + def test_integer_overflow_bounds_keep_sqlite_semantics(self): + import sqlite3 + + query = ( + "WITH lineitem AS (SELECT 0 AS l_discount) SELECT * FROM lineitem " + "WHERE l_discount BETWEEN (9223372036854775807 + 1 - 9223372036854775807) AND (1 + 1)" + ) + connection = sqlite3.connect(":memory:") + try: + # SQLite overflows to REAL here, so the lower bound is 0.0 and the row matches. + expected = connection.execute(query).fetchall() + assert expected == [(0,)] + translated = translate_sql_query(query, target_dialect="sqlite") + assert connection.execute(translated).fetchall() == expected + finally: + connection.close() + + def test_bare_dot_decimal_bounds_are_folded(self): + import sqlite3 + + # SQLGlot reads `.06` as `0.06`, so this has the same boundary defect as the public Q6 text. + query = "SELECT l_discount FROM lineitem WHERE l_discount BETWEEN .06 - .01 AND .06 + .01 ORDER BY l_discount" + connection = sqlite3.connect(":memory:") + try: + connection.execute("CREATE TABLE lineitem (l_discount DECIMAL(15,2))") + connection.executemany("INSERT INTO lineitem VALUES (?)", [(v,) for v in ("0.04", "0.05", "0.06", "0.07")]) + assert connection.execute(query).fetchall() == [(0.05,), (0.06,)] + translated = translate_sql_query(query, target_dialect="sqlite") + assert connection.execute(translated).fetchall() == [(0.05,), (0.06,), (0.07,)] + finally: + connection.close() + + def test_two_decimal_discount_domain_matches_exact_decimal_semantics(self): + import sqlite3 + from decimal import Decimal + + from benchbox.utils.dialect_utils import _fix_sqlite_unsupported_syntax + + values = [f"{cents / 100:.2f}" for cents in range(101)] + connection = sqlite3.connect(":memory:") + try: + connection.execute("CREATE TABLE lineitem (l_discount DECIMAL(15,2))") + connection.executemany("INSERT INTO lineitem VALUES (?)", [(value,) for value in values]) + original_wrong = rewritten_wrong = checked = 0 + for cents in range(101): + for delta in ("0.01", "0.02", "0.05"): + low, high = ( + Decimal(f"{cents / 100:.2f}") - Decimal(delta), + Decimal(f"{cents / 100:.2f}") + Decimal(delta), + ) + if low < 0: + continue + query = ( + f"SELECT l_discount FROM lineitem WHERE l_discount BETWEEN {cents / 100:.2f} - {delta} " + f"AND {cents / 100:.2f} + {delta} ORDER BY l_discount" + ) + expected = [float(value) for value in values if low <= Decimal(value) <= high] + checked += 1 + original_wrong += [row[0] for row in connection.execute(query).fetchall()] != expected + rewritten = _fix_sqlite_unsupported_syntax(query) + rewritten_wrong += [row[0] for row in connection.execute(rewritten).fetchall()] != expected + assert checked > 250 + assert original_wrong > 0, "the sweep must exercise the SQLite boundary defect" + assert rewritten_wrong == 0 + finally: + connection.close() + + @pytest.mark.parametrize( + "query", + [ + "SELECT * FROM orders WHERE l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01", + "SELECT * FROM t WHERE l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01", + # Mentioning the table in a string or comment is not reading it. + "SELECT 'lineitem' AS note FROM t WHERE l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01", + "SELECT * FROM t /* lineitem */ WHERE l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01", + ], + ) + def test_queries_that_do_not_read_lineitem_are_not_folded(self, query): + from benchbox.utils.dialect_utils import _fix_sqlite_unsupported_syntax + + assert _fix_sqlite_unsupported_syntax(query) == query + + def test_a_query_that_reads_lineitem_is_folded_in_a_join_and_a_subquery(self): + from benchbox.utils.dialect_utils import _fix_sqlite_unsupported_syntax + + joined = ( + "SELECT 1 FROM orders o JOIN lineitem l ON l.l_orderkey = o.o_orderkey " + "WHERE l.l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01" + ) + assert "BETWEEN 0.05 AND 0.07" in _fix_sqlite_unsupported_syntax(joined) + nested = "SELECT * FROM (SELECT l_discount FROM lineitem) WHERE l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01" + assert "BETWEEN 0.05 AND 0.07" in _fix_sqlite_unsupported_syntax(nested) + + def test_value_inside_the_double_noise_band_follows_exact_decimal_semantics(self): + import sqlite3 + + from benchbox.utils.dialect_utils import _fix_sqlite_unsupported_syntax + + # 0.1 + 0.2 - 0.3 is 5.55e-17 in doubles but exactly 0. A stored 1e-17 lies in that band, so + # SQLite's noisy bound matches it and the exact endpoint does not. TPC-H discounts have two + # decimals, so this cannot occur in the benchmark data; the behavior is documented, not relied on. + query = ( + "WITH lineitem AS (SELECT 0.00000000000000001 AS l_discount) SELECT l_discount FROM lineitem " + "WHERE l_discount BETWEEN (0.0 + 0.0) AND (0.1 + 0.2 - 0.3)" + ) + connection = sqlite3.connect(":memory:") + try: + assert connection.execute(query).fetchall() == [(1e-17,)] + assert connection.execute(_fix_sqlite_unsupported_syntax(query)).fetchall() == [] + finally: + connection.close() + + @pytest.mark.parametrize( + "rows, low, high, expected", + [ + # Doubles lose the 9.0, so SQLite's lower bound is 0.0 and both rows match. + ( + (8.0, 9.0), + "(10000000000000000000000000000.0 + 9.0 - 10000000000000000000000000000.0)", + "(20.0 + 0.0)", + [8.0, 9.0], + ), + # Doubles lose the 1e-10, so the lower bound is 0.0 and the zero row matches. + ((0.0, 0.05), "(100000000.0 + 0.0000000001 - 100000000.0)", "(0.06 + 0.01)", [0.0, 0.05]), + # inf - inf is NULL in SQLite, so no row matches. + ((0.05,), f"(1{'0' * 400}.0 - 1{'0' * 400}.0)", "(0.06 + 0.01)", []), + ], + ) + def test_cancellation_and_overflow_bounds_keep_sqlite_semantics(self, rows, low, high, expected): + import sqlite3 + + from benchbox.utils.dialect_utils import _fix_sqlite_unsupported_syntax + + values = " UNION ALL ".join(f"SELECT {value} AS l_discount" for value in rows) + query = ( + f"WITH lineitem AS ({values}) SELECT l_discount FROM lineitem " + f"WHERE l_discount BETWEEN {low} AND {high} ORDER BY l_discount" + ) + assert _fix_sqlite_unsupported_syntax(query) == query + connection = sqlite3.connect(":memory:") + try: + assert [value for (value,) in connection.execute(query).fetchall()] == expected + translated = translate_sql_query(query, target_dialect="sqlite") + assert [value for (value,) in connection.execute(translated).fetchall()] == expected + finally: + connection.close() + + @pytest.mark.parametrize("dialect", ["duckdb", "postgres", "mysql"]) + def test_non_sqlite_bounds_are_unchanged(self, dialect): + import sqlglot + from sqlglot import exp + + query = "SELECT * FROM lineitem WHERE l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01" + actual = sqlglot.parse_one(translate_sql_query(query, target_dialect=dialect), read=dialect) + between = actual.find(exp.Between) + assert isinstance(between.args["low"], exp.Sub) + assert isinstance(between.args["high"], exp.Add) + + @pytest.mark.parametrize("surface", ["seed42", "bulk"]) + def test_public_q6_real_sqlite_duckdb_parity(self, surface): + from decimal import Decimal + + import duckdb + + from benchbox.core.tpch.benchmark import TPCHBenchmark + from benchbox.platforms.sqlite import SQLiteAdapter + + benchmark = TPCHBenchmark(scale_factor=0.01) + if surface == "seed42": + queries = { + dialect: benchmark.get_query(6, seed=42, scale_factor=0.01, dialect=dialect) + for dialect in ("sqlite", "duckdb") + } + shipdate = "1997-06-01" + else: + queries = { + "sqlite": benchmark.get_queries(dialect="sqlite")["6"], + "duckdb": benchmark.get_query(6, dialect="duckdb"), + } + assert queries["sqlite"] == benchmark.get_query(6, dialect="sqlite") + shipdate = "1994-06-01" + adapter = SQLiteAdapter(database_path=":memory:") + sqlite = adapter.create_connection() + reference = duckdb.connect(":memory:") + rows = [("100.00", discount, shipdate, "1.00") for discount in ("0.04", "0.05", "0.06", "0.07", "0.08")] + try: + for connection in (sqlite, reference): + connection.execute( + "CREATE TABLE lineitem (l_extendedprice DECIMAL(15,2), l_discount DECIMAL(15,2), " + "l_shipdate DATE, l_quantity DECIMAL(15,2))" + ) + connection.executemany("INSERT INTO lineitem VALUES (?, ?, ?, ?)", rows) + result = adapter.execute_query(sqlite, queries["sqlite"], "6", validate_row_count=False) + expected = reference.execute(queries["duckdb"]).fetchall() + assert expected == [(Decimal("18.0000"),)] + assert result["results"] == [(18.0,)] + assert [(Decimal(str(value)),) for (value,) in result["results"]] == expected + finally: + sqlite.close() + reference.close() + + @pytest.mark.parametrize( + "center, discounts, expected", + [ + ("0.06", ("0.04", "0.05", "0.06", "0.07", "0.08"), 18), + ("0.07", ("0.05", "0.06", "0.07", "0.08", "0.09"), 21), + ], + ) + def test_real_engine_boundary_membership(self, center, discounts, expected): + import duckdb + + from benchbox.platforms.sqlite import SQLiteAdapter + + adapter = SQLiteAdapter(database_path=":memory:") + sqlite = adapter.create_connection() + reference = duckdb.connect(":memory:") + source = f"SELECT l_discount BETWEEN {center} - 0.01 AND {center} + 0.01 FROM lineitem ORDER BY l_discount" + aggregate = ( + f"SELECT SUM(100 * l_discount) FROM lineitem WHERE l_discount BETWEEN {center} - 0.01 AND {center} + 0.01" + ) + try: + for connection in (sqlite, reference): + connection.execute("CREATE TABLE lineitem (l_discount DECIMAL(15,2))") + connection.executemany("INSERT INTO lineitem VALUES (?)", [(value,) for value in discounts]) + for source_query, expected_rows in [(source, [(0,), (1,), (1,), (1,), (0,)]), (aggregate, [(expected,)])]: + actual = adapter.execute_query( + sqlite, translate_sql_query(source_query, target_dialect="sqlite"), "6", validate_row_count=False + )["results"] + assert actual == reference.execute(source_query).fetchall() == expected_rows + finally: + sqlite.close() + reference.close() + + class TestDialectNormalization: """Test dialect normalization for SQLGlot compatibility.""" diff --git a/tests/unit/utils/test_exasol_identifier_policy.py b/tests/unit/utils/test_exasol_identifier_policy.py new file mode 100644 index 0000000000..3b7489cf99 --- /dev/null +++ b/tests/unit/utils/test_exasol_identifier_policy.py @@ -0,0 +1,106 @@ +"""Exasol translates with unquoted identifiers while reserved words stay quoted. + +Exasol folds unquoted identifiers to UPPERCASE. Quoting every identifier by +default would create lower-case quoted names that adapter-internal unquoted +SQL cannot find, so the ``exasol`` target joins the no-identify set. Reserved +words stay safe because sqlglot's ``ExasolGenerator.RESERVED_KEYWORDS`` still +quotes them. These tests pin that contract on both translation paths, and pin +that no other target's output changes. + +Copyright 2026 Joe Harris / BenchBox Project + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +import logging + +import pytest + +from benchbox.platforms.base.dialect_translation import DialectTranslationMixin +from benchbox.utils.dialect_utils import ( + NO_IDENTIFY_DIALECTS, + normalize_dialect_for_sqlglot, + translate_sql_query, +) + +pytestmark = [ + pytest.mark.unit, + pytest.mark.fast, +] + + +class _ExasolHost(DialectTranslationMixin): + _dialect = "exasol" + logger = logging.getLogger("test-exasol-identify") + + +class TestExasolIdentifierPolicy: + """Unquoted identifiers, quoted reserved words, unchanged passthrough.""" + + def test_exasol_is_a_no_identify_target(self): + """The policy constant carries exasol alongside the existing members.""" + assert frozenset({"clickhouse", "postgres", "snowflake", "exasol"}) == NO_IDENTIFY_DIALECTS + + def test_normalize_passes_exasol_through(self): + """sqlglot ships the dialect, so no fallback mapping applies.""" + assert normalize_dialect_for_sqlglot("exasol") == "exasol" + assert normalize_dialect_for_sqlglot("EXASOL") == "exasol" + + def test_tpch_ddl_emits_unquoted_identifiers(self): + """TPC-H DDL columns and keys come out unquoted.""" + ddl = ( + "CREATE TABLE lineitem (l_orderkey INTEGER NOT NULL, l_partkey INTEGER NOT NULL, PRIMARY KEY (l_orderkey))" + ) + result = translate_sql_query(ddl, target_dialect="exasol", source_dialect="standard") + assert '"l_orderkey"' not in result + assert '"lineitem"' not in result + assert "l_orderkey" in result + + def test_tpch_query_emits_unquoted_identifiers(self): + """A TPC-H query keeps plain identifiers.""" + result = translate_sql_query( + "SELECT l_orderkey, l_partkey FROM lineitem WHERE l_orderkey > 10", + target_dialect="exasol", + ) + assert '"' not in result + assert "l_orderkey" in result + + def test_tpcds_reserved_aliases_stay_quoted(self): + """TPC-DS reserved aliases are quoted despite identify=False.""" + result = translate_sql_query( + "SELECT d_year AS year, c_id AS catalog FROM dates", + target_dialect="exasol", + ) + assert '"year"' in result + assert '"catalog"' in result + + def test_reserved_ddl_column_stays_quoted(self): + """A synthetic DDL column named after a reserved word is quoted.""" + result = translate_sql_query( + "CREATE TABLE t (year INTEGER NOT NULL, id INTEGER NOT NULL)", + target_dialect="exasol", + source_dialect="standard", + ) + assert '"year"' in result + assert '"id"' not in result + + def test_platforms_path_matches_utils_path(self): + """The adapter mixin path honors the same policy.""" + host = _ExasolHost() + assert '"' not in host.translate_sql("SELECT l_orderkey FROM lineitem", source_dialect="postgres") + assert '"year"' in host.translate_sql("SELECT d_year AS year FROM dates", source_dialect="postgres") + + _UNCHANGED_TARGET_EXPECTATIONS = { + "duckdb": 'SELECT "l_orderkey" FROM "lineitem"', + "postgres": "SELECT l_orderkey FROM lineitem", + "snowflake": "SELECT l_orderkey FROM lineitem", + "clickhouse": "SELECT l_orderkey FROM lineitem", + "sqlite": 'SELECT "l_orderkey" FROM "lineitem"', + "trino": 'SELECT "l_orderkey" FROM "lineitem"', + } + + @pytest.mark.parametrize("target", ["duckdb", "postgres", "snowflake", "clickhouse", "sqlite", "trino"]) + def test_other_targets_are_unchanged(self, target): + """G1: only the exasol target may change behavior.""" + query = "SELECT l_orderkey FROM lineitem" + assert translate_sql_query(query, target_dialect=target) == self._UNCHANGED_TARGET_EXPECTATIONS[target] diff --git a/tests/unit/visualization/test_visualize_cli.py b/tests/unit/visualization/test_visualize_cli.py index da0fbe1223..61c9f5b269 100644 --- a/tests/unit/visualization/test_visualize_cli.py +++ b/tests/unit/visualization/test_visualize_cli.py @@ -7,7 +7,7 @@ import pytest from click.testing import CliRunner -from benchbox.utils.printing import set_quiet +import benchbox.utils.printing as printing pytestmark = [ pytest.mark.unit, @@ -19,11 +19,9 @@ @pytest.fixture(autouse=True) -def _ensure_quiet_off(): - """Ensure quiet mode is off so console.print() output reaches CliRunner.""" - set_quiet(False) - yield - set_quiet(False) +def _ensure_quiet_off(_hermetic_state, monkeypatch): + """Inject visible output and restore the caller's actual quiet state.""" + monkeypatch.setattr(printing, "_QUIET", False) # Helper to generate valid result JSON diff --git a/tests/unit/workflows/test_auto_merge_enablement_point.py b/tests/unit/workflows/test_auto_merge_enablement_point.py deleted file mode 100644 index a078d2d0e3..0000000000 --- a/tests/unit/workflows/test_auto_merge_enablement_point.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Auto-merge must not be armed merely because a PR was opened. - -Arming at creation is only correct if nothing more will be pushed - and the -usual reason something more is pushed is review feedback, which arrives after -the PR exists. A PR armed at creation can satisfy its checks and merge while -the follow-up commit is still being written, leaving develop with a partial -change and the remainder orphaned on a closed branch. - -That happened three times in one session: #1503 lost the browser gate job its -own PR body described, and #1521 and #1531 then each lost the very commit that -addressed their own review findings. No local check can prevent it - in all -three the working tree was clean when the PR was opened. - -#1567 moved the Makefile hold (`pr-open` withholds; `pr-ready` arms). The -workflow `auto-merge-on-open.yml` still armed on `opened` for any non-draft -PR, so bare `gh pr create` defeated the hold (#1568/#1569). #1592 narrowed -the workflow arm to `ready_for_review` — which then never fired once (drafts -are unused; 0 events across 150 PRs), so the arm step was deleted outright -(auto-merge-policy-consolidation-2026-08-06, D2). The cross-layer policy is -therefore: - -- Local: `pr-open` withholds; `pr-ready` is the arm path, and `READY=1` is a - shortcut only when reusing an already-open, reviewed PR. -- Workflow: revoke-only (soundness paths + `no-auto-merge` label); it never - arms on any event. Soundness disable runs on opened/reopened/synchronize. -""" - -from __future__ import annotations - -import json -import os -import re -import shutil -import subprocess -from pathlib import Path -from typing import Any - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -REPO_ROOT = Path(__file__).resolve().parents[3] -MAKEFILE = REPO_ROOT / "Makefile" -AUTO_MERGE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "auto-merge-on-open.yml" - -ARM_COMMAND = "gh pr merge --auto --squash" - -# Exact disable `if:` pins for auto-merge-on-open.yml (collapsed form). -DISABLE_IF = "steps.soundness.outputs.soundness_path == 'true'" -HOLD_DISABLE_IF = "steps.hold.outputs.held == 'true'" - - -def _target_body(name: str) -> str: - """Return the recipe lines of a Makefile target.""" - text = MAKEFILE.read_text(encoding="utf-8") - match = re.search(rf"^{re.escape(name)}:.*?\n((?:\t.*\n|\n)*)", text, re.MULTILINE) - assert match, f"Makefile has no target {name!r}" - return match.group(1) - - -def _load_workflow() -> dict[str, Any]: - return yaml.safe_load(AUTO_MERGE_WORKFLOW.read_text(encoding="utf-8")) - - -def _triggers(workflow: dict[str, Any]) -> dict[str, Any]: - """Return the `on:` block (PyYAML 1.1 may resolve unquoted `on` to True).""" - triggers = workflow.get("on", workflow.get(True)) - assert triggers is not None, "workflow has no `on:` block" - return triggers - - -def _revoke_job(workflow: dict[str, Any]) -> dict[str, Any]: - jobs = workflow.get("jobs") or {} - assert "revoke" in jobs, "auto-merge workflow has no revoke job" - return jobs["revoke"] - - -def _steps_by_name(job: dict[str, Any]) -> dict[str, dict[str, Any]]: - named: dict[str, dict[str, Any]] = {} - for step in job.get("steps") or []: - name = step.get("name") - if name: - named[str(name)] = step - return named - - -def _collapsed_if(step: dict[str, Any]) -> str: - """Normalize a step `if:` to a single-line string for exact comparison. - - PyYAML may leave multi-line folded scalars with internal newlines/spaces; - collapse whitespace so the pin is the semantic condition, not YAML layout. - """ - raw = str(step.get("if") or "").strip() - return re.sub(r"\s+", " ", raw) - - -# --------------------------------------------------------------------------- -# Makefile layer (hold at pr-open; arm via pr-ready) -# --------------------------------------------------------------------------- - - -def test_auto_merge_enablement_point_is_not_pr_open() -> None: - """`pr-open` must not arm auto-merge on its own. - - This is the assertion that fails on the unfixed tree, where pr-open calls - `gh pr merge --auto --squash` directly for any non-soundness branch. - """ - body = _target_body("pr-open") - assert ARM_COMMAND not in body, ( - "pr-open arms auto-merge directly, so a PR can merge before a later commit is pushed" - ) - - -def test_auto_merge_enablement_point_keeps_a_reviewed_reuse_path() -> None: - """READY=1 arms only a reused PR; creation remains a successful hold.""" - text = MAKEFILE.read_text(encoding="utf-8") - assert re.search(r"^pr-ready:", text, re.MULTILINE), "no pr-ready target; withholding would be a dead end" - body = _target_body("pr-open") - assert "REUSED_PR=0" in body and "REUSED_PR=1" in body - assert 'elif [ "$$REUSED_PR" != "1" ]; then' in body - assert "PR created and held" in body - assert "Next action after review: make pr-ready" in body - assert '$(MAKE) -s pr-ready REPO="$$REPOSITORY"' in body - - -def test_auto_merge_enablement_point_does_not_treat_creation_as_readiness_failure() -> None: - """The new-PR READY=1 branch reports the follow-up and skips pr-ready.""" - body = _target_body("pr-open") - held_at = body.index('elif [ "$$REUSED_PR" != "1" ]; then') - arm_at = body.index('$(MAKE) -s pr-ready REPO="$$REPOSITORY"') - assert held_at < arm_at - assert "READY=1 does not arm a newly created PR" in body - - -def _pr_reuse_query() -> str: - """Extract the owner-aware jq query used by `pr-open` reuse.""" - body = _target_body("pr-open") - match = re.search(r"--json url,headRepositoryOwner,headRefName\s+\\\n\s*--jq '([^']+)'", body) - assert match, "pr-open has no owner-aware JSON reuse query" - return match.group(1) - - -@pytest.mark.skipif(shutil.which("jq") is None, reason="jq is required to exercise the gh JSON filter") -@pytest.mark.parametrize( - ("owner", "branch", "expected_url"), - [ - ("BenchBox-dev", "feature/reuse-safe", "https://example.test/same-repo"), - ("contributor", "feature/reuse-safe", "https://example.test/fork"), - ], -) -def test_pr_open_reuse_query_selects_same_repo_and_fork_by_owner_and_branch( - owner: str, branch: str, expected_url: str -) -> None: - """Reuse must distinguish repository owner when fork branches share a name.""" - payload = [ - { - "url": "https://example.test/same-repo", - "headRepositoryOwner": {"login": "BenchBox-dev"}, - "headRefName": "feature/reuse-safe", - }, - { - "url": "https://example.test/fork", - "headRepositoryOwner": {"login": "contributor"}, - "headRefName": "feature/reuse-safe", - }, - { - "url": "https://example.test/other-branch", - "headRepositoryOwner": {"login": owner}, - "headRefName": "feature/other", - }, - ] - result = subprocess.run( - ["jq", "-r", _pr_reuse_query()], - input=json.dumps(payload), - text=True, - capture_output=True, - check=False, - env={**os.environ, "PR_HEAD_OWNER": owner, "PR_HEAD_NAME": branch}, - ) - - assert result.returncode == 0, result.stderr - assert result.stdout.splitlines() == [expected_url] - - -def test_pr_open_preserves_owner_qualified_create_and_avoids_unsupported_list_head() -> None: - """Creation may use owner:branch; reuse must use supported JSON fields.""" - body = _target_body("pr-open") - create_commands = re.findall(r"gh pr create .*?--head \"\$\$HEAD_SPEC\"", body) - - assert len(create_commands) == 2 - assert '--head "$$HEAD_SPEC"' not in body.split("URL=$$(gh pr list", 1)[1].split("if [ -z", 1)[0] - assert "--json url,headRepositoryOwner,headRefName" in body - assert "env.PR_HEAD_OWNER" in body and "env.PR_HEAD_NAME" in body - - -def test_auto_merge_enablement_point_has_one_arming_implementation() -> None: - """Both entry points must share one arming path. - - The soundness withhold lives in that path. A second copy is how the two - entry points drift until one of them stops checking. - """ - text = MAKEFILE.read_text(encoding="utf-8") - executable = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith(("#", "@#"))) - assert ARM_COMMAND not in executable, "Makefile contains a second executable arming implementation" - body = _target_body("pr-arm-auto-merge") - assert "pr-landing-ready" in body and "EVIDENCE is required" in body, ( - "Makefile arm target no longer delegates to the readiness transaction" - ) - - -def test_auto_merge_enablement_point_preserves_soundness_withholding() -> None: - """Must-preserve: soundness-path PRs are still withheld. - - The arming path has to consult the soundness predicate before it arms, or - moving the enablement point would quietly drop the control that keeps - oracle-adjacent changes from merging hands-free. - """ - helper = (REPO_ROOT / "scripts" / "pr_landing.py").read_text(encoding="utf-8") - assert "soundness_paths_changed" in helper, "arming helper no longer consults the soundness predicate" - assert "--auto" in helper and "--match-head-commit" in helper, "arming helper lost its guarded merge operation" - - -# --------------------------------------------------------------------------- -# Workflow layer (revoke-only: never arms on any event) -# --------------------------------------------------------------------------- - - -def test_auto_merge_workflow_never_arms() -> None: - """The workflow must contain no arm path at all. - - The historical defect was arming on `opened` (defeating the Makefile - hold — #1568/#1569); the #1592 fix narrowed arming to `ready_for_review`, - which never fired once (drafts unused). The step was deleted (D2): with - no arm command in the file, no event — opened, reopened, synchronize, or - a future looser condition — can arm. - """ - workflow = _load_workflow() - text = AUTO_MERGE_WORKFLOW.read_text(encoding="utf-8") - assert "Enable squash auto-merge" not in text, "the dead arm step is back" - for step in _revoke_job(workflow).get("steps") or []: - run = str(step.get("run") or "") - assert ARM_COMMAND not in run, f"workflow step {step.get('name')!r} arms auto-merge" - - -def test_auto_merge_workflow_keeps_revocation_triggers() -> None: - """opened/reopened/synchronize must still run for soundness disable. - - Dropping those triggers would leave a soundness PR that later gains a - soundness commit without a revocation path from this workflow. `labeled` - is also required so the durable hold label revokes without needing a - push. `ready_for_review` must stay absent: a draft cannot carry - auto-merge (nothing to revoke), and its only historical use was the dead - arm point. - """ - types = _triggers(_load_workflow())["pull_request"]["types"] - for required in ("opened", "reopened", "synchronize", "labeled"): - assert required in types, f"pull_request types missing {required!r}: {types}" - assert "ready_for_review" not in types, "ready_for_review is back; the arm point must not return" - - -def test_auto_merge_workflow_preserves_soundness_disable() -> None: - """Must-preserve: soundness paths still revoke auto-merge.""" - workflow = _load_workflow() - steps = _steps_by_name(_revoke_job(workflow)) - disable = steps.get("Disable auto-merge for soundness PR") - assert disable is not None, "soundness disable step is gone" - assert "--disable-auto" in str(disable.get("run") or ""), "soundness disable no longer calls --disable-auto" - assert _collapsed_if(disable) == DISABLE_IF, ( - f"disable step if: drifted from policy pin\n got: {_collapsed_if(disable)!r}\n want: {DISABLE_IF!r}" - ) - - -def test_auto_merge_workflow_preserves_explicit_hold_disable() -> None: - """Must-preserve: no-auto-merge label revokes auto-merge independently.""" - workflow = _load_workflow() - steps = _steps_by_name(_revoke_job(workflow)) - disable = steps.get("Disable auto-merge for explicit hold") - assert disable is not None, "explicit-hold disable step is gone" - assert "--disable-auto" in str(disable.get("run") or "") - assert _collapsed_if(disable) == HOLD_DISABLE_IF, ( - f"hold disable if: drifted from policy pin\n got: {_collapsed_if(disable)!r}\n want: {HOLD_DISABLE_IF!r}" - ) diff --git a/tests/unit/workflows/test_develop_post_merge_metrics.py b/tests/unit/workflows/test_develop_post_merge_metrics.py deleted file mode 100644 index 99aaf50ba5..0000000000 --- a/tests/unit/workflows/test_develop_post_merge_metrics.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Tests for develop-post-merge metrics jq behavior.""" - -from __future__ import annotations - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest -import yaml - -pytestmark = [ - pytest.mark.unit, - pytest.mark.fast, -] - -REPO_ROOT = Path(__file__).resolve().parents[3] -# Mirrors the live filter in develop-post-merge.yml's metrics step so the -# fixture stays in sync with the workflow. If the workflow adds another job -# to the post-merge gate, extend this filter and add a fixture exercising -# the new job's failure timestamp. -RED_AT_JQ = r""" -if $post_merge_red == "true" then - ([($jobs.jobs // [])[] | - select((.name == "lint" or .name == "fast-test" or .name == "explorer-tokens" or .name == "medium-test") and .conclusion == "failure" and .completed_at != null) | - .completed_at] | min // null) -else null end -""" - - -def run_red_at_jq(jobs: dict, post_merge_red: str = "true") -> str | None: - jq = shutil.which("jq") - if jq is None: - pytest.skip("jq is required for workflow metrics fixture tests") - result = subprocess.run( - [ - jq, - "-n", - "--argjson", - "jobs", - json.dumps(jobs), - "--arg", - "post_merge_red", - post_merge_red, - RED_AT_JQ, - ], - text=True, - capture_output=True, - check=True, - ) - return json.loads(result.stdout) - - -def test_failed_post_merge_jobs_use_earliest_completion_timestamp() -> None: - jobs = { - "jobs": [ - { - "name": "fast-test", - "conclusion": "failure", - "completed_at": "2026-05-01T10:10:00Z", - }, - { - "name": "lint", - "conclusion": "failure", - "completed_at": "2026-05-01T10:03:00Z", - }, - { - "name": "docs", - "conclusion": "failure", - "completed_at": "2026-05-01T09:55:00Z", - }, - { - "name": "lint", - "conclusion": "failure", - "completed_at": None, - }, - ] - } - - assert run_red_at_jq(jobs) == "2026-05-01T10:03:00Z" - - -def test_workflow_metrics_expression_uses_min_not_max() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - - assert ".completed_at] | min // null" in workflow - assert ".completed_at] | max // null" not in workflow - - -def test_lint_baseline_cache_writes_are_serialized() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - - assert "group: develop-post-merge-lint-${{ github.ref }}" in workflow - assert "cancel-in-progress: false" in workflow - - -def test_medium_test_timeout_matches_the_pre_merge_lane() -> None: - pre_merge = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8")) - post_merge = yaml.safe_load( - (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - ) - - assert pre_merge["jobs"]["medium-test"]["timeout-minutes"] == post_merge["jobs"]["medium-test"]["timeout-minutes"] - - -def test_explorer_tokens_failure_counted_in_red_at() -> None: - # Mirror of test_failed_post_merge_jobs_use_earliest_completion_timestamp - # but with an `explorer-tokens` failure as the earliest. Locks in the - # blind-spot remediation (token-scan gate) as a tracked post-merge red - # signal — not just lint and fast-test. - jobs = { - "jobs": [ - { - "name": "lint", - "conclusion": "failure", - "completed_at": "2026-05-01T10:10:00Z", - }, - { - "name": "explorer-tokens", - "conclusion": "failure", - "completed_at": "2026-05-01T10:01:00Z", - }, - { - "name": "fast-test", - "conclusion": "failure", - "completed_at": "2026-05-01T10:05:00Z", - }, - ] - } - - assert run_red_at_jq(jobs) == "2026-05-01T10:01:00Z" - - -def test_medium_test_failure_counted_in_red_at() -> None: - # Mirror of test_explorer_tokens_failure_counted_in_red_at for the medium - # speed tier's post-merge CI home (medium-tier-ci-home.yaml). While - # medium-test is demoted (job-level continue-on-error, see - # test_medium_test_is_visibility_only_not_auto_revert_trigger), this - # branch of the jq filter is inert in practice: MEDIUM_TEST_RESULT reads - # "success" under continue-on-error, so post_merge_red never flips on a - # medium-test failure alone. Kept wired (and pinned here) so the metrics - # row is complete the moment the job is promoted. - jobs = { - "jobs": [ - { - "name": "lint", - "conclusion": "failure", - "completed_at": "2026-05-01T10:10:00Z", - }, - { - "name": "medium-test", - "conclusion": "failure", - "completed_at": "2026-05-01T10:01:00Z", - }, - { - "name": "fast-test", - "conclusion": "failure", - "completed_at": "2026-05-01T10:05:00Z", - }, - ] - } - - assert run_red_at_jq(jobs) == "2026-05-01T10:01:00Z" - - -def test_workflow_metrics_filter_includes_explorer_tokens() -> None: - # The jq filter inside develop-post-merge.yml's metrics step decides - # which job names are counted as post-merge-red signals. If - # `explorer-tokens` is dropped from the filter, the gate would still - # flip the `post_merge_red` shell flag but the metrics row would - # under-report the red event timestamp. - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - assert '.name == "explorer-tokens"' in workflow - - -def test_post_merge_red_shell_flag_includes_explorer_tokens() -> None: - # The shell `post_merge_red` flag is the gate the auto-revert and - # metrics rows both branch on. If a future workflow cleanup drops - # EXPLORER_TOKENS_RESULT from this expression, the gate would flag - # red but no auto-revert PR would open and the metrics row would be - # marked green — silently regressing the blind-spot remediation. - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - assert '${EXPLORER_TOKENS_RESULT}" = "failure"' in workflow - - -def test_post_merge_red_shell_flag_includes_medium_test() -> None: - # Same guard as test_post_merge_red_shell_flag_includes_explorer_tokens, - # for the medium-test job added by medium-tier-ci-home.yaml. Inert while - # medium-test carries continue-on-error (the result reads "success"), - # but kept wired so promotion only needs the workflow-side flip. - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - assert '${MEDIUM_TEST_RESULT}" = "failure"' in workflow - - -def test_auto_revert_triggers_on_explorer_tokens_failure() -> None: - # The auto-revert-on-failure job's `if:` expression decides which - # post-merge red events open a revert PR. Adding `explorer-tokens` - # to the trigger was the blind-spot remediation; this test prevents - # an inadvertent removal during a future `if:` cleanup. - workflow_yaml = yaml.safe_load( - (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - ) - auto_revert = workflow_yaml["jobs"]["auto-revert-on-failure"] - assert "explorer-tokens" in auto_revert["needs"] - # The `if:` is a single string after yaml.safe_load. - assert "needs.explorer-tokens.result == 'failure'" in auto_revert["if"] - - -def test_medium_test_is_promoted_to_auto_revert_trigger() -> None: - # PROMOTED 2026-07-06 (medium-tier-red-disposition-and-promotion): after - # the medium tier ran clean (green) on the real runner - develop-post-merge - # run 28763663703, job 85283671051 - once its dispositioned failures were - # fixed (#977 timeout markers, #994 pytest-ci.ini filterwarnings marker - # sync), medium-test was promoted to a blocking post-merge lane on the - # explorer-tokens pattern: no continue-on-error, present in - # auto-revert-on-failure's needs + if:failure trigger. This is the inverse - # of the former visibility-only pin; a regression here means the promotion - # was silently undone and a medium-test failure would no longer open a - # revert PR. - workflow_yaml = yaml.safe_load( - (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - ) - medium_test = workflow_yaml["jobs"]["medium-test"] - auto_revert = workflow_yaml["jobs"]["auto-revert-on-failure"] - - assert "continue-on-error" not in medium_test - assert "medium-test" in auto_revert["needs"] - assert "needs.medium-test.result == 'failure'" in auto_revert["if"] - - -def test_close_orphaned_prs_waits_for_post_merge_validation_success() -> None: - # Closing superseded PRs is only safe after the post-merge validation - # jobs have passed. Otherwise a failing develop push can be auto-reverted - # after ancestor PRs were already closed as superseded. - # - # medium-test joined this list 2026-07-06 (review-followup-sweep): - # medium-test was promoted into the blocking auto-revert signal (see - # test_medium_test_is_promoted_to_auto_revert_trigger above) but this job - # still only waited on lint/fast-test/explorer-tokens, so a medium-test - # failure alongside three passing jobs could close orphaned PRs before - # the develop push was classified red and a revert PR opened. - workflow_yaml = yaml.safe_load( - (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - ) - close_orphaned_prs = workflow_yaml["jobs"]["close-orphaned-prs"] - assert close_orphaned_prs["needs"] == ["lint", "fast-test", "explorer-tokens", "medium-test"] - # Schedule exclusion is allowed (gates-only sweep); push path must still - # wait on all four gates and must not run mutation work on schedule. - job_if = close_orphaned_prs.get("if") - assert job_if is None or "github.event_name != 'schedule'" in str(job_if) - # Must not use always() here: a skipped/failed gate must skip close. - assert "always()" not in str(job_if or "") - - -def test_post_merge_explorer_tokens_job_runs_on_every_push() -> None: - # The post-merge `explorer-tokens` job must run against the merged - # develop tree on every push. If a future cleanup gated the push path - # (e.g. mirroring the PR-time path-gated form), the post-merge re-scan - # would silently stop firing while the auto-revert wiring would still - # reference `needs.explorer-tokens.result == 'failure'` — a state that - # could no longer occur. The whole blind-spot remediation (squash-race - # regressions tripping auto-revert) depends on this job running on - # every push to develop. Since ci-dedupe-02 it skips only on a - # covered-tip schedule run, which already gated this exact SHA. - workflow_yaml = yaml.safe_load( - (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - ) - job = workflow_yaml["jobs"]["explorer-tokens"] - condition = str(job.get("if") or "") - assert "github.event_name != 'schedule'" in condition, ( - f"post-merge explorer-tokens job must run on every push; found: {job.get('if')!r}" - ) - assert "needs.sweep-coverage.outputs.covered != 'true'" in condition - - -def test_baseline_selection_filters_by_event() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - - assert ".event == $event" in workflow - - -def test_advisory_issue_creation_failure_is_loud() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - - assert '--body "${body}" || true' not in workflow - - -def test_unproven_attribution_routes_to_owned_incident_without_revert() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - - assert 'should_revert="false"' in workflow - assert "comparison-state=${comparison_state}" in workflow - assert "Classification: ${classification}" in workflow - assert "Incident key: \\`${incident_key}\\`" in workflow - assert '--search "${incident_key} in:body"' in workflow - assert "Fail loudly on unattributable failure classes" not in workflow - - -def test_new_owned_regression_reaches_revert_route() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - - assert 'should_revert="true"\n comparison_state="new"' in workflow - assert "steps.signature-decision.outputs.should-revert == 'true'" in workflow - assert "steps.attribute.outputs.action == 'revert'" in workflow - assert 'revert_suppressed="true"' in workflow - - -def test_missing_current_signature_uses_incident_fallback_and_cannot_revert() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - attribute_block = workflow.split("id: attribute", 1)[1].split("- name: Finalize revert-suppressed", 1)[0] - - assert 'empty_signature=\'{"failure_ids":[],"jobs":[],"source_inputs":{}}\'' in attribute_block - assert "if [ -f current-combined.json ]; then" in attribute_block - assert 'jq -ce \'if type == "object" and' in attribute_block - assert 'error("signature object has invalid fields") end\'' in attribute_block - assert 'current_signature="${empty_signature}"' in attribute_block - assert '--argjson current_signature "$(cat current-combined.json)"' not in attribute_block - assert "comparison_state: $comparison_state" in attribute_block - assert ( - "steps.signature-decision.outputs.should-revert == 'false' || steps.attribute.outputs.action == 'advisory'" - in workflow - ) - - -def test_persistent_incident_fallback_uses_current_combined_signature() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - upsert_block = workflow.split("id: upsert-incident", 1)[1].split("green-run-cleanup:", 1)[0] - - assert "current-combined.json" in upsert_block - assert 'current_failure_ids_json="$(jq -c' in upsert_block - assert 'current_incident_key="$(printf' in upsert_block - assert 'COMPARISON_STATE}" = "persistent"' in upsert_block - assert "failure_ids: ($signature.failure_ids // [])" in upsert_block - assert "jobs: ($signature.jobs // [])" in upsert_block - assert "source_input_identities: {current: ($signature.source_inputs // {})" in upsert_block - assert "NEW_FAILURE_IDS" not in upsert_block - - -def test_incident_artifact_contains_required_attribution_fields() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - - for field in ( - "failing_sha", - "current_target_sha", - "predecessor_source_inputs", - "predecessor_evidence", - "failing_pr", - "owner", - "next_action", - ): - assert field in workflow - - -def test_revert_checks_live_target_and_inverse_diff_before_push() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml").read_text(encoding="utf-8") - - assert 'target_sha="$(git rev-parse --verify origin/develop || true)"' in workflow - assert 'git revert --no-commit "${FAILING_SHA}"' in workflow - assert "git diff --check" in workflow - assert "git diff --name-status" in workflow - assert 'current_target_sha="$(git ls-remote origin refs/heads/develop | cut -f1)"' in workflow diff --git a/tests/unit/workflows/test_develop_ruleset_drift.py b/tests/unit/workflows/test_develop_ruleset_drift.py deleted file mode 100644 index 2d244f574b..0000000000 --- a/tests/unit/workflows/test_develop_ruleset_drift.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Security and required-check contract for per-PR governance drift.""" - -import re -from pathlib import Path - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -ROOT = Path(__file__).resolve().parents[3] -WORKFLOW = ROOT / ".github" / "workflows" / "develop-ruleset-drift.yml" - - -def _workflow() -> dict: - return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) - - -def test_ruleset_drift_uses_trusted_base_code_with_minimal_permissions() -> None: - workflow = _workflow() - job = workflow["jobs"]["ruleset-drift"] - checkout = job["steps"][0] - - assert workflow["permissions"] == {"contents": "read"} - assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}", checkout["uses"]), ( - "ruleset-drift checkout must pin actions/checkout to an immutable SHA" - ) - assert checkout["with"]["ref"] == "${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}" - assert checkout["with"]["persist-credentials"] is False - assert "head.sha" not in WORKFLOW.read_text(encoding="utf-8") - - -def test_ruleset_drift_fails_closed_and_preserves_evidence() -> None: - workflow = _workflow() - job = workflow["jobs"]["ruleset-drift"] - check = job["steps"][1] - upload = job["steps"][2] - - assert job["name"] == "ruleset-drift" - assert check["env"] == {"RULESET_DRIFT_TOKEN": "${{ secrets.RULESET_DRIFT_TOKEN }}"} - assert "scripts/ruleset_drift_check.py" in check["run"] - assert "--require-bypass-actor-visibility" in check["run"] - assert "set +e" not in check["run"] - assert upload["if"] == "always()" - assert upload["with"]["retention-days"] == 30 diff --git a/tests/unit/workflows/test_docker_integration_workflow.py b/tests/unit/workflows/test_docker_integration_workflow.py new file mode 100644 index 0000000000..44e017a8d9 --- /dev/null +++ b/tests/unit/workflows/test_docker_integration_workflow.py @@ -0,0 +1,90 @@ +"""Contract tests for the Docker integration pipeline. + +The docker-integration.yml workflow owns nightly compose-backed live suites +for postgres, clickhouse, and trino. These tests pin the schedule, +per-service jobs, compose stack references, and the no-silent-skip guard. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "docker-integration.yml" + + +def _load_workflow() -> dict[str, Any]: + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def _triggers(workflow: dict[str, Any]) -> dict[str, Any]: + triggers = workflow.get("on", workflow.get(True)) + assert triggers is not None, "workflow has no `on:` block" + return triggers + + +def test_workflow_exists_and_scheduled_nightly() -> None: + assert WORKFLOW.exists() + triggers = _triggers(_load_workflow()) + assert "schedule" in triggers, "docker-integration.yml must run on a schedule" + assert "workflow_dispatch" in triggers, "docker-integration.yml must allow manual runs" + + +def test_all_three_service_jobs_present() -> None: + jobs = _load_workflow()["jobs"] + assert {"postgres", "clickhouse", "trino"} <= set(jobs) + + +def test_dispatch_input_validated_with_exact_tokens() -> None: + workflow = _load_workflow() + assert "validate-input" in workflow["jobs"] + validate = workflow["jobs"]["validate-input"] + outputs = validate.get("outputs", {}) + assert set(outputs) >= {"run_postgres", "run_clickhouse", "run_trino"} + validate_text = "\n".join(str(s.get("run", "")) for s in validate["steps"]) + assert "Unknown service" in validate_text + for job_name, flag in ( + ("postgres", "run_postgres"), + ("clickhouse", "run_clickhouse"), + ("trino", "run_trino"), + ): + job = workflow["jobs"][job_name] + assert job.get("needs") == ["validate-input"], f"{job_name} must wait for validation" + assert flag in job.get("if", ""), f"{job_name} must gate on {flag}" + + +def test_clickhouse_fixture_matches_compose_password() -> None: + fixture = (REPO_ROOT / "tests" / "integration" / "platforms" / "test_clickhouse_live.py").read_text( + encoding="utf-8" + ) + assert 'password="benchbox"' in fixture + + +def test_each_job_uses_own_compose_stack_and_guards_skips() -> None: + workflow = _load_workflow() + expected = { + "postgres": ("docker/postgresql/docker-compose.yml", "live_postgresql"), + "clickhouse": ("docker/clickhouse/docker-compose.yml", "live_clickhouse"), + "trino": ("docker/trino/docker-compose.yml", "live_trino"), + } + for job_name, (compose, marker) in expected.items(): + steps = workflow["jobs"][job_name]["steps"] + text = "\n".join(str(s.get("run", "")) for s in steps) + assert compose in text, f"{job_name} must bring up {compose}" + assert marker in text, f"{job_name} must run the {marker} suite" + assert "no passing tests" in text, f"{job_name} must fail on all-skipped runs" + + +def test_compose_files_referenced_exist() -> None: + for compose in ( + "docker/postgresql/docker-compose.yml", + "docker/clickhouse/docker-compose.yml", + "docker/trino/docker-compose.yml", + ): + assert (REPO_ROOT / compose).exists(), f"missing {compose}" diff --git a/tests/unit/workflows/test_docs_concurrency.py b/tests/unit/workflows/test_docs_concurrency.py deleted file mode 100644 index 1ab1322f95..0000000000 --- a/tests/unit/workflows/test_docs_concurrency.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Tests for Documentation workflow superseded-run cancellation. - -docs.yml runs on nearly every code PR push; without a concurrency group -every superseded run finishes. Cancellation must apply to pull_request runs -for the same PR only: pushes to develop and release feed the deploy job and -must never cancel each other. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -REPO_ROOT = Path(__file__).resolve().parents[3] -DOCS = REPO_ROOT / ".github" / "workflows" / "docs.yml" - -EXPECTED_GROUP = ( - "${{ github.event_name == 'pull_request' " - "&& format('docs-pr-{0}', github.event.pull_request.number) " - "|| format('docs-{0}-{1}', github.sha, github.run_id) }}" -) -EXPECTED_CANCEL = "${{ github.event_name == 'pull_request' }}" - -REQUIRED_CONTEXTS = frozenset({"ci-required-result", "Results Explorer browser gate", "ruleset-drift"}) - - -def _workflow() -> dict[str, object]: - return yaml.safe_load(DOCS.read_text(encoding="utf-8")) # type: ignore[no-any-return] - - -def _evaluate(event: str, number: int | None, sha: str) -> tuple[str, bool]: - """Mirror the committed concurrency expressions for one event row.""" - workflow = _workflow() - concurrency = workflow["concurrency"] - assert concurrency["group"] == EXPECTED_GROUP - assert concurrency["cancel-in-progress"] == EXPECTED_CANCEL - if event == "pull_request": - assert number is not None - return f"docs-pr-{number}", True - return f"docs-{sha}-123", False - - -def test_docs_cancels_superseded_pr_runs_only() -> None: - group, cancel = _evaluate("pull_request", 123, "a" * 40) - assert cancel is True - assert group == "docs-pr-123" - - -def test_two_quick_pushes_to_same_pr_share_cancel_group() -> None: - first = _evaluate("pull_request", 123, "a" * 40) - second = _evaluate("pull_request", 123, "b" * 40) - assert first == second == ("docs-pr-123", True) - - -def test_develop_and_release_pushes_never_cancel() -> None: - for event, sha in (("push", "c" * 40), ("push", "d" * 40), ("workflow_dispatch", "e" * 40)): - group, cancel = _evaluate(event, None, sha) - assert cancel is False, event - assert group == f"docs-{sha}-123" - assert _evaluate("push", None, "c" * 40)[0] != _evaluate("push", None, "d" * 40)[0] - - -def test_path_filter_not_narrowed() -> None: - # NOTE: PyYAML parses the bare `on:` trigger key as boolean True. - workflow = _workflow() - triggers = workflow.get("on", workflow.get(True)) - paths = triggers["pull_request"]["paths"] - for required in ("benchbox/**", "scripts/**", "Makefile", "docs/**"): - assert required in paths - - -def test_docs_declares_no_required_check() -> None: - jobs = _workflow()["jobs"] - names = {str(job.get("name", job_id)) for job_id, job in jobs.items()} - assert not (names & REQUIRED_CONTEXTS) diff --git a/tests/unit/workflows/test_docs_skip_marker.py b/tests/unit/workflows/test_docs_skip_marker.py new file mode 100644 index 0000000000..dddf429fcf --- /dev/null +++ b/tests/unit/workflows/test_docs_skip_marker.py @@ -0,0 +1,89 @@ +"""docs.yml skipped-deploy marker contract pins. + +When the independent-publication receipt gates off the legacy release deploy, +a follow-on job must mark exactly this run's pre-created github-pages record +`inactive` so deployment history does not read the skip as `success`. Two +past defects shape these pins: posting without `auto_inactive=false` marks +every prior successful github-pages deployment inactive (including the live +independent-publication deployment), and a bare newest-first lookup by SHA can +select the wrong record because duplicate github-pages records per SHA exist +(reruns, concurrent queued runs). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[3] +DOCS_PATH = REPO_ROOT / ".github" / "workflows" / "docs.yml" + + +def _load() -> dict: + return yaml.safe_load(DOCS_PATH.read_text(encoding="utf-8")) + + +def _deploy_job(data: dict) -> dict: + return data["jobs"]["deploy"] + + +def _record_job(data: dict) -> dict: + return data["jobs"]["record-skipped-deployment"] + + +def _steps(job: dict) -> list: + return job["steps"] + + +def _step_by_name(job: dict, name: str) -> dict: + for step in _steps(job): + if step.get("name") == name: + return step + raise AssertionError(f"step {name!r} not found") + + +def test_skip_marker_exposes_bound_deployment_id() -> None: + deploy = _deploy_job(_load()) + outputs = deploy.get("outputs") or {} + assert outputs.get("skipped_deployment_id"), ( + "deploy must expose the bound record id so the follow-on marks exactly this run's record" + ) + capture = _step_by_name(deploy, "Capture this run's skipped deployment record") + assert capture["id"] == "capture-deployment" + run = capture["run"] + assert 'select(.ref == "release")' in run + assert "pushed_at" in run + + +def test_skip_marker_posts_inactive_without_side_effects() -> None: + data = _load() + record = _record_job(data) + assert (record.get("permissions") or {}).get("deployments") == "write" + assert "environment" not in record, ( + "follow-on must not set environment: so no new deployment record is pre-created for the marker itself" + ) + assert "skipped_deployment_id" in record["if"] + step = _steps(record)[0] + run = step["run"] + assert "-F auto_inactive=false" in run + assert '-f state="inactive"' in run + assert "deployments/$DEPLOYMENT_ID/statuses" in run + + +def test_skip_marker_only_runs_on_skipped_release_push() -> None: + record = _record_job(_load()) + condition = record["if"] + assert "refs/heads/release" in condition + assert "skipped" in condition + assert "skipped_deployment_id" in condition + + +def test_deploy_still_gates_pages_write_on_receipt() -> None: + deploy = _deploy_job(_load()) + steps = _steps(deploy) + deploy_step = next(step for step in steps if str(step.get("uses", "")).startswith("actions/deploy-pages@")) + assert deploy_step["if"] == "steps.independent.outputs.active != 'true'" diff --git a/tests/unit/workflows/test_heavy_tier_queue_only.py b/tests/unit/workflows/test_heavy_tier_queue_only.py deleted file mode 100644 index e10ef44330..0000000000 --- a/tests/unit/workflows/test_heavy_tier_queue_only.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Tests for the heavy-tier queue-only rule (ci-dedupe-06). - -The heavy tier (medium-test, correctness-gate, plan-capture-gate, -tpch-binary-framing, integration samples) runs only when -``heavy-needed`` fires: a code-routed tree AND (merge_group event OR -soundness paths touched OR packaging paths touched). Everything else -skips, and the required umbrella models the skip explicitly instead of -trusting it. -""" - -from __future__ import annotations - -import importlib.util -import json -import subprocess -import sys -from pathlib import Path -from typing import Any - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -REPO_ROOT = Path(__file__).resolve().parents[3] -SCRIPTS = REPO_ROOT / "scripts" -PR_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr.yml" - -_SPEC = importlib.util.spec_from_file_location("heavy_tier_needed", SCRIPTS / "heavy_tier_needed.py") -assert _SPEC is not None and _SPEC.loader is not None -heavy = importlib.util.module_from_spec(_SPEC) -sys.modules[_SPEC.name] = heavy -_SPEC.loader.exec_module(heavy) - -HEAVY_JOBS = ( - "medium-test", - "correctness-gate", - "plan-capture-gate", - "tpch-binary-framing", - "postgres-integration", - "datafusion-integration", - "clickhouse-integration", -) -HEAVY_GUARD = "needs.ci-paths.outputs.heavy-needed == 'true'" - -SOUNDNESS_PATH = "benchbox/core/equivalence/ordinary.py" -PLAIN_PATH = "benchbox/core/datavault/etl/transformer.py" - - -def _predicate_source(flag_paths: set[str]) -> str: - flagged = sorted(flag_paths) - return ( - f"def any_soundness_path(paths):\n flagged = {flagged!r}\n return any(str(p) in flagged for p in paths)\n" - ) - - -def _decision(*, code: bool = True, packaging: bool = False, paths: list[str] | None = None) -> dict[str, Any]: - return { - "needs_code_ci": code, - "packaging_needed": packaging, - "changed_paths": paths if paths is not None else [PLAIN_PATH], - } - - -def _lookup(decision: dict[str, Any], event: str) -> dict[str, Any]: - base_reader = lambda _base, _root: _predicate_source({SOUNDNESS_PATH}) - pr_reader = lambda _root: _predicate_source({SOUNDNESS_PATH}) - return heavy.heavy_needed(decision, event, "origin/develop", REPO_ROOT, base_reader, pr_reader) - - -# --------------------------------------------------------------------------- -# decision table -# --------------------------------------------------------------------------- - - -def test_code_pr_without_carve_out_skips_heavy() -> None: - result = _lookup(_decision(), "pull_request") - assert result == { - "heavy_needed": False, - "reason": "no soundness path touched under either predicate copy; pull_request runs skip the heavy tier", - } - - -def test_soundness_pr_runs_heavy() -> None: - result = _lookup(_decision(paths=[SOUNDNESS_PATH]), "pull_request") - assert result["heavy_needed"] is True - assert "soundness carve-out" in str(result["reason"]) - - -def test_pr_copy_only_soundness_hit_runs_heavy() -> None: - # The union covers a PR that narrows the base predicate: the base copy - # sees nothing, the PR copy flags the path, the tier still runs. - base_reader = lambda _base, _root: _predicate_source(set()) - pr_reader = lambda _root: _predicate_source({SOUNDNESS_PATH}) - result = heavy.heavy_needed( - _decision(paths=[SOUNDNESS_PATH]), "pull_request", "origin/develop", REPO_ROOT, base_reader, pr_reader - ) - assert result["heavy_needed"] is True - assert "pr" in str(result["reason"]) - - -def test_packaging_pr_runs_heavy() -> None: - result = _lookup(_decision(packaging=True), "pull_request") - assert result == {"heavy_needed": True, "reason": "packaging paths touched (packaging carve-out)"} - - -def test_merge_group_always_runs_heavy_for_code_trees() -> None: - result = _lookup(_decision(), "merge_group") - assert result == { - "heavy_needed": True, - "reason": "merge_group runs keep the full tier on every code-routed tree", - } - - -def test_non_code_merge_group_stays_light() -> None: - result = _lookup(_decision(code=False), "merge_group") - assert result["heavy_needed"] is False - - -def test_lookup_error_fails_closed() -> None: - def _boom_base(_base: str, _root: Path) -> str: - raise OSError("git offline") - - result = heavy.heavy_needed( - _decision(paths=[SOUNDNESS_PATH]), - "pull_request", - "origin/develop", - REPO_ROOT, - _boom_base, - lambda _root: _predicate_source(set()), - ) - assert result["heavy_needed"] is True - assert "failed closed" in str(result["reason"]) - - -def test_missing_decision_fields_fail_closed() -> None: - result = _lookup({"needs_code_ci": True}, "pull_request") - assert result["heavy_needed"] is True - - -def test_main_check_and_outputs(tmp_path: Path) -> None: - decision_path = tmp_path / "decision.json" - decision_path.write_text(json.dumps(_decision()), encoding="utf-8") - output = tmp_path / "github_output.txt" - summary = tmp_path / "summary.md" - base_reader = lambda _base, _root: _predicate_source({SOUNDNESS_PATH}) - pr_reader = lambda _root: _predicate_source({SOUNDNESS_PATH}) - with pytest.MonkeyPatch.context() as patch: - patch.setattr(heavy, "_read_base_copy", base_reader) - patch.setattr(heavy, "_read_pr_copy", pr_reader) - code = heavy.main( - [ - "--decision-in", - str(decision_path), - "--event", - "pull_request", - "--github-output", - str(output), - "--summary", - str(summary), - ] - ) - assert code == 0 - check = heavy.main(["--decision-in", str(decision_path), "--event", "pull_request", "--check"]) - assert check == 1 - check_heavy = heavy.main(["--decision-in", str(decision_path), "--event", "merge_group", "--check"]) - assert check_heavy == 0 - written = output.read_text(encoding="utf-8") - assert "heavy-needed=false" in written - assert "Heavy tier needed" in summary.read_text(encoding="utf-8") - - -def test_main_without_decision_fails_closed() -> None: - assert heavy.main(["--event", "pull_request"]) == 0 - - -# --------------------------------------------------------------------------- -# pr.yml wiring -# --------------------------------------------------------------------------- - - -def _workflow() -> dict[str, Any]: - return yaml.safe_load(PR_WORKFLOW.read_text(encoding="utf-8")) # type: ignore[no-any-return] - - -def test_heavy_jobs_gate_on_heavy_needed() -> None: - jobs = _workflow()["jobs"] - for name in HEAVY_JOBS: - assert jobs[name]["if"] == "${{ " + HEAVY_GUARD + " }}", name - - -def test_light_lane_jobs_stay_on_needs_code_ci() -> None: - jobs = _workflow()["jobs"] - expected = "${{ needs.ci-paths.outputs.needs-code-ci == 'true' }}" - for name in ("code-lint", "code-test"): - assert jobs[name]["if"] == expected, name - - -def test_ci_paths_emits_heavy_needed() -> None: - jobs = _workflow()["jobs"] - assert jobs["ci-paths"]["outputs"]["heavy-needed"] == "${{ steps.heavy.outputs.heavy-needed }}" - decide = next(step for step in jobs["ci-paths"]["steps"] if step.get("id") == "heavy") - assert "scripts/heavy_tier_needed.py" in decide["run"] - assert '--event "${{ github.event_name }}"' in decide["run"] - - -def test_umbrella_models_heavy_third_state() -> None: - aggregate = _workflow()["jobs"]["ci-required-result"] - assert aggregate.get("if") == "always()" - step = next(step for step in aggregate["steps"] if step.get("name") == "Aggregate required result") - assert step["env"]["HEAVY_NEEDED"] == "${{ needs.ci-paths.outputs.heavy-needed }}" - assert "check_heavy" in step["run"] - - -def test_light_lane_certification_kind_is_not_full() -> None: - job = _workflow()["jobs"]["certification-identity"] - record = next(step for step in job["steps"] if step.get("name") == "Record certification identity") - assert record["env"]["HEAVY_NEEDED"] == "${{ needs.ci-paths.outputs.heavy-needed }}" - assert 'certification_kind = "fast"' in record["run"] - - -# --------------------------------------------------------------------------- -# umbrella semantics, executed against the shipped bash -# --------------------------------------------------------------------------- - - -def _aggregate_script(tmp_path: Path) -> Path: - step = next( - step - for step in _workflow()["jobs"]["ci-required-result"]["steps"] - if step.get("name") == "Aggregate required result" - ) - script = tmp_path / "aggregate.sh" - script.write_text(step["run"], encoding="utf-8") - return script - - -def _base_env() -> dict[str, str]: - return { - "CI_PATHS_RESULT": "success", - "TPCH_BINARY_FRAMING_RESULT": "skipped", - "CONTENT_RESULT": "skipped", - "SKILL_INTEGRITY_RESULT": "skipped", - "LINT_RESULT": "success", - "TEST_RESULT": "success", - "CORRECTNESS_RESULT": "skipped", - "PLAN_CAPTURE_RESULT": "skipped", - "MEDIUM_TEST_RESULT": "skipped", - "EXPLORER_TOKENS_RESULT": "skipped", - "SITE_THEME_TOKENS_RESULT": "skipped", - "EXPLORER_VITEST_RESULT": "skipped", - "AUDIT_SHA_RESULT": "skipped", - "PACKAGE_SMOKE_RESULT": "skipped", - "DEPENDENCY_AUDIT_RESULT": "skipped", - "PARITY_CHECK_RESULT": "skipped", - "PUBLICATION_RECONCILIATION_RESULT": "success", - "CONTENT_GUARD_NEEDED": "false", - "SKILL_INTEGRITY_NEEDED": "false", - "NEEDS_CODE_CI": "true", - "SAFE_CONTENT_ONLY": "false", - "HEAVY_NEEDED": "false", - } - - -def _run_umbrella(tmp_path: Path, **overrides: str) -> int: - import os - - env = {**os.environ, **_base_env(), **overrides} - proc = subprocess.run(["bash", str(_aggregate_script(tmp_path))], capture_output=True, text=True, env=env) - return proc.returncode - - -def test_light_pr_lane_passes_with_skipped_heavy(tmp_path: Path) -> None: - assert _run_umbrella(tmp_path) == 0 - - -def test_carve_out_pr_passes_with_successful_heavy(tmp_path: Path) -> None: - assert ( - _run_umbrella( - tmp_path, - HEAVY_NEEDED="true", - TPCH_BINARY_FRAMING_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - ) - == 0 - ) - - -def test_skipped_heavy_on_required_lane_fails_umbrella(tmp_path: Path) -> None: - assert _run_umbrella(tmp_path, HEAVY_NEEDED="true") != 0 - - -def test_failed_heavy_on_light_lane_fails_umbrella(tmp_path: Path) -> None: - assert _run_umbrella(tmp_path, MEDIUM_TEST_RESULT="failure") != 0 - - -def test_unexpected_heavy_success_on_light_lane_fails_umbrella(tmp_path: Path) -> None: - assert _run_umbrella(tmp_path, MEDIUM_TEST_RESULT="success") != 0 - - -def test_merge_group_lane_passes_with_full_heavy(tmp_path: Path) -> None: - assert ( - _run_umbrella( - tmp_path, - HEAVY_NEEDED="true", - TPCH_BINARY_FRAMING_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - ) - == 0 - ) diff --git a/tests/unit/workflows/test_nightly_t3_workflow.py b/tests/unit/workflows/test_nightly_t3_workflow.py new file mode 100644 index 0000000000..606523377b --- /dev/null +++ b/tests/unit/workflows/test_nightly_t3_workflow.py @@ -0,0 +1,368 @@ +"""Contract tests for the nightly T3 workflow. + +The workflow splits Tier 3 validation into independent domain jobs and keeps one +issue per domain label. These tests pin the properties that make that safe: +triggers without branch filters, one job per domain, the cloud opt-in variable, +issue-write permission held by the reporting job only, and SHA-pinned actions. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from tests.utilities.posix_shell import posix_shell, skip_without_posix_shell + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" +WORKFLOW = WORKFLOWS_DIR / "nightly-v2.yml" + +# Job id -> issue label. +DOMAIN_JOBS = { + "docker": "t3:docker", + "cloud": "t3:cloud", + "perf": "t3:perf", + "matrix": "t3:matrix", + "browser": "t3:browser", + "extension": "t3:extension", + "install": "t3:install", + "drift": "t3:drift", + "quarantine": "t3:quarantine", + "linkcheck": "t3:linkcheck", + "durations-refresh": "t3:durations", +} + +_PINNED_ACTION = re.compile(r"^[\w.-]+/[\w./-]+@[0-9a-f]{40}$") + + +def _load(path: Path = WORKFLOW) -> dict[str, Any]: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _triggers(workflow: dict[str, Any]) -> dict[str, Any]: + # PyYAML parses the bare `on` key as boolean True. + triggers = workflow.get("on", workflow.get(True)) + assert isinstance(triggers, dict), "workflow has no `on:` mapping" + return triggers + + +def _steps(job: dict[str, Any]) -> list[dict[str, Any]]: + return job.get("steps", []) + + +def _run_text(job: dict[str, Any]) -> str: + return "\n".join(str(step.get("run", "")) for step in _steps(job)) + + +def _run_workflow_script(script: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + skip_without_posix_shell() + shell = posix_shell() + assert shell is not None + return subprocess.run( + [shell, "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", 'export PATH="$PWD:$PATH"\n' + script], + cwd=cwd, + env={**os.environ, **(env or {})}, + capture_output=True, + text=True, + check=False, + ) + + +def test_triggers_are_schedule_and_dispatch_without_branch_filters() -> None: + triggers = _triggers(_load()) + assert set(triggers) == {"schedule", "workflow_dispatch"} + assert not any("branches" in (value or {}) for value in triggers.values() if isinstance(value, dict)) + + +def test_schedule_does_not_collide_with_other_workflows() -> None: + crons = _triggers(_load())["schedule"] + assert len(crons) == 1 + mine = crons[0]["cron"] + others: dict[str, str] = {} + for path in sorted(WORKFLOWS_DIR.glob("*.yml")): + if path == WORKFLOW: + continue + document = _load(path) + triggers = document.get("on", document.get(True)) if isinstance(document, dict) else None + if not isinstance(triggers, dict): + continue + for entry in triggers.get("schedule") or []: + others[entry["cron"]] = path.name + assert mine not in others, f"cron {mine!r} already used by {others.get(mine)}" + + +def test_every_domain_has_a_job() -> None: + jobs = _load()["jobs"] + missing = sorted(set(DOMAIN_JOBS) - set(jobs)) + assert not missing, f"missing domain jobs: {missing}" + + +def test_docker_covers_all_three_services() -> None: + docker = _load()["jobs"]["docker"] + services = {entry["service"] for entry in docker["strategy"]["matrix"]["include"]} + assert services == {"postgres", "clickhouse", "trino"} + assert docker["strategy"]["fail-fast"] is False + + +def test_wheel_matrix_covers_python_versions_and_operating_systems() -> None: + job = _load()["jobs"]["matrix"] + matrix = job["strategy"]["matrix"] + assert matrix["python-version"] == ["3.11", "3.12", "3.13", "3.14"] + assert set(matrix["os"]) == {"ubuntu-latest", "macos-latest", "windows-latest"} + text = _run_text(job) + assert "generate_data" in text and "TPCH" in text and "TPCDS" in text, "dbgen and dsdgen must run per OS" + + +def test_browser_covers_firefox_and_webkit() -> None: + assert _load()["jobs"]["browser"]["strategy"]["matrix"]["browser"] == ["firefox", "webkit"] + + +def test_cloud_job_is_gated_by_repository_variable() -> None: + condition = str(_load()["jobs"]["cloud"]["if"]) + assert "vars.BENCHBOX_T3_CLOUD_ENABLED == 'true'" in condition + # The gate must be the whole condition: no `|| true` style escape hatches. + assert "||" not in condition + + +def test_drift_job_is_weekly_gated_and_covers_all_checks() -> None: + jobs = _load()["jobs"] + assert jobs["drift"]["needs"] == ["drift-gate"] + assert "needs.drift-gate.outputs.run == 'true'" in str(jobs["drift"]["if"]) + gate_text = _run_text(jobs["drift-gate"]) + assert "date -u +%u" in gate_text and "workflow_dispatch" in gate_text + text = _run_text(jobs["drift"]) + for needle in ( + "results-data/bundles", + "find_public_path_leaks", + "check_submission_validator_sync.py", + "cross_surface_baseline_autodetect.py", + "generate_pricing_data.py --refresh", + ): + assert needle in text, f"drift job is missing {needle!r}" + + +def test_quarantine_job_tolerates_unregistered_marker() -> None: + text = _run_text(_load()["jobs"]["quarantine"]) + assert "-m quarantine" in text + assert "pytest.mark.quarantine" in text, "job must check marker registration before selecting it" + + +@pytest.mark.parametrize("pytest_exit, expected", [(0, 0), (1, 1), (2, 2), (5, 0)]) +def test_quarantine_exit_codes_under_runner_shell(tmp_path: Path, pytest_exit: int, expected: int) -> None: + stub = tmp_path / "uv" + stub.write_text( + '#!/usr/bin/env bash\nif [[ "$*" == *--markers* ]]; then\n' + ' echo "@pytest.mark.quarantine: quarantined tests"\nelse\n' + f" exit {pytest_exit}\nfi\n", + encoding="utf-8", + ) + stub.chmod(0o755) + step = next(step for step in _steps(_load()["jobs"]["quarantine"]) if step.get("name") == "Run quarantined tests") + result = _run_workflow_script(step["run"], tmp_path) + assert result.returncode == expected, result.stdout + result.stderr + + +@pytest.mark.parametrize("result_count", [0, 1, 2]) +def test_perf_comparison_uses_only_the_current_job_result(tmp_path: Path, result_count: int) -> None: + runner_temp = tmp_path / "job output" + output_root = runner_temp / "perf-smoke" + results = output_root / "results" + results.mkdir(parents=True) + for index in range(result_count): + (results / f"tpch_sf001_duckdb_sql_{index}.json").write_text("{}", encoding="utf-8") + job = _load()["jobs"]["perf"] + setup = next(step for step in _steps(job) if step.get("name") == "Set isolated benchmark output") + github_env = tmp_path / "github-env" + setup_result = _run_workflow_script( + setup["run"], tmp_path, {"RUNNER_TEMP": str(runner_temp), "GITHUB_ENV": str(github_env)} + ) + assert setup_result.returncode == 0, setup_result.stdout + setup_result.stderr + key, _, configured_root = github_env.read_text().strip().partition("=") + assert key == "BENCHBOX_OUTPUT_DIR" and Path(configured_root) == output_root + step = next(step for step in _steps(job) if step.get("id") == "current") + github_output = tmp_path / "github-output" + result = _run_workflow_script(step["run"], tmp_path, {key: configured_root, "GITHUB_OUTPUT": str(github_output)}) + assert (result.returncode == 0) == (result_count == 1), result.stdout + result.stderr + if result_count == 1: + assert github_output.read_text().strip() == f"path={results / 'tpch_sf001_duckdb_sql_0.json'}" + + +@pytest.mark.parametrize("tier", ["fast", "slow"]) +@pytest.mark.parametrize("pytest_exit, tee_exit, expected", [(0, 0, 0), (1, 0, 0), (2, 0, 2), (5, 0, 5), (0, 1, 1)]) +def test_duration_measurement_keeps_assertion_reports_and_rejects_runner_failures( + tmp_path: Path, tier: str, pytest_exit: int, tee_exit: int, expected: int +) -> None: + (tmp_path / "t3-durations").mkdir() + stub = tmp_path / "uv" + stub.write_text( + '#!/usr/bin/env bash\nfor arg in "$@"; do\n' + ' if [[ "$arg" == --junitxml=* ]]; then\n' + ' printf "" > "${arg#--junitxml=}"\n' + " fi\ndone\necho 'measurement output'\n" + f"exit {pytest_exit}\n", + encoding="utf-8", + ) + stub.chmod(0o755) + if tee_exit: + tee = tmp_path / "tee" + tee.write_text(f"#!/usr/bin/env bash\ncat >/dev/null\nexit {tee_exit}\n", encoding="utf-8") + tee.chmod(0o755) + step = next( + step + for step in _steps(_load()["jobs"]["durations-refresh"]) + if step.get("name") == f"Measure {tier} tier durations" + ) + result = _run_workflow_script(step["run"], tmp_path) + assert result.returncode == expected, result.stdout + result.stderr + assert (tmp_path / "t3-durations" / f"junit-{tier}.xml").read_text() == '' + if tee_exit == 0: + assert (tmp_path / "t3-durations" / f"pytest-{tier}.exit").read_text().strip() == str(pytest_exit) + + +@pytest.mark.parametrize("compare_exit", [0, 1]) +def test_perf_comparison_preserves_threshold_and_failure(tmp_path: Path, compare_exit: int) -> None: + current = tmp_path / "perf-smoke" / "results" / "tpch_sf001_duckdb_sql_current.json" + arguments = tmp_path / "compare-arguments" + step = next( + step + for step in _steps(_load()["jobs"]["perf"]) + if step.get("name") == "Compare against baseline (fail on >10% regression)" + ) + script = step["run"].replace("${{ steps.current.outputs.path }}", str(current)) + script = 'uv() { printf "%s\\n" "$@" > "$ARGUMENTS"; return "$COMPARE_EXIT"; }\n' + script + result = _run_workflow_script(script, tmp_path, {"ARGUMENTS": str(arguments), "COMPARE_EXIT": str(compare_exit)}) + assert result.returncode == compare_exit, result.stdout + result.stderr + assert arguments.read_text().splitlines() == [ + "run", + "benchbox", + "compare", + "_project/baselines/perf_smoke_duckdb_tpch_001.json", + str(current), + "--fail-on-regression", + "10%", + ] + + +@pytest.mark.parametrize( + "fast, slow", + [("0", "0"), ("1", "0"), ("0", "1"), ("1", "1"), (None, "0"), ("0", None), ("", "0"), ("0", "invalid")], +) +def test_duration_final_verdict_rejects_failed_missing_or_malformed_samples(tmp_path: Path, fast, slow) -> None: + output = tmp_path / "t3-durations" + output.mkdir() + for tier, value in [("fast", fast), ("slow", slow)]: + if value is not None: + (output / f"pytest-{tier}.exit").write_text(value, encoding="utf-8") + steps = _steps(_load()["jobs"]["durations-refresh"]) + final = next(step for step in steps if step.get("name") == "Require successful sampled tests") + upload = next(step for step in steps if step.get("name") == "Upload duration artifacts") + assert steps.index(final) > steps.index(upload) + assert "always()" in final["if"] and "always()" in upload["if"] + result = _run_workflow_script(final["run"], tmp_path) + assert (result.returncode == 0) == (fast == slow == "0"), result.stdout + result.stderr + + +@pytest.mark.parametrize("fast_exit, slow_exit", [(0, 0), (1, 0), (0, 1)]) +def test_failed_duration_samples_keep_both_reports_and_regenerated_artifact( + tmp_path: Path, fast_exit: int, slow_exit: int +) -> None: + tool = tmp_path / "_project" / "scripts" / "update_test_durations.py" + tool.parent.mkdir(parents=True) + shutil.copyfile(REPO_ROOT / "_project/scripts/update_test_durations.py", tool) + (tmp_path / "tests").mkdir() + shutil.copyfile(REPO_ROOT / "tests/duration_policy.py", tmp_path / "tests/duration_policy.py") + (tmp_path / "tests/__init__.py").touch() + stub = tmp_path / "uv" + stub.write_text( + "#!/usr/bin/env bash\n" + 'if [[ "$*" != *"-m pytest"* ]]; then shift; exec "$@"; fi\n' + 'for arg in "$@"; do\n' + ' if [[ "$arg" == --junitxml=* ]]; then\n' + ' report="${arg#--junitxml=}"\n' + ' case "$report" in *fast*) tier=fast; rc="$FAST_EXIT" ;; *slow*) tier=slow; rc="$SLOW_EXIT" ;; esac\n' + ' printf \'\' "$tier" > "$report"\n' + ' fi\ndone\necho "measurement $tier"\nexit "$rc"\n', + encoding="utf-8", + ) + stub.chmod(0o755) + steps = _steps(_load()["jobs"]["durations-refresh"]) + env = {"FAST_EXIT": str(fast_exit), "SLOW_EXIT": str(slow_exit)} + for name in ["Measure fast tier durations", "Measure slow tier durations", "Regenerate duration file"]: + step = next(step for step in steps if step.get("name") == name) + script = step["run"].replace("uv run -- python", f"uv run -- {sys.executable}") + result = _run_workflow_script(script, tmp_path, env) + assert result.returncode == 0, result.stdout + result.stderr + output = tmp_path / "t3-durations" + for tier in ["fast", "slow"]: + assert (output / f"junit-{tier}.xml").is_file() + assert (output / f"durations-{tier}.txt").read_text().strip() == f"measurement {tier}" + artifact = json.loads((output / "test_durations.json").read_text()) + assert len(artifact["tests"]) == 2 + final = next(step for step in steps if step.get("name") == "Require successful sampled tests") + result = _run_workflow_script(final["run"], tmp_path) + assert (result.returncode == 0) == (fast_exit == slow_exit == 0), result.stdout + result.stderr + + +def test_durations_job_emits_pytest_durations_artifact() -> None: + job = _load()["jobs"]["durations-refresh"] + assert "--durations=0" in _run_text(job) + uploads = [step for step in _steps(job) if str(step.get("uses", "")).startswith("actions/upload-artifact@")] + assert uploads and uploads[-1]["with"]["name"] == "t3-durations" + + +def test_every_domain_job_uploads_artifacts() -> None: + jobs = _load()["jobs"] + without = [ + name + for name in DOMAIN_JOBS + if not any(str(step.get("uses", "")).startswith("actions/upload-artifact@") for step in _steps(jobs[name])) + ] + assert not without, f"domain jobs without an artifact upload: {without}" + + +def test_default_permissions_are_read_only() -> None: + assert _load()["permissions"] == {"contents": "read"} + + +def test_only_report_job_holds_issue_write() -> None: + jobs = _load()["jobs"] + writers = { + name for name, job in jobs.items() if "write" in {str(v) for v in (job.get("permissions") or {}).values()} + } + assert writers == {"report"} + assert jobs["report"]["permissions"] == {"contents": "read", "issues": "write"} + + +def test_report_job_covers_every_domain_and_label() -> None: + report = _load()["jobs"]["report"] + assert set(report["needs"]) == set(DOMAIN_JOBS) + assert "always()" in str(report["if"]) + text = _run_text(report) + for job in DOMAIN_JOBS: + assert re.search(rf"\b{re.escape(job)}\b", text), f"report loop omits {job}" + # Labels are derived as t3:; the durations job maps to t3:durations. + assert 'label="t3:${domain}"' in text + assert "durations-refresh) domain=durations" in text + assert "gh issue create" in text and "gh issue close" in text and "gh issue comment" in text + + +def test_actions_are_pinned_by_sha() -> None: + unpinned: list[str] = [] + for name, job in _load()["jobs"].items(): + for step in _steps(job): + uses = step.get("uses") + if uses and not _PINNED_ACTION.match(str(uses)): + unpinned.append(f"{name}: {uses}") + assert not unpinned, f"actions must be pinned to a full commit SHA: {unpinned}" diff --git a/tests/unit/workflows/test_oracle_review_workflow.py b/tests/unit/workflows/test_oracle_review_workflow.py new file mode 100644 index 0000000000..d3b2b23234 --- /dev/null +++ b/tests/unit/workflows/test_oracle_review_workflow.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "oracle-review.yml" +SCRIPT = "_project/scripts/oracle_review_check.py" +HEAD = "a" * 40 +BASE = "b" * 40 +REPO = "BenchBox-dev/BenchBox" + + +def _load() -> dict[str, Any]: + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def _triggers() -> dict[str, Any]: + workflow = _load() + triggers = workflow.get("on", workflow.get(True)) + assert triggers is not None, "workflow has no `on:` block" + return triggers + + +def test_job_id_and_name_are_the_required_check_name() -> None: + workflow = _load() + assert workflow["name"] == "oracle-review" + assert list(workflow["jobs"]) == ["oracle-review"] + assert workflow["jobs"]["oracle-review"]["name"] == "oracle-review" + + +def test_triggers_cover_pushes_reviews_and_merge_queue() -> None: + triggers = _triggers() + assert set(triggers) == {"pull_request", "pull_request_review", "merge_group", "workflow_dispatch"} + assert triggers["merge_group"]["types"] == ["checks_requested"] + assert triggers["pull_request"]["types"] == ["opened", "synchronize", "reopened", "edited", "ready_for_review"] + assert triggers["pull_request_review"]["types"] == ["submitted", "dismissed"] + assert triggers["workflow_dispatch"]["inputs"]["pr"]["required"] is True + assert triggers["workflow_dispatch"]["inputs"]["pr"]["type"] == "string" + + +def test_permissions_are_read_only() -> None: + assert _load()["permissions"] == {"actions": "read", "contents": "read", "issues": "read", "pull-requests": "read"} + assert "permissions" not in _load()["jobs"]["oracle-review"] + + +def test_concurrency_cancels_superseded_runs_per_pull_request() -> None: + concurrency = _load()["concurrency"] + assert concurrency["cancel-in-progress"] is True + assert "github.event.pull_request.number" in concurrency["group"] + assert "inputs.pr" in concurrency["group"] + assert "github.event.merge_group.head_sha" in concurrency["group"], "queued groups must not share one slot" + + +def test_job_invokes_the_script_with_the_pull_request_number_and_token() -> None: + steps = _load()["jobs"]["oracle-review"]["steps"] + runs = [step for step in steps if SCRIPT in step.get("run", "")] + assert len(runs) == 1 + step = runs[0] + assert step["env"]["GITHUB_TOKEN"] == "${{ github.token }}" + assert "github.event.pull_request.number" in step["env"]["PR_NUMBER"] + assert "inputs.pr" in step["env"]["PR_NUMBER"] + assert "--pr" in step["run"] + assert "merge_group.head_ref" in step["env"]["MERGE_GROUP_REF"] + + +def test_actions_are_pinned_to_full_commit_shas() -> None: + steps = _load()["jobs"]["oracle-review"]["steps"] + for step in steps: + if "uses" in step: + ref = step["uses"].split("@", 1)[1] + assert len(ref) == 40 and all(c in "0123456789abcdef" for c in ref), step["uses"] + + +def test_checkout_uses_the_base_checker_and_manifest() -> None: + steps = _load()["jobs"]["oracle-review"]["steps"] + checkouts = [step for step in steps if step.get("uses", "").startswith("actions/checkout@")] + assert len(checkouts) == 1 + ref = checkouts[0]["with"]["ref"] + assert "github.event.pull_request.base.sha" in ref + assert "github.event.merge_group.base_sha" in ref + assert "steps.dispatch-pr.outputs.base_sha" in ref + assert "head" not in ref + assert checkouts[0]["with"]["persist-credentials"] is False + resolve = next(step for step in steps if step.get("id") == "dispatch-pr") + assert steps.index(resolve) < steps.index(checkouts[0]) + assert resolve["if"] == "github.event_name == 'workflow_dispatch'" + assert resolve["env"]["PR_NUMBER"] == "${{ inputs.pr }}" + assert resolve["env"]["EXPECTED_HEAD"] == "${{ github.sha }}" + assert resolve["env"]["CURRENT_REF"] == "${{ github.ref }}" + assert all("${{" not in step.get("run", "") for step in steps) + + +def test_run_name_records_the_event_action_the_script_uses_to_find_head_moves() -> None: + assert _load()["run-name"] == "oracle-review (${{ github.event.action || github.event_name }})" + + +def _pull(**changes: Any) -> dict[str, Any]: + return { + "number": 7, + "state": "OPEN", + "baseRefName": "develop", + "baseRefOid": BASE, + "headRefName": "fix/example", + "headRefOid": HEAD, + "isCrossRepository": False, + **changes, + } + + +def _environment(tmp_path: Path, pulls: list[Any], checker_exit: int, checker_message: str) -> dict[str, str]: + gh = tmp_path / "gh" + gh.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "from pathlib import Path\n" + "counter = Path(os.environ['FAKE_GH_COUNTER'])\n" + "index = int(counter.read_text()) if counter.exists() else 0\n" + "counter.write_text(str(index + 1))\n" + "with open(os.environ['FAKE_GH_CALLS'], 'a') as calls:\n" + " calls.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "assert sys.argv[1:6] == ['pr', 'view', '7', '--repo', 'BenchBox-dev/BenchBox']\n" + "pull = json.loads(os.environ['FAKE_PULLS'])[index]\n" + "if isinstance(pull, dict) and pull.get('api_error'):\n" + " sys.exit(1)\n" + "if isinstance(pull, dict):\n" + " pull = {field: pull[field] for field in sys.argv[7].split(',') if field in pull}\n" + "print(json.dumps(pull))\n" + ) + gh.chmod(0o755) + checker = tmp_path / "python" + checker.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "from pathlib import Path\n" + "Path(os.environ['FAKE_CHECKER_CALL']).write_text(json.dumps(sys.argv[1:]))\n" + "print(os.environ['FAKE_CHECKER_MESSAGE'])\n" + "sys.exit(int(os.environ['FAKE_CHECKER_EXIT']))\n" + ) + checker.chmod(0o755) + return { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "FAKE_GH_COUNTER": str(tmp_path / "gh-count"), + "FAKE_GH_CALLS": str(tmp_path / "gh-calls"), + "FAKE_PULLS": json.dumps(pulls), + "FAKE_CHECKER_CALL": str(tmp_path / "checker-call"), + "FAKE_CHECKER_EXIT": str(checker_exit), + "FAKE_CHECKER_MESSAGE": checker_message, + "GITHUB_OUTPUT": str(tmp_path / "outputs"), + "GH_TOKEN": "fixture-token", + "GITHUB_TOKEN": "fixture-token", + "EVENT_NAME": "workflow_dispatch", + "PR_NUMBER": "7", + "REPO": REPO, + "EXPECTED_HEAD": HEAD, + "CURRENT_REF": "refs/heads/fix/example", + "MERGE_GROUP_REF": "", + "PR_SNAPSHOT": "", + } + + +def _run_step(tmp_path: Path, step: dict[str, Any], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + script = tmp_path / "workflow-step.sh" + script.write_text(step["run"], encoding="utf-8") + return subprocess.run(["bash", str(script)], env=env, text=True, capture_output=True, timeout=10) + + +def _dispatch( + tmp_path: Path, + pulls: list[Any], + *, + checker_exit: int = 0, + checker_message: str = "oracle-review: not a soundness path change", + **env_changes: str, +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + env = _environment(tmp_path, pulls, checker_exit, checker_message) + env.update(env_changes) + steps = _load()["jobs"]["oracle-review"]["steps"] + resolve = next(step for step in steps if step.get("id") == "dispatch-pr") + check = next(step for step in steps if SCRIPT in step.get("run", "")) + result = _run_step(tmp_path, resolve, env) + if result.returncode: + return result, {} + outputs = dict(line.split("=", 1) for line in (tmp_path / "outputs").read_text().splitlines()) + env["PR_SNAPSHOT"] = outputs["snapshot"] + return _run_step(tmp_path, check, env), outputs + + +@pytest.mark.parametrize( + "message", + ["oracle-review: not a soundness path change", f"oracle-review: pass (Codex connector review of {HEAD})"], +) +def test_dispatch_checks_current_head_around_every_checker_success(tmp_path: Path, message: str) -> None: + result, outputs = _dispatch(tmp_path, [_pull(), _pull(), _pull()], checker_message=message) + assert result.returncode == 0, result.stderr + assert outputs["base_sha"] == BASE + assert json.loads(outputs["snapshot"])["headRefOid"] == HEAD + assert (tmp_path / "gh-count").read_text() == "3" + assert json.loads((tmp_path / "checker-call").read_text()) == [SCRIPT, "--repo", REPO, "--pr", "7"] + assert message in result.stdout + + +@pytest.mark.parametrize("pr", ["", "0", "-7", "7.5", " 7", "7\n8", "$(touch injected)"]) +def test_dispatch_rejects_malformed_pr_before_any_api_or_checkout(tmp_path: Path, pr: str) -> None: + result, outputs = _dispatch(tmp_path, [], PR_NUMBER=pr) + assert result.returncode != 0 + assert outputs == {} + assert not (tmp_path / "gh-count").exists() + assert not (tmp_path / "checker-call").exists() + + +@pytest.mark.parametrize("head", ["", "a" * 39, "z" * 40]) +def test_dispatch_rejects_malformed_captured_commit(tmp_path: Path, head: str) -> None: + result, outputs = _dispatch(tmp_path, [], EXPECTED_HEAD=head) + assert result.returncode != 0 + assert outputs == {} + assert not (tmp_path / "gh-count").exists() + + +@pytest.mark.parametrize( + "changes", + [ + {"number": 8}, + {"state": "CLOSED"}, + {"baseRefName": "release"}, + {"isCrossRepository": True}, + {"headRefName": "fix/another"}, + {"headRefOid": "c" * 40}, + {"baseRefOid": None}, + {"baseRefOid": "not-a-sha"}, + ], +) +def test_dispatch_rejects_wrong_pr_or_untrusted_checkout(tmp_path: Path, changes: dict[str, Any]) -> None: + result, outputs = _dispatch(tmp_path, [_pull(**changes)]) + assert result.returncode != 0 + assert outputs == {} + assert not (tmp_path / "outputs").exists() + assert not (tmp_path / "checker-call").exists() + + +@pytest.mark.parametrize("ref", ["refs/heads/develop", "refs/tags/fix/example", "refs/heads/fix/another"]) +def test_dispatch_rejects_default_branch_tags_and_other_branches(tmp_path: Path, ref: str) -> None: + result, outputs = _dispatch(tmp_path, [_pull()], CURRENT_REF=ref) + assert result.returncode != 0 + assert outputs == {} + assert not (tmp_path / "checker-call").exists() + + +@pytest.mark.parametrize("changed_read", [1, 2]) +def test_dispatch_rejects_head_moving_before_or_during_checker(tmp_path: Path, changed_read: int) -> None: + pulls = [_pull(), _pull(), _pull()] + pulls[changed_read] = _pull(headRefOid="c" * 40) + result, _ = _dispatch(tmp_path, pulls) + assert result.returncode != 0 + assert "identity or head changed" in result.stderr + assert (tmp_path / "checker-call").exists() is (changed_read == 2) + + +@pytest.mark.parametrize("changes", [{"state": "CLOSED"}, {"baseRefName": "release"}, {"isCrossRepository": True}]) +def test_dispatch_rejects_identity_change_after_checker(tmp_path: Path, changes: dict[str, Any]) -> None: + result, _ = _dispatch(tmp_path, [_pull(), _pull(), _pull(**changes)]) + assert result.returncode != 0 + assert "identity or head changed" in result.stderr + assert (tmp_path / "checker-call").exists() + + +@pytest.mark.parametrize("failed_read", [0, 1, 2]) +def test_dispatch_api_failure_never_passes(tmp_path: Path, failed_read: int) -> None: + pulls = [_pull(), _pull(), _pull()] + pulls[failed_read] = {"api_error": True} + result, _ = _dispatch(tmp_path, pulls) + assert result.returncode != 0 + assert (tmp_path / "checker-call").exists() is (failed_read == 2) + + +@pytest.mark.parametrize("pull", [None, {}, "malformed"]) +def test_dispatch_malformed_metadata_never_reaches_checkout(tmp_path: Path, pull: Any) -> None: + result, outputs = _dispatch(tmp_path, [pull]) + assert result.returncode != 0 + assert outputs == {} + assert not (tmp_path / "checker-call").exists() + + +@pytest.mark.parametrize("checker_exit", [1, 2]) +def test_dispatch_waiting_and_error_remain_failed(tmp_path: Path, checker_exit: int) -> None: + result, _ = _dispatch(tmp_path, [_pull(), _pull()], checker_exit=checker_exit) + assert result.returncode == checker_exit + assert (tmp_path / "gh-count").read_text() == "2" + + +def test_dispatch_does_not_require_develop_to_stop_advancing(tmp_path: Path) -> None: + result, outputs = _dispatch(tmp_path, [_pull(), _pull(baseRefOid="c" * 40), _pull(baseRefOid="d" * 40)]) + assert result.returncode == 0, result.stderr + assert outputs["base_sha"] == BASE + + +@pytest.mark.parametrize( + ("event", "pr", "merge_ref"), + [("pull_request", "7", ""), ("merge_group", "", "refs/heads/gh-readonly-queue/develop/pr-7-example")], +) +def test_automatic_events_do_not_use_dispatch_guards(tmp_path: Path, event: str, pr: str, merge_ref: str) -> None: + env = _environment(tmp_path, [], 0, "oracle-review: not a soundness path change") + env.update(EVENT_NAME=event, PR_NUMBER=pr, MERGE_GROUP_REF=merge_ref) + check = next(step for step in _load()["jobs"]["oracle-review"]["steps"] if SCRIPT in step.get("run", "")) + result = _run_step(tmp_path, check, env) + assert result.returncode == 0, result.stderr + assert not (tmp_path / "gh-count").exists() + assert json.loads((tmp_path / "checker-call").read_text()) == [SCRIPT, "--repo", REPO, "--pr", "7"] diff --git a/tests/unit/workflows/test_pr_refresh_shadow_workflow.py b/tests/unit/workflows/test_pr_refresh_shadow_workflow.py deleted file mode 100644 index 49bcdec9dd..0000000000 --- a/tests/unit/workflows/test_pr_refresh_shadow_workflow.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Trust and permission contracts for the refresh-shadow workflow.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -REPO_ROOT = Path(__file__).resolve().parents[3] -SHADOW = REPO_ROOT / ".github" / "workflows" / "develop-refresh-shadow.yml" -PR_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr.yml" -ADMIN = REPO_ROOT / "docs" / "operations" / "repo-admin-settings.md" - - -def _triggers(workflow: dict[str, Any]) -> dict[str, Any]: - triggers = workflow.get("on", workflow.get(True)) - assert triggers is not None - return triggers - - -@pytest.fixture(scope="module") -def workflow() -> dict[str, Any]: - return yaml.safe_load(SHADOW.read_text(encoding="utf-8")) - - -def test_shadow_uses_pull_request_target_and_is_not_required(workflow: dict[str, Any]) -> None: - triggers = _triggers(workflow) - assert "pull_request_target" in triggers - assert triggers["pull_request_target"]["branches"] == ["develop"] - text = SHADOW.read_text(encoding="utf-8") - assert "ci-required-result" not in text - assert "Results Explorer browser gate" not in text - admin = ADMIN.read_text(encoding="utf-8") - assert "refresh-shadow" in admin - assert "not a required" in admin.lower() or "observational" in admin.lower() - required_block = admin.split("Required status checks:", 1)[1].split("```text", 1)[1].split("```", 1)[0] - assert "refresh-shadow" not in required_block - - -def test_shadow_permissions_are_read_only(workflow: dict[str, Any]) -> None: - perms = workflow["permissions"] - assert perms == { - "contents": "read", - "actions": "read", - "pull-requests": "read", - } - assert "write" not in yaml.safe_dump(perms) - - -def test_shadow_checks_out_trusted_base_only(workflow: dict[str, Any]) -> None: - job = workflow["jobs"]["refresh-shadow"] - checkout = next(step for step in job["steps"] if str(step.get("uses", "")).startswith("actions/checkout@")) - assert checkout["with"]["ref"] == "${{ github.event.pull_request.base.sha }}" - assert checkout["with"]["persist-credentials"] is False - text = SHADOW.read_text(encoding="utf-8") - checkout_region = text.split("actions/checkout", 1)[-1].split("Fetch PR commits", 1)[0] - assert "pull_request.head.sha" not in checkout_region - assert "ref: ${{ github.event.pull_request.head.sha }}" not in text - assert "ref: ${{ github.sha }}" not in text - - -def test_shadow_does_not_skip_or_replace_required_pr_lanes() -> None: - pr = yaml.safe_load(PR_WORKFLOW.read_text(encoding="utf-8")) - required = pr["jobs"]["ci-required-result"]["needs"] - for name in ( - "ci-paths", - "content-guard", - "code-lint", - "code-test", - "correctness-gate", - "plan-capture-gate", - "medium-test", - ): - assert name in required - lint_if = pr["jobs"]["code-lint"]["if"] - test_if = pr["jobs"]["code-test"]["if"] - assert "refresh" not in lint_if - assert "refresh" not in test_if - assert "shadow" not in lint_if - assert "shadow" not in test_if - - -def test_shadow_collector_fail_closes_without_inventing_prior_identity(workflow: dict[str, Any]) -> None: - text = SHADOW.read_text(encoding="utf-8") - assert "pr-certification-identity" in text - assert "pr-certification-lanes" in text - assert "actions/artifacts/" in text - assert "gh" in text and "api" in text - assert "scripts/pr_refresh_certification.py" in text - assert "missing_path_evidence" in text - classify = next( - step - for step in workflow["jobs"]["refresh-shadow"]["steps"] - if step.get("name") == "Classify refresh in shadow mode" - ) - assert "contents: write" not in yaml.safe_dump(classify) - - -def test_shadow_uploads_bounded_decision_artifact(workflow: dict[str, Any]) -> None: - upload = next( - step for step in workflow["jobs"]["refresh-shadow"]["steps"] if step.get("name") == "Upload shadow decision" - ) - assert upload["if"] == "always()" - assert upload["with"]["path"] == "shadow-decision.json" - assert "refresh-shadow-" in str(upload["with"]["name"]) - text = SHADOW.read_text(encoding="utf-8") - assert "duration_ms" in text - assert "This record is not a required check" in text diff --git a/tests/unit/workflows/test_pr_workflow_outputs.py b/tests/unit/workflows/test_pr_workflow_outputs.py deleted file mode 100644 index 770b75393d..0000000000 --- a/tests/unit/workflows/test_pr_workflow_outputs.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Pin Develop PR required-lane outputs after shadow evidence was added.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -REPO_ROOT = Path(__file__).resolve().parents[3] -PR_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr.yml" - - -@pytest.fixture(scope="module") -def workflow() -> dict[str, Any]: - return yaml.safe_load(PR_WORKFLOW.read_text(encoding="utf-8")) - - -def test_required_umbrella_observes_every_selected_lane(workflow: dict[str, Any]) -> None: - needs = workflow["jobs"]["ci-required-result"]["needs"] - assert needs == [ - "ci-paths", - "tpch-binary-framing", - "content-guard", - "skill-integrity", - "code-lint", - "code-test", - "correctness-gate", - "plan-capture-gate", - "medium-test", - "explorer-tokens", - "site-theme-tokens", - "explorer-vitest", - "audit-sha", - "package-smoke", - "dependency-audit", - "parity-check", - "publication-reconciliation", - ] - - -def test_code_lanes_still_path_gated_only(workflow: dict[str, Any]) -> None: - expected = "${{ needs.ci-paths.outputs.needs-code-ci == 'true' }}" - for job_id in ("code-lint", "code-test"): - assert workflow["jobs"][job_id]["if"] == expected - - -def test_heavy_tier_gates_on_heavy_needed(workflow: dict[str, Any]) -> None: - # Since the heavy-tier queue-only change, the heavy tier runs on - # merge_group for every code-routed tree and skips on pull_request - # unless a carve-out fires; code-lint/code-test still run on every - # code-routed PR. - expected = "${{ needs.ci-paths.outputs.heavy-needed == 'true' }}" - for job_id in ( - "medium-test", - "correctness-gate", - "plan-capture-gate", - "tpch-binary-framing", - "postgres-integration", - "datafusion-integration", - "clickhouse-integration", - ): - assert workflow["jobs"][job_id]["if"] == expected - - -def test_certification_identity_is_observational(workflow: dict[str, Any]) -> None: - job = workflow["jobs"]["certification-identity"] - assert "if" not in job - assert "certification-identity" not in workflow["jobs"]["ci-required-result"]["needs"] - names = [step.get("name") for step in job["steps"]] - assert "Record certification identity" in names - checkout = next(step for step in job["steps"] if str(step.get("uses", "")).startswith("actions/checkout@")) - assert checkout["with"]["persist-credentials"] is False - - -def test_lane_evidence_cannot_fail_the_required_umbrella(workflow: dict[str, Any]) -> None: - steps = workflow["jobs"]["ci-required-result"]["steps"] - record = next(step for step in steps if step.get("name") == "Record certification lane evidence") - evidence = next(step for step in steps if step.get("name") == "Upload certification lane evidence") - assert record.get("if") == "always()" - assert evidence.get("if") == "always()" - assert evidence.get("continue-on-error") is True - assert "pr-certification-lanes.json" in record["run"] - assert evidence["with"]["name"] == "pr-certification-lanes" - - -def test_tpch_binary_framing_is_observed_by_the_required_umbrella(workflow: dict[str, Any]) -> None: - aggregate = workflow["jobs"]["ci-required-result"] - assert "tpch-binary-framing" in aggregate["needs"] - step = next(step for step in aggregate["steps"] if step.get("name") == "Aggregate required result") - assert step["env"]["TPCH_BINARY_FRAMING_RESULT"] == "${{ needs.tpch-binary-framing.result }}" - assert 'TPCH_BINARY_FRAMING_RESULT" != "success"' in step["run"] diff --git a/tests/unit/workflows/test_pr_workflow_path_classifier.py b/tests/unit/workflows/test_pr_workflow_path_classifier.py deleted file mode 100644 index eefa1d9f93..0000000000 --- a/tests/unit/workflows/test_pr_workflow_path_classifier.py +++ /dev/null @@ -1,679 +0,0 @@ -"""Guardrails for the develop PR path classifier workflow.""" - -from __future__ import annotations - -import importlib.util -import os -import re -import subprocess -import sys -from pathlib import Path -from typing import Any - -import pytest -import yaml - -from tests.utilities.posix_shell import run_posix_shell, skip_without_posix_shell - -pytestmark = [ - pytest.mark.unit, - pytest.mark.fast, -] - -REPO_ROOT = Path(__file__).resolve().parents[3] - -# Matches dot and bracket reference forms for every segment of the chain -# independently: needs.ci-paths.outputs.x, needs['ci-paths'].outputs.x, -# needs.ci-paths.outputs['x'], needs['ci-paths']['outputs']['x'] (the fully -# bracketed form GitHub Actions also accepts for hyphenated keys), and any -# dot/bracket mix across the three segments. Shared by the lockstep test and -# its own regex-behavior regression test below so they cannot drift apart. -_CI_PATHS_SEG = r"(?:\.ci-paths|\[['\"]ci-paths['\"]\])" -_OUTPUTS_SEG = r"(?:\.outputs|\[['\"]outputs['\"]\])" -CI_PATHS_OUTPUT_REFERENCE_RE = ( - r"needs" + _CI_PATHS_SEG + _OUTPUTS_SEG + r"(?:\.([A-Za-z0-9_-]+)|\[['\"]([A-Za-z0-9_-]+)['\"]\])" -) - - -def _load_path_filter_decision() -> Any: - """Load scripts/path_filter_decision.py the way the workflow uses it. - - tests/unit/scripts/ has a conftest sys.path shim; here we load by file - path so the lockstep test derives group names from the same - `load_rules` + `extra_group_keys` logic the classify step runs (output - naming additionally mirrors write_github_output's `_`→`-` mapping). - """ - scripts_dir = str(REPO_ROOT / "scripts") - if scripts_dir not in sys.path: - sys.path.insert(0, scripts_dir) - script = REPO_ROOT / "scripts" / "path_filter_decision.py" - spec = importlib.util.spec_from_file_location("path_filter_decision_lockstep", script) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _ci_required_result_script() -> str: - workflow = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8")) - steps = workflow["jobs"]["ci-required-result"]["steps"] - for step in steps: - if step.get("name") == "Aggregate required result": - return step["run"] - raise AssertionError("ci-required-result aggregate step not found") - - -def _run_ci_required_result(**env_overrides: str) -> subprocess.CompletedProcess[str]: - # Only the shell-executing tests in this module skip; the YAML/regex ones - # alongside them need no shell and must keep running everywhere. - skip_without_posix_shell() - env = { - **os.environ, - "CI_PATHS_RESULT": "success", - "TPCH_BINARY_FRAMING_RESULT": "success", - "CONTENT_RESULT": "skipped", - "SKILL_INTEGRITY_RESULT": "skipped", - "LINT_RESULT": "skipped", - "TEST_RESULT": "skipped", - "CORRECTNESS_RESULT": "skipped", - "PLAN_CAPTURE_RESULT": "skipped", - "MEDIUM_TEST_RESULT": "skipped", - "EXPLORER_TOKENS_RESULT": "skipped", - "SITE_THEME_TOKENS_RESULT": "skipped", - "AUDIT_SHA_RESULT": "skipped", - "PACKAGE_SMOKE_RESULT": "skipped", - "DEPENDENCY_AUDIT_RESULT": "skipped", - "PARITY_CHECK_RESULT": "skipped", - "PUBLICATION_RECONCILIATION_RESULT": "success", - "EXPLORER_VITEST_RESULT": "skipped", - "CONTENT_GUARD_NEEDED": "false", - "SKILL_INTEGRITY_NEEDED": "false", - "NEEDS_CODE_CI": "false", - "SAFE_CONTENT_ONLY": "true", - "HEAVY_NEEDED": "false", - **env_overrides, - } - return run_posix_shell( - _ci_required_result_script(), - check=False, - capture_output=True, - env=env, - text=True, - ) - - -def test_pr_path_classifier_fetches_base_history_for_merge_base() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8") - base_fetch = "git fetch --no-tags origin \"${{ github.base_ref || 'develop' }}:refs/remotes/origin/${{ github.base_ref || 'develop' }}\"" - - # The classifier uses `git diff origin/develop...HEAD`; a depth-1 base fetch - # on GitHub's synthetic PR merge ref can leave no merge base available. - # Consumers of the full base history today: - # - ci-paths (path classifier) - # - content-guard (recreates path lists for content validators) - # - explorer-tokens and site-theme-tokens (each greps the diff for its - # own source changes) - assert '--depth=1 origin "${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}"' not in workflow - assert workflow.count(base_fetch) == 4 - - -def test_ci_required_result_preserves_content_guard_failure() -> None: - result = _run_ci_required_result( - CONTENT_GUARD_NEEDED="true", - CONTENT_RESULT="failure", - ) - - assert result.returncode == 1 - assert "content-guard=failure" in result.stdout - assert "Content-only PR; skipped Python code CI." not in result.stdout - - -def test_ci_required_result_fails_on_explorer_tokens_failure() -> None: - # When the explorer-tokens job fires (results-explorer/src changed) and - # fails, the aggregator must fail the required result. Without this - # branch, the PR could merge despite a token-scan red — silently - # regressing the blind-spot remediation. - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="failure", - ) - - assert result.returncode == 1 - assert "explorer-tokens=failure" in result.stdout - - -def test_ci_required_result_requires_selected_skill_integrity_success() -> None: - result = _run_ci_required_result( - SKILL_INTEGRITY_NEEDED="true", - SKILL_INTEGRITY_RESULT="skipped", - SAFE_CONTENT_ONLY="false", - ) - - assert result.returncode == 1 - assert "skill-integrity=skipped" in result.stdout - - -def test_ci_required_result_accepts_skill_integrity_only_success() -> None: - result = _run_ci_required_result( - SKILL_INTEGRITY_NEEDED="true", - SKILL_INTEGRITY_RESULT="success", - SAFE_CONTENT_ONLY="false", - ) - - assert result.returncode == 0 - assert "Skill-integrity-only PR" in result.stdout - - -def test_ci_required_result_requires_skill_and_product_for_mixed_diff() -> None: - result = _run_ci_required_result( - SKILL_INTEGRITY_NEEDED="true", - SKILL_INTEGRITY_RESULT="failure", - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - ) - - assert result.returncode == 1 - assert "skill-integrity=failure" in result.stdout - - -def test_ci_required_result_fails_on_tpch_binary_framing_failure() -> None: - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - TPCH_BINARY_FRAMING_RESULT="failure", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - ) - - assert result.returncode == 1 - assert "tpch-binary-framing=failure" in result.stdout - - -def test_ci_required_result_accepts_tpch_binary_framing_success() -> None: - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - TPCH_BINARY_FRAMING_RESULT="success", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - ) - - assert result.returncode == 0 - - -def test_ci_required_result_fails_on_explorer_vitest_failure() -> None: - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - EXPLORER_VITEST_RESULT="failure", - ) - - assert result.returncode == 1 - assert "explorer-vitest=failure" in result.stdout - - -def test_ci_required_result_treats_explorer_vitest_skipped_as_success() -> None: - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - EXPLORER_VITEST_RESULT="skipped", - ) - - assert result.returncode == 0 - - -def test_ci_required_result_treats_explorer_tokens_skipped_as_success() -> None: - # Defensive: pin the `|| "skipped"` clause in the aggregator's - # explorer-tokens check. The (NEEDS_CODE_CI="true", - # EXPLORER_TOKENS_RESULT="skipped") combination is not produced by any - # real PR shape today: the explorer-tokens job's `if:` is gated on - # `needs-code-ci == 'true'`, so when needs-code-ci is true the job runs - # (its inner detection step skips the scan when no results-explorer/src - # paths changed, but the *job* still concludes "success", not - # "skipped"). The only path that yields EXPLORER_TOKENS_RESULT="skipped" - # is needs-code-ci="false", but the aggregator early-exits at the - # NEEDS_CODE_CI=false branch before reading EXPLORER_TOKENS_RESULT. The - # `|| "skipped"` clause is therefore belt-and-braces — this test pins - # it so a future cleanup that drops the clause is a deliberate choice. - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="skipped", - ) - - assert result.returncode == 0 - assert "Code/infra PR; lint, fast tests, and the required heavy tier passed." in result.stdout - - -def test_ci_required_result_passes_on_explorer_tokens_success() -> None: - # Sanity: when explorer-tokens runs and passes alongside lint+test - # success, the aggregator returns success. - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - ) - - assert result.returncode == 0 - assert "Code/infra PR; lint, fast tests, and the required heavy tier passed." in result.stdout - - -def test_ci_required_result_fails_on_plan_capture_gate_failure() -> None: - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="failure", - EXPLORER_TOKENS_RESULT="success", - ) - - assert result.returncode == 1 - assert "plan-capture-gate=failure" in result.stdout - - -def test_ci_required_result_fails_on_medium_test_failure() -> None: - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="failure", - EXPLORER_TOKENS_RESULT="success", - ) - - assert result.returncode == 1 - assert "medium-test=failure" in result.stdout - - -def test_ci_required_result_required_jobs_in_needs() -> None: - # If a future cleanup drops `explorer-tokens` from the - # `ci-required-result.needs:` list, the aggregator wouldn't observe its - # status at all (always "" → handled as not-success in the bash logic). - # Lock the wiring in place. - workflow_yaml = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8")) - needs = workflow_yaml["jobs"]["ci-required-result"]["needs"] - assert "explorer-tokens" in needs - assert "skill-integrity" in needs - assert "correctness-gate" in needs - assert "plan-capture-gate" in needs - # Path-filtered packaging promotion (pr-gate-package-and-audit-promotion): - # each must be observed by the aggregator or a skipped-counts-as-pass - # result silently disappears from the gate. - assert "package-smoke" in needs - assert "dependency-audit" in needs - assert "explorer-vitest" in needs - - -def test_skill_integrity_job_is_required_read_only_and_pinned() -> None: - workflow_text = (REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8") - workflow = yaml.safe_load(workflow_text) - job = workflow["jobs"]["skill-integrity"] - job_text = yaml.safe_dump(job) - - assert job["needs"] == "ci-paths" - assert job["if"] == "${{ needs.ci-paths.outputs.skill-integrity-needed == 'true' }}" - assert job["timeout-minutes"] == 10 - assert "permissions" not in job # inherits workflow-level contents: read - assert "scripts/skill_sync_ci_policy.py validate --manifest skill-sync.conf" in workflow_text - # The vendored shell wrapper runs the full cycle with no Node build. - assert "sh tools/skill-sync preview" in workflow_text - assert "sh tools/skill-sync apply" in workflow_text - assert "sh tools/skill-sync verify" in workflow_text - assert "sh tools/skill-sync check" in workflow_text - # The retired TypeScript verifier must not come back: no npm build of a - # verifier checkout and no node dist CLI inside this job. - assert "npm ci" not in job_text - assert "dist/cli/index.js" not in job_text - assert "6d09682dabe2ff0d68f400d60f8ba8b87f8c02aa" not in workflow_text - assert '--check-commit-range "$BASE_SHA"' in workflow_text - - -def test_classifier_and_certification_bind_to_pull_request_event_base() -> None: - workflow = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8")) - classify = next(step for step in workflow["jobs"]["ci-paths"]["steps"] if step.get("id") == "classify") - certification = workflow["jobs"]["certification-identity"] - - assert ( - '--base-ref "${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}"' in classify["run"] - ) - assert "origin/${{ github.base_ref }}" not in classify["run"] - assert certification["needs"] == "ci-paths" - record = next(step for step in certification["steps"] if step.get("name") == "Record certification identity") - assert record["env"]["BASE_SHA"] == "${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}" - assert record["env"]["SKILL_INTEGRITY_NEEDED"] == ("${{ needs.ci-paths.outputs.skill-integrity-needed }}") - assert 'certification_kind = "skill_integrity"' in record["run"] - assert 'certification_kind = "full"' in record["run"] - - -def test_explorer_vitest_job_uses_contract_filter_and_exact_command() -> None: - workflow_yaml = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8")) - job = workflow_yaml["jobs"]["explorer-vitest"] - assert job["if"] == "${{ needs.ci-paths.outputs.explorer-vitest-needed == 'true' }}" - run_steps = [step for step in job["steps"] if step.get("name") == "Run Explorer Vitest suite"] - assert len(run_steps) == 1 - assert run_steps[0]["run"] == "npm test -- --run" - assert run_steps[0]["working-directory"] == "results-explorer" - aggregate = next( - step - for step in workflow_yaml["jobs"]["ci-required-result"]["steps"] - if step.get("name") == "Aggregate required result" - ) - assert aggregate["env"]["EXPLORER_VITEST_RESULT"] == "${{ needs.explorer-vitest.result }}" - - -@pytest.mark.parametrize( - "changed_path", - [ - ".github/workflows/pr.yml", - ".github/path-filters.yml", - "tests/unit/core/tuning/fixtures/tuning_mode_vocabulary.yaml", - "tests/parity/fixtures/chart_ids.json", - ], -) -def test_explorer_vitest_filter_covers_each_non_local_contract_input(changed_path: str) -> None: - pfd = _load_path_filter_decision() - rules = pfd.load_rules(REPO_ROOT / ".github" / "path-filters.yml") - - decision = pfd.classify_paths([changed_path], rules) - - assert decision["explorer_vitest_needed"] is True - assert decision["explorer_vitest_paths"] == [changed_path] - - -def test_explorer_vitest_filter_skips_unrelated_python_changes() -> None: - pfd = _load_path_filter_decision() - rules = pfd.load_rules(REPO_ROOT / ".github" / "path-filters.yml") - - decision = pfd.classify_paths(["benchbox/cli/run.py"], rules) - - assert decision["explorer_vitest_needed"] is False - assert decision["explorer_vitest_paths"] == [] - - -def test_explorer_vitest_job_declares_clean_runner_python_and_uv_setup() -> None: - workflow_yaml = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8")) - job = workflow_yaml["jobs"]["explorer-vitest"] - steps = job["steps"] - - setup_python = next(step for step in steps if step.get("name") == "Set up Python") - assert re.fullmatch(r"actions/setup-python@[0-9a-f]{40}", setup_python["uses"]), ( - "explorer-vitest setup-python must pin to an immutable SHA" - ) - assert setup_python["with"]["python-version"] == "3.12" - - setup_uv = next(step for step in steps if step.get("name") == "Install uv") - assert re.fullmatch(r"astral-sh/setup-uv@[0-9a-f]{40}", setup_uv["uses"]), ( - "explorer-vitest setup-uv must pin to an immutable SHA" - ) - - install_python = next(step for step in steps if step.get("name") == "Install Python dependencies") - assert install_python["run"] == "uv sync --group dev" - - npm_install_index = next(i for i, step in enumerate(steps) if step.get("name") == "Install explorer dependencies") - vitest_index = next(i for i, step in enumerate(steps) if step.get("name") == "Run Explorer Vitest suite") - prerequisite_indexes = [ - i - for i, step in enumerate(steps) - if step.get("name") in {"Set up Python", "Install uv", "Install Python dependencies"} - ] - assert len(prerequisite_indexes) == 3 - - # Anchor the ordering to the Vitest step, not just to `npm ci`. Specs such as - # db-remediation-pin.test.ts and tuningModeVocabulary.test.ts spawn `uv`, so - # moving the suite ahead of the Python/uv setup group breaks a clean runner - # even while `npm ci` still trails it - which the npm-only guard below, and - # the exact-command test above, would both still wave through. - assert all(i < vitest_index for i in prerequisite_indexes) - assert all(i < npm_install_index for i in prerequisite_indexes) - assert npm_install_index < vitest_index - assert all("continue-on-error" not in step for step in steps) - - -def test_ci_paths_job_outputs_declare_every_path_filter_group() -> None: - # Incident pin (PR #952 → fixed in PR #955 commit 8652d135): the classify - # step wrote `packaging-needed`/`viz-needed` to GITHUB_OUTPUT and the - # gated jobs' `if:` referenced `needs.ci-paths.outputs.-needed`, - # but the ci-paths JOB `outputs:` block never mapped those step outputs. - # GitHub Actions only exposes job outputs that are explicitly declared, - # so every `if:` saw "" and package-smoke / dependency-audit / - # parity-check silently skipped on EVERY PR — reading green via the - # aggregator's skip-counts-as-pass rule. path-filters.yml is designed to - # grow new groups without script changes, so this lockstep must be - # derived from the rules file, never a hardcoded group list. - # (Mapping currently lives at pr.yml jobs.ci-paths.outputs, lines ~27-28.) - pfd = _load_path_filter_decision() - groups = pfd.extra_group_keys(pfd.load_rules(REPO_ROOT / ".github" / "path-filters.yml")) - assert groups, "expected at least one extra path-filter group (e.g. packaging, viz)" - - workflow_yaml = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8")) - declared_outputs = workflow_yaml["jobs"]["ci-paths"]["outputs"] - - # Direction 1 (both ways): the set of `*-needed` outputs declared on the - # ci-paths job must equal the classify step's `*-needed` emissions — - # one per extra group (write_github_output normalizes the rules key - # `_`→`-`) plus the always-emitted core `content-guard-needed`. A - # missing declaration silently disables a gate (the #952 incident); a - # stale declaration after a group is removed/renamed feeds "" to the - # gated job's `if:` — the same silent skip from the other side. - expected_needed = {f"{group.replace('_', '-')}-needed" for group in groups} - expected_needed.add("content-guard-needed") - declared_needed = {key for key in declared_outputs if key.endswith("-needed")} - # `heavy-needed` is deliberately not a path-filter group emission: the - # heavy-tier decision combines the classifier outputs with the event - # name and the soundness carve-out in the dedicated heavy step, so it - # cannot come from write_github_output. It keeps its own mapping pin - # below instead of joining this lockstep. - assert declared_needed == expected_needed | {"heavy-needed"}, ( - "jobs.ci-paths.outputs `*-needed` keys must stay in lockstep with " - "path-filters.yml groups (plus core content-guard-needed and the " - "dedicated heavy-needed emission). " - f"Missing declarations: {sorted(expected_needed - declared_needed)}; " - f"stale declarations (no emission behind them): " - f"{sorted(declared_needed - expected_needed)}. Either way the gated " - "job's `if:` sees an empty string and silently skips (PR #952)." - ) - heavy_actual = re.sub(r"\s+", "", str(declared_outputs["heavy-needed"])) - assert heavy_actual == "${{steps.heavy.outputs.heavy-needed}}", ( - "jobs.ci-paths.outputs.heavy-needed must map steps.heavy.outputs.heavy-needed" - ) - - for key in sorted(expected_needed): - # Whitespace-insensitive: `${{steps.classify.outputs.x}}` and - # `${{ steps.classify.outputs.x }}` behave identically in Actions. - actual = re.sub(r"\s+", "", str(declared_outputs[key])) - assert actual == f"${{{{steps.classify.outputs.{key}}}}}", ( - f"jobs.ci-paths.outputs.{key} must map steps.classify.outputs.{key}" - ) - - -def test_every_referenced_ci_paths_output_is_declared() -> None: - # Direction 2 of the lockstep: any `needs.ci-paths.outputs.` - # reference anywhere in pr.yml (job `if:`, step `if:`, env, run) must - # name an output the ci-paths job actually declares. Catches a future - # job referencing a never-declared output even outside the extra-group - # `-needed` convention. - # Scanning the raw text (rather than walking parsed YAML) deliberately - # covers every usage site — job/step `if:`, env, run blocks. Trade-off: - # a comment naming a dropped output would fail this test too; that is a - # loud false-fail, preferred over a silent skip. - workflow_text = (REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8") - workflow_yaml = yaml.safe_load(workflow_text) - declared_outputs = set(workflow_yaml["jobs"]["ci-paths"]["outputs"]) - - referenced = {dot or bracket for dot, bracket in re.findall(CI_PATHS_OUTPUT_REFERENCE_RE, workflow_text)} - assert referenced, "expected pr.yml to reference ci-paths outputs" - - undeclared = referenced - declared_outputs - assert not undeclared, ( - f"pr.yml references undeclared ci-paths outputs {sorted(undeclared)}; " - "declare them in jobs.ci-paths.outputs or the referencing `if:` " - "silently evaluates against an empty string (the PR #952 incident)." - ) - - -def test_ci_paths_output_reference_regex_matches_fully_bracketed_form() -> None: - # Regression pin: the regex previously hardcoded a literal `.outputs`, so - # `needs['ci-paths']['outputs']['new-needed']` (GitHub Actions' fully - # bracketed index syntax, a common way to reference hyphenated keys) was - # invisible to Direction 2 -- an undeclared output referenced only in that - # form would silently bypass this lockstep guard. Exercised against - # synthetic text so this stays green even if pr.yml itself never happens - # to use the bracketed form. - synthetic = "if: ${{ needs['ci-paths']['outputs']['new-needed'] == 'true' }}" - matches = {dot or bracket for dot, bracket in re.findall(CI_PATHS_OUTPUT_REFERENCE_RE, synthetic)} - assert matches == {"new-needed"} - - -def test_parity_check_is_promoted_to_the_required_lane() -> None: - # parity-check was promoted from non-blocking to required (2026-07-05, - # parity-check-promotion) once both criteria held: (1) one green run on a - # real GitHub runner (PR #973), and (2) the 2e-13 geomean_ms drift did NOT - # reproduce on the runner (a local libm artifact, not a fixture error), so - # exact comparison is kept. This is the package-smoke pattern: the job - # keeps its viz path-filter, drops continue-on-error, is in - # ci-required-result.needs, and the aggregator observes PARITY_CHECK_RESULT - # with skipped-counts-as-pass. - workflow_text = (REPO_ROOT / ".github" / "workflows" / "pr.yml").read_text(encoding="utf-8") - workflow_yaml = yaml.safe_load(workflow_text) - parity_job = workflow_yaml["jobs"]["parity-check"] - aggregator = workflow_yaml["jobs"]["ci-required-result"] - needs = aggregator["needs"] - - # Promoted shape: blocking, observed, still path-filtered. - assert parity_job.get("continue-on-error") is None, "continue-on-error must be dropped on promotion" - assert "viz-needed == 'true'" in parity_job["if"], "path-filtering must stay (non-viz PRs skip)" - assert "parity-check" in needs, "aggregator must depend on parity-check to observe its result" - - # The aggregator must read PARITY_CHECK_RESULT and apply skipped-counts-as-pass. - agg_step = next(s for s in aggregator["steps"] if s.get("name") == "Aggregate required result") - assert agg_step["env"].get("PARITY_CHECK_RESULT") == "${{ needs.parity-check.result }}" - run = agg_step["run"] - assert 'PARITY_CHECK_RESULT" != "success"' in run - assert 'PARITY_CHECK_RESULT" != "skipped"' in run - - -def test_ci_required_result_fails_on_packaging_job_failure() -> None: - # Skipped-counts-as-pass only applies when the path-filtered job didn't - # run at all; a real failure on a packaging PR must still fail the - # required gate. - for failing_var in ("PACKAGE_SMOKE_RESULT", "DEPENDENCY_AUDIT_RESULT"): - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - **{failing_var: "failure"}, - ) - assert result.returncode == 1, f"{failing_var}=failure should fail ci-required-result" - - -def test_ci_required_result_passes_when_packaging_jobs_skip() -> None: - # A PR that touches no packaging paths sees both packaging jobs skipped - # and must still pass (matches the audit-sha pattern). - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - ) - assert result.returncode == 0 - - -def test_ci_required_result_fails_on_parity_check_failure() -> None: - # Promotion (parity-check-promotion): a viz-touching PR whose parity-check - # job fails must now fail the required gate (was non-blocking before). - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - PARITY_CHECK_RESULT="failure", - ) - assert result.returncode == 1 - - -def test_ci_required_result_passes_when_parity_check_skips() -> None: - # A non-viz PR skips parity-check (path-filtered) and must still pass - - # the skipped-counts-as-pass contract the promotion preserves. - result = _run_ci_required_result( - NEEDS_CODE_CI="true", - HEAVY_NEEDED="true", - SAFE_CONTENT_ONLY="false", - LINT_RESULT="success", - TEST_RESULT="success", - CORRECTNESS_RESULT="success", - PLAN_CAPTURE_RESULT="success", - MEDIUM_TEST_RESULT="success", - EXPLORER_TOKENS_RESULT="success", - PARITY_CHECK_RESULT="skipped", - ) - assert result.returncode == 0 diff --git a/tests/unit/workflows/test_public_site_visual_workflow.py b/tests/unit/workflows/test_public_site_visual_workflow.py index 9a081c8208..aab53ab51e 100644 --- a/tests/unit/workflows/test_public_site_visual_workflow.py +++ b/tests/unit/workflows/test_public_site_visual_workflow.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os +import subprocess from pathlib import Path from typing import Any @@ -31,7 +33,7 @@ def test_develop_pushes_produce_a_public_site_visual_baseline() -> None: assert upload["with"]["retention-days"] == 3 visual = workflow["jobs"]["public-site-visual-regression"] - assert visual["needs"] == "build" + assert visual["needs"] == ["visual-inputs", "build"] assert "refs/heads/develop" in visual["if"] assert "workflow_dispatch" in visual["if"] assert "base_ref == 'develop'" in visual["if"] @@ -41,26 +43,118 @@ def test_develop_pushes_produce_a_public_site_visual_baseline() -> None: ) assert "refs/heads/develop" in baseline_upload["if"] assert "workflow_dispatch" in baseline_upload["if"] - assert baseline_upload["with"]["name"] == "public-site-visual-baseline" + assert ( + baseline_upload["with"]["name"] == "public-site-visual-baseline-${{ needs.visual-inputs.outputs.source_sha }}" + ) assert baseline_upload["with"]["retention-days"] == 30 -def test_pull_requests_compare_exact_base_sha_or_run_bootstrap_capture() -> None: +def test_pull_requests_require_exact_base_comparison() -> None: visual = _workflow()["jobs"]["public-site-visual-regression"] steps = visual["steps"] - download = next(step for step in steps if step.get("name") == "Download exact base visual baseline") - run = next(step for step in steps if step.get("name") == "Capture and compare public site") + names = [step.get("name") for step in steps] + download = next( + step for step in steps if step.get("name") == "Download visual baseline for base or site-equivalent ancestor" + ) + capture = next(step for step in steps if step.get("name") == "Capture public site") + run = next(step for step in steps if step.get("name") == "Compare public site with exact base") + + assert names.index("Capture public site") < names.index( + "Download visual baseline for base or site-equivalent ancestor" + ) + assert names.index("Download visual baseline for base or site-equivalent ancestor") < names.index( + "Compare public site with exact base" + ) + assert capture["env"]["PUBLIC_SITE_VISUAL_PHASE"] == "capture" + assert "if" not in capture + assert run["env"]["PUBLIC_SITE_VISUAL_PHASE"] == "compare" + assert run["if"] == "github.event_name == 'pull_request'" + assert run["env"]["PUBLIC_SITE_VISUAL_REQUIRE_BASELINE"] == "1" + assert run["env"]["PUBLIC_SITE_VISUAL_OUTPUT"] == capture["env"]["PUBLIC_SITE_VISUAL_OUTPUT"] - assert download["env"]["PUBLIC_SITE_VISUAL_BASE_SHA"] == "${{ github.event.pull_request.base.sha }}" + assert download["env"]["PUBLIC_SITE_VISUAL_BASE_SHA"] == "${{ needs.visual-inputs.outputs.base_sha }}" + assert download["if"] == "github.event_name == 'pull_request'" + assert "PUBLIC_SITE_VISUAL_BASELINE_WAIT_SECONDS" not in download["env"] + assert "continue-on-error" not in download assert "download-public-site-visual-baseline.mjs" in download["run"] - assert "PUBLIC_SITE_VISUAL_REQUIRE_BASELINE" in run["env"] - assert "steps.baseline-mode.outputs.require" in run["env"]["PUBLIC_SITE_VISUAL_BASELINE"] + assert run["env"]["PUBLIC_SITE_VISUAL_BASELINE"] == download["env"]["PUBLIC_SITE_VISUAL_BASELINE"] + assert "merge_group" not in str(run["env"]) + assert "MERGE_GROUP" not in str(run["env"]) assert run["env"]["E2E_PAGES_SHAPED"] == "1" assert "public-site-visual" in run["env"]["PUBLIC_SITE_VISUAL_OUTPUT"] - baseline_mode = next(step for step in steps if step.get("name") == "Determine baseline mode") - assert "protected baseline exists" in baseline_mode["run"] - assert "BOOTSTRAP" in baseline_mode["env"] + assert not any(step.get("name") == "Determine baseline mode" for step in steps) + + +def test_download_accepts_site_equivalent_ancestors_and_compare_binds_the_used_sha() -> None: + workflow = _workflow() + assert workflow["jobs"]["visual-inputs"]["outputs"]["baseline_candidates"] == ( + "${{ steps.paths.outputs.baseline_candidates }}" + ) + steps = workflow["jobs"]["public-site-visual-regression"]["steps"] + download = next( + step for step in steps if step.get("name") == "Download visual baseline for base or site-equivalent ancestor" + ) + compare = next(step for step in steps if step.get("name") == "Compare public site with exact base") + assert download["id"] == "baseline" + assert download["env"]["PUBLIC_SITE_VISUAL_BASELINE_CANDIDATES"] == ( + "${{ needs.visual-inputs.outputs.baseline_candidates }}" + ) + # The compare step checks the downloaded manifest against the SHA the + # lookup actually used, which the download step publishes. + assert compare["env"]["PUBLIC_SITE_VISUAL_BASE_SHA"] == "${{ steps.baseline.outputs.baseline_sha }}" + + +def _commit(repo: Path, path: str, content: str, message: str) -> str: + target = repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + for args in ( + ["add", path], + ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", message], + ): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + return subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + +def test_baseline_candidates_stop_at_the_first_site_input_change(tmp_path: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + _commit(tmp_path, "docs/index.md", "v1\n", "root") + site_change = _commit(tmp_path, "docs/index.md", "v2\n", "site change") + quiet_one = _commit(tmp_path, "tests/a.py", "a\n", "non-site change") + base = _commit(tmp_path, "tests/b.py", "b\n", "another non-site change") + head = _commit(tmp_path, "tests/c.py", "c\n", "pull request head") + + classifier = _workflow()["jobs"]["visual-inputs"]["steps"][1]["run"] + output = tmp_path / "github-output" + env = dict(os.environ) + env.update( + EVENT_NAME="pull_request", + PR_BASE_SHA=base, + RECOVERY_SOURCE_SHA="", + CURRENT_SHA=head, + CURRENT_REF="refs/pull/1/merge", + GITHUB_OUTPUT=str(output), + ) + result = subprocess.run(["bash", "-c", classifier], cwd=tmp_path, env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + lines = dict(line.split("=", 1) for line in output.read_text().splitlines()) + # The base, then non-site ancestors, then the last commit that changed a + # site input (identical site inputs from that commit onward); nothing older. + assert lines["baseline_candidates"].split() == [base, quiet_one, site_change] + + +def test_no_workflow_publishes_a_merge_queue_candidate_baseline() -> None: + for path in sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")): + text = path.read_text(encoding="utf-8") + assert "Upload merge-queue candidate visual baseline" not in text, path.name + lookup = (REPO_ROOT / "results-explorer" / "scripts" / "public-site-visual-baseline-lookup.mjs").read_text( + encoding="utf-8" + ) + assert "merge_group" not in lookup + assert "MERGE_QUEUE" not in lookup def test_visual_baseline_script_and_capture_command_are_tracked() -> None: @@ -69,10 +163,17 @@ def test_visual_baseline_script_and_capture_command_are_tracked() -> None: script_source = script.read_text(encoding="utf-8") assert script.is_file() - assert "bootstrap=true" in script_source - assert "page=${page}" in script_source - assert "BASELINE_LOOKUP_ATTEMPTS" in script_source - assert "lookupFailed = false" in script_source + assert "bootstrap=true" not in script_source + assert "manifest.source_sha !== baselineSha" in script_source + assert "baseline_sha=${baselineSha}" in script_source + assert "/^[0-9a-f]{40}$/" in script_source + assert "waitForTrustedBaseline" in script_source + lookup_source = (REPO_ROOT / "results-explorer" / "scripts" / "public-site-visual-baseline-lookup.mjs").read_text( + encoding="utf-8" + ) + assert "page=${page}" in lookup_source + assert "actions/runs/${runId}" in lookup_source + assert "lastLookupError = undefined" in lookup_source assert "test:e2e:public-site" in package["scripts"] assert "e2e/captures/public-site-pages.spec.ts" in package["scripts"]["test:e2e:public-site"] @@ -84,3 +185,135 @@ def test_public_results_capture_waits_for_data_before_digesting() -> None: assert "Recent Results" in source assert "timeout: 240_000" in source assert "coldResultsLoad" not in source + + +def test_docs_workflow_keeps_baselines_and_ci_reports_the_comparison() -> None: + """docs.yml captures protected-develop baselines on push; ci.yml runs the comparison.""" + workflow = _workflow() + triggers = workflow.get("on", workflow.get(True)) + assert "pull_request" not in triggers + assert "merge_group" not in triggers + assert set(triggers) >= {"push", "workflow_dispatch"} + gate = workflow["jobs"]["public-site-visual-required"] + assert gate["name"] == "Public-site visual acceptance" + assert gate["if"] == "always()" + assert set(gate["needs"]) == {"visual-inputs", "build", "public-site-visual-regression"} + + ci = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + ci_triggers = ci.get("on", ci.get(True)) + assert "paths" not in ci_triggers["pull_request"] + assert "merge_group" not in ci_triggers + visual = ci["jobs"]["public-site-visual-regression"] + assert visual["name"] == "Public-site visual regression" + assert "render_changed" in visual["if"] + assert "merge_group" not in visual["if"] + + +@pytest.mark.parametrize( + ("event", "base_ref", "changed", "build", "visual", "expected"), + [ + ("pull_request", "develop", "false", "skipped", "skipped", 0), + ("pull_request", "develop", "true", "success", "success", 0), + ("pull_request", "develop", "true", "success", "failure", 1), + ("pull_request", "develop", "", "success", "success", 1), + ], +) +def test_required_gate_fails_closed_for_changed_inputs( + event: str, base_ref: str, changed: str, build: str, visual: str, expected: int +) -> None: + step = _workflow()["jobs"]["public-site-visual-required"]["steps"][0] + env = dict(os.environ) + env.update( + INPUT_RESULT="success", + SITE_CHANGED=changed, + BUILD_RESULT=build, + VISUAL_RESULT=visual, + EVENT_NAME=event, + BASE_REF=base_ref, + ) + result = subprocess.run(["bash", "-c", step["run"]], env=env, capture_output=True, text=True, check=False) + assert result.returncode == expected, result.stderr + + +def test_required_gate_rejects_failed_input_classification() -> None: + step = _workflow()["jobs"]["public-site-visual-required"]["steps"][0] + result = subprocess.run( + ["bash", "-c", step["run"]], + env={ + **os.environ, + "INPUT_RESULT": "failure", + "SITE_CHANGED": "false", + "EVENT_NAME": "pull_request", + "BASE_REF": "develop", + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1 + + +def test_classification_retains_every_prior_public_site_input() -> None: + workflow = _workflow() + classifier = workflow["jobs"]["visual-inputs"]["steps"][1]["run"] + former_inputs = ( + "benchbox/", + "docs/", + "examples/", + "landing/", + "_project/scripts/explorer_pipeline/", + "_project/scripts/explorer_publish.py", + "_project/scripts/results_explorer_snapshot_invariants.py", + "results-data/", + "results-explorer/", + "scripts/", + ".github/workflows/docs.yml", + "Makefile", + "README.md", + "pyproject.toml", + ) + assert all(path in classifier for path in former_inputs) + assert "git merge-base --is-ancestor" in classifier + assert "source_sha=$SOURCE_SHA" in classifier + + +@pytest.mark.parametrize( + ("event", "changed_path", "expected"), + [ + ("pull_request", "docs/changed.md", "true"), + ("pull_request", "results-explorer/changed.ts", "true"), + ("pull_request", "tests/changed.py", "false"), + ], +) +def test_input_classifier_uses_exact_base_diff(tmp_path: Path, event: str, changed_path: str, expected: str) -> None: + def git(*args: str) -> str: + result = subprocess.run(["git", *args], cwd=tmp_path, text=True, capture_output=True, check=True) + return result.stdout.strip() + + git("init", "-q") + (tmp_path / "README.md").write_text("original\n") + git("add", "README.md") + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "base") + base_sha = git("rev-parse", "HEAD") + changed = tmp_path / changed_path + changed.parent.mkdir(parents=True, exist_ok=True) + changed.write_text("changed\n") + git("add", changed_path) + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "change") + head_sha = git("rev-parse", "HEAD") + + classifier = _workflow()["jobs"]["visual-inputs"]["steps"][1]["run"] + output = tmp_path / "github-output" + env = dict(os.environ) + env.update( + EVENT_NAME=event, + PR_BASE_SHA=base_sha, + RECOVERY_SOURCE_SHA="", + CURRENT_SHA=head_sha, + CURRENT_REF="refs/heads/develop", + GITHUB_OUTPUT=str(output), + ) + result = subprocess.run(["bash", "-c", classifier], cwd=tmp_path, env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert f"changed={expected}" in output.read_text() + assert f"base_sha={base_sha}" in output.read_text() diff --git a/tests/unit/workflows/test_publication_lane_docs.py b/tests/unit/workflows/test_publication_lane_docs.py deleted file mode 100644 index 1fd43011f0..0000000000 --- a/tests/unit/workflows/test_publication_lane_docs.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tests for the decoupled publication-lane-docs.yml workflow contract.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -ROOT = Path(__file__).resolve().parents[3] -WORKFLOW_PATH = ROOT / ".github" / "workflows" / "publication-lane-docs.yml" - - -def _workflow() -> dict[str, Any]: - assert WORKFLOW_PATH.is_file(), f"workflow file missing: {WORKFLOW_PATH}" - return yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) - - -def test_publication_lane_docs_workflow_is_valid_yaml() -> None: - wf = _workflow() - assert wf["name"] == "Publication Lane - Docs and Prose" - assert "jobs" in wf - assert "build-docs-lane" in wf["jobs"] - - -def test_publication_lane_docs_permissions_are_least_privilege() -> None: - wf = _workflow() - perms = wf.get("permissions") - assert perms == {"contents": "read"} - # Explicitly verify no deployment or write permissions - raw_text = WORKFLOW_PATH.read_text(encoding="utf-8") - assert "pages: write" not in raw_text - assert "id-token: write" not in raw_text - assert "actions: write" not in raw_text - - -def test_publication_lane_docs_triggers_and_path_filters() -> None: - wf = _workflow() - triggers = wf[True] if True in wf else wf["on"] - - assert "push" in triggers - assert "pull_request" in triggers - assert "workflow_dispatch" in triggers - - push_branches = triggers["push"]["branches"] - pr_branches = triggers["pull_request"]["branches"] - assert "develop" in push_branches - assert "release" in push_branches - assert "develop" in pr_branches - assert "release" in pr_branches - - # Paths must cover docs/prose; shared build inputs (benchbox/pyproject/uv.lock) - # are allowed because Sphinx autodoc imports benchbox, but corpus/explorer must not contaminate - push_paths = triggers["push"]["paths"] - assert "docs/**" in push_paths - assert "landing/**" in push_paths - assert "_blog/**" in push_paths - # Shared inputs are intentionally watched (Sphinx imports benchbox) - assert "benchbox/**" in push_paths - assert "pyproject.toml" in push_paths - assert "uv.lock" in push_paths - # Must NOT contaminate corpus/explorer lanes - assert not any("results-data" in p for p in push_paths) - assert not any("results-explorer" in p for p in push_paths) - assert not any("_project/scripts/explorer_pipeline" in p for p in push_paths) - - -def test_publication_lane_docs_generates_decoupled_artifacts() -> None: - wf = _workflow() - steps = wf["jobs"]["build-docs-lane"]["steps"] - step_names = [s.get("name") for s in steps] - - assert "Build Sphinx documentation" in step_names - assert "Assemble prose site artifact" in step_names - assert "Package release API documentation artifact" in step_names - assert "Verify lane isolation" in step_names - assert "Upload prose site artifact" in step_names - assert "Upload API docs artifact" in step_names - - # Verify upload artifacts configuration - uploads = [s for s in steps if s.get("uses", "").startswith("actions/upload-artifact")] - uploaded_names = {u["with"]["name"] for u in uploads} - assert uploaded_names == {"prose_site", "api_docs"} - - -def test_publication_lane_docs_executes_verify_lane_isolation() -> None: - raw_text = WORKFLOW_PATH.read_text(encoding="utf-8") - assert "uv run python scripts/publication/verify_lane_isolation.py --lane site" in raw_text - assert "set -euo pipefail" in raw_text - assert "--changed-paths-file" in raw_text - # PR path must fail closed: no soft-fail on fetch/diff - assert 'git fetch origin "${{ github.base_ref }}"' in raw_text - assert "git fetch origin" in raw_text - fetch_line = next(line for line in raw_text.splitlines() if "git fetch origin" in line and "base_ref" in line) - assert "|| true" not in fetch_line - diff_line = next(line for line in raw_text.splitlines() if "git diff --name-only" in line and "base_ref" in line) - assert "|| true" not in diff_line - # PRs must always pass --changed-paths-file (never skip on empty via soft-fail) - assert 'github.event_name }}" = "pull_request"' in raw_text - assert '--changed-paths-file "$CHANGED_FILE"' in raw_text or '--changed-paths-file "$CHANGED_FILE"' in raw_text - - -def test_publication_lane_docs_checkout_has_full_history() -> None: - """Three-dot base...HEAD diff needs a merge base (CI 2022 lane red). - - A shallow checkout fails that diff with "no merge base", failing closed - for the wrong reason. The checkout must be full history. - """ - wf = _workflow() - steps = wf["jobs"]["build-docs-lane"]["steps"] - checkout = next(s for s in steps if s.get("uses", "").startswith("actions/checkout")) - assert str(checkout.get("with", {}).get("fetch-depth")) == "0" - - -def test_publication_lane_docs_does_not_contain_pages_deployment() -> None: - raw_text = WORKFLOW_PATH.read_text(encoding="utf-8") - assert "actions/deploy-pages" not in raw_text - assert "environment:" not in raw_text - assert "environment_url" not in raw_text - - -def test_publication_lane_docs_api_docs_not_empty() -> None: - """Finding #4: workflow must guard against empty api_docs upload.""" - raw_text = WORKFLOW_PATH.read_text(encoding="utf-8") - # Guards that fail the job when artifact is empty - assert "if [ ! -f lane_artifacts/api_docs/api.html ]" in raw_text - assert 'if [ -z "$(ls -A lane_artifacts/api_docs' in raw_text - # Upload must not silently succeed with no files - assert "if-no-files-found: error" in raw_text - # Verify lane isolation script also validates api_docs would fail with empty - wf = _workflow() - steps = wf["jobs"]["build-docs-lane"]["steps"] - pkg = next(s for s in steps if s.get("name") == "Package release API documentation artifact") - assert "exit 1" in pkg["run"] - - -def test_publication_lane_docs_job_permissions_not_escalated() -> None: - """Finding #2 (workflow surface): job must not re-escalate permissions.""" - raw_text = WORKFLOW_PATH.read_text(encoding="utf-8") - # No job-level permissions override at all; only top-level contents: read - wf = _workflow() - for job_name, job in wf["jobs"].items(): - assert "permissions" not in job, f"job {job_name} must not override permissions" - # Also assert no contents: write anywhere in the workflow file - assert "contents: write" not in raw_text diff --git a/tests/unit/workflows/test_publication_lane_explorer.py b/tests/unit/workflows/test_publication_lane_explorer.py deleted file mode 100644 index 30b8bd4375..0000000000 --- a/tests/unit/workflows/test_publication_lane_explorer.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Publication lane explorer workflow contracts. - -Pins least-privilege, concurrency, artifact provenance, and test coverage -for the independent Results Explorer publication lane. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -REPO_ROOT = Path(__file__).resolve().parents[3] -WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "publication-lane-explorer.yml" -INVENTORY = REPO_ROOT / "docs" / "operations" / "develop-push-drop-inventory.md" -CHECKER_SCRIPT = REPO_ROOT / "scripts" / "publication" / "check_explorer_compat.py" - - -def _load_workflow() -> dict: - text = WORKFLOW_PATH.read_text(encoding="utf-8") - data = yaml.safe_load(text) - assert isinstance(data, dict) - return data, text - - -def _get_on_block(workflow: dict): - # PyYAML 1.1 treats `on` as boolean True - on = workflow.get(True) or workflow.get("on") - assert isinstance(on, dict), "workflow missing `on:` block" - return on - - -def test_publication_lane_permissions_are_least_privilege() -> None: - workflow, _ = _load_workflow() - assert workflow.get("permissions") == {"contents": "read"}, ( - f"publication lane must be least-privilege contents: read, got {workflow.get('permissions')!r}" - ) - - -def test_publication_lane_no_job_level_permissions() -> None: - workflow, _ = _load_workflow() - for job_name, job in workflow.get("jobs", {}).items(): - assert "permissions" not in job, f"job {job_name!r} must not override workflow permissions" - - -def test_publication_lane_no_deploy_permissions() -> None: - _, text = _load_workflow() - # No pages, id-token, packages, deployments, or write permissions - for needle in ("pages: write", "id-token: write", "packages: write", "deployments: write", "contents: write"): - assert needle not in text, f"workflow must not contain {needle!r}" - # No deploy step - assert "deploy" not in text.lower() or "upload-artifact" in text.lower() - - -def test_publication_lane_concurrency_is_sha_keyed() -> None: - _, text = _load_workflow() - # Must be SHA-keyed for push (not ref) and PR number for pull_request - assert "github.sha" in text, "concurrency group must be SHA-keyed for push" - assert "github.event.pull_request.number" in text, "concurrency group must be PR-number-keyed for pull_request" - # Should not use github.ref as the push key (would coalesce concurrent pushes) - # The only allowed ref usage is for merge_group etc; publication lane should not use ref as fallback - workflow, _ = _load_workflow() - concurrency = workflow.get("concurrency", {}) - group = str(concurrency.get("group", "")) - assert "github.sha" in group, f"concurrency group must contain github.sha, got {group!r}" - assert "github.ref" not in group, f"concurrency group must not use github.ref (use github.sha), got {group!r}" - - -def test_publication_lane_artifact_name_includes_sha() -> None: - _, text = _load_workflow() - workflow, _ = _load_workflow() - jobs = workflow.get("jobs", {}) - found_upload = False - for job in jobs.values(): - for step in job.get("steps", []): - if "upload-artifact" in str(step.get("uses", "")): - found_upload = True - name = str(step.get("with", {}).get("name", "")) - # Must include github.sha for provenance - assert "github.sha" in name, f"artifact name must include github.sha for provenance, got {name!r}" - assert name != "explorer_app", "artifact name must not be fixed 'explorer_app' (fork PR provenance)" - assert found_upload, "workflow must have an upload-artifact step" - # Also check raw text - assert "explorer_app-${{ github.sha }}" in text or "explorer_app-${{github.sha}}" in text.replace(" ", "") - - -def test_publication_lane_runs_explorer_tests() -> None: - workflow, text = _load_workflow() - # Must run npm test (vitest) and python contract tests - assert "npm run test" in text or "npm test" in text, "workflow must run Explorer unit tests (npm run test)" - # Check that npm test step has working-directory results-explorer - found_npm_test = False - for job in workflow["jobs"].values(): - for step in job["steps"]: - run = str(step.get("run", "")) - if "npm run test" in run and "test:e2e" not in run: - # Ensure it's the unit test step, not e2e - found_npm_test = True - # Check working-directory is results-explorer (if present) - if "working-directory" in step: - assert step["working-directory"] == "results-explorer" - assert found_npm_test, "workflow must have a step running 'npm run test' for Explorer unit tests" - # Python contract tests - assert "test_duckdb_browser_contract" in text or "test_explorer_build_contract" in text, ( - "workflow must run Python contract tests" - ) - assert "test_duckdb_browser_contract.py" in text - assert "test_explorer_build_contract.py" in text - - -def test_publication_lane_has_separate_generate_and_verify_steps() -> None: - _, text = _load_workflow() - workflow, _ = _load_workflow() - jobs = workflow["jobs"] - # Find generate and verify steps - generate_count = text.count("--generate-manifest") - require_count = text.count("--require-manifest") - assert generate_count >= 1, "workflow must have a step with --generate-manifest" - assert require_count >= 1, "workflow must have a separate step with --require-manifest for verification" - # They must be in separate steps (not same run line) - for job in jobs.values(): - for step in job["steps"]: - run = str(step.get("run", "")) - assert not ("--generate-manifest" in run and "--require-manifest" in run), ( - "generate and require must be in separate steps" - ) - - -def test_publication_lane_manifest_provenance() -> None: - # Checker script must embed read_model_version and github.sha - text = CHECKER_SCRIPT.read_text(encoding="utf-8") - assert "read_model_version" in text - assert "github_sha" in text or "GITHUB_SHA" in text - assert "SUPPORTED_SCHEMA_VERSIONS" in text - # Ensure manifest generation includes provenance - assert "EXPLORER_READ_MODEL_VERSION" in text or "CURRENT_SCHEMA_VERSION" in text - - -def test_publication_lane_inventory_is_advisory() -> None: - inv = INVENTORY.read_text(encoding="utf-8") - # Find publication-lane row - assert "publication-lane-explorer.yml" in inv, "inventory must list publication-lane-explorer.yml" - # The row must not claim required-check as primary safety property - # Find the line containing the workflow name - for line in inv.splitlines(): - if "publication-lane-explorer.yml" in line: - lower = line.lower().replace("*", "").replace("`", "") - # Must contain advisory and must not claim "required-path gate" or "required check" - assert "advisory" in lower, f"inventory row must be marked advisory, got: {line!r}" - # Check that row states lane is not a required check (allow markdown bold) - assert "not" in lower and "required" in lower, ( - f"inventory row must state lane is not a required check, got: {line!r}" - ) - # Should not claim pre-merge required gate - assert "pre-merge required-path gate" not in line, ( - f"inventory must not claim pre-merge required-path gate, got: {line!r}" - ) - break - else: - pytest.fail("publication-lane-explorer row not found in inventory") - - -def test_publication_lane_checker_supports_only_v9() -> None: - text = CHECKER_SCRIPT.read_text(encoding="utf-8") - # Must import from contract or set to 9 - assert "EXPLORER_READ_MODEL_VERSION" in text or "SUPPORTED_SCHEMA_VERSIONS: tuple[int, ...] = (9,)" in text - assert "SUPPORTED_SCHEMA_VERSIONS: tuple[int, ...] = (7, 8, 9)" not in text - # Must not contain v7/v8 hand-maintained logic - assert ( - "if version == 8" not in text or "cpu_identity_provenance" not in text.split("if version == 8")[0][-500:] - ) # rough - # Check that get_table_columns_for_version only supports CURRENT_SCHEMA_VERSION - assert "if version == 7" not in text - # Ensure checker imports contract - has_import = "from _project.scripts.explorer_pipeline.contract import" in text - has_fallback = "SUPPORTED_SCHEMA_VERSIONS: tuple[int, ...] = (9,)" in text - assert has_import or has_fallback - - -def test_publication_lane_checker_manifest_fail_closed() -> None: - text = CHECKER_SCRIPT.read_text(encoding="utf-8") - # Must have require_manifest handling - assert "require_manifest" in text - assert "manifest.json is missing" in text - assert "SHA256SUMS is missing" in text - # Must reject malformed SHA256SUMS - assert "malformed SHA256SUMS" in text - # Must anchor exclude to root-relative - assert "relative_to(directory).as_posix()" in text or "as_posix()" in text - # Check that excludes is checked via rel, not p.name - assert "if rel in excludes" in text diff --git a/tests/unit/workflows/test_publication_transaction.py b/tests/unit/workflows/test_publication_transaction.py index 27b2824061..7b0b8b487c 100644 --- a/tests/unit/workflows/test_publication_transaction.py +++ b/tests/unit/workflows/test_publication_transaction.py @@ -128,6 +128,7 @@ def test_five_write_paths_inventory_and_disabled_or_journaled_invariant() -> Non "docs.yml", # Path 3 (legacy release) "publication-deploy.yml", # Path 4 (legacy recovery) "publication-preview-deploy.yml", # Path 5 (preview, disabled in code) + "site-deploy.yml", } assert workflow_names == expected_workflow_names, ( @@ -469,3 +470,31 @@ def test_transaction_workflow_reconciliation_wiring() -> None: rec_step = next((s for s in verify_steps if s.get("name") == "Record verification success in journal CAS"), None) assert rec_step is not None assert "--github-token" in rec_step.get("run", "") + + +def test_transaction_validates_candidate_against_bounded_develop_ancestry() -> None: + """Both validations accept a candidate built from the tip or a recent ancestor. + + The pre-approval check pins the resolved tip with an explicit commit + bound; the post-approval check re-resolves the tip after the approval + wait instead of reusing the permit value, so queued approvals survive + merge-queue landings without accepting unreachable or stale bundles. + """ + text = TX_WORKFLOW_PATH.read_text(encoding="utf-8") + assert text.count("--develop-max-behind-commits 50") >= 2 + + wf = _load_yaml(TX_WORKFLOW_PATH) + pre_steps = wf["jobs"]["prepare"]["steps"] + pre_step = next( + (s for s in pre_steps if s.get("name") == "Resolve and validate selected candidate before approval"), + None, + ) + assert pre_step is not None + assert '--expected-develop-sha "$EXPECTED_DEVELOP_SHA"' in pre_step.get("run", "") + + deploy_steps = wf["jobs"]["deploy"]["steps"] + mat_step = next((s for s in deploy_steps if s.get("name") == "Materialize site bytes"), None) + assert mat_step is not None + mat_run = mat_step.get("run", "") + assert "git fetch --no-tags origin develop:refs/remotes/origin/develop" in mat_run + assert '--expected-develop-sha "$CURRENT_DEVELOP_SHA"' in mat_run diff --git a/tests/unit/workflows/test_release_canary_shard_evidence.py b/tests/unit/workflows/test_release_canary_shard_evidence.py new file mode 100644 index 0000000000..7843b5a20d --- /dev/null +++ b/tests/unit/workflows/test_release_canary_shard_evidence.py @@ -0,0 +1,525 @@ +"""The shard evidence step must survive a failing pytest run. + +`release-canary.yml` declares `shell: bash` for the shard steps, which GitHub +invokes as `bash --noprofile --norc -eo pipefail`. Under errexit a bare failing +pipeline aborts the script at that line. The shard step ends in exactly that +shape -- `pytest ... | tee ` -- so a shard with failing tests used to stop +before it recorded anything: no end stamp, no JUnit fallback, no capture marker, +and no `pytest_exit_code` output. + +That is the worst case for this evidence, not the best. A red shard is precisely +when a reviewer needs to know what ran and how long it took, and the missing +capture marker made the summary report `junit_captured=false` even for a shard +whose pytest run had written a complete JUnit report with real per-test +durations. The summary then understated measured cost, which is the one failure +mode this evidence exists to prevent. + +These tests execute the real `run:` blocks under errexit, substituting only the +`uv` entrypoint so the pytest outcome is controlled. Asserting that "set +e" +appears in the YAML would pin the spelling of the fix instead of the behaviour, +and would keep passing if the step were restructured to lose the evidence a +different way. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest +import yaml + +from tests.utilities.posix_shell import posix_shell, skip_without_posix_shell + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release-canary.yml" + +# A real JUnit report is what makes junit_captured meaningful: the file exists +# and carries per-test durations. Absent it, the workflow writes a marked empty +# fallback. Both cases are exercised below. +_REAL_JUNIT = ( + '' + '' + '' +) + + +def _steps() -> dict[str, dict]: + workflow = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) + return {step.get("name"): step for step in workflow["jobs"]["credential-free-non-fast"]["steps"]} + + +def _shell_has_mapfile() -> bool: + """Whether the resolved shell provides the bash 4 `mapfile` builtin.""" + shell = posix_shell() + if shell is None: + return False + # No trailing `exit 0`: the probe must return mapfile's own status, or it + # would pass on a shell that lacks the builtin. + probe = subprocess.run( + [shell, "--noprofile", "--norc", "-c", 'mapfile -t probe <<< "a b c"'], + capture_output=True, + text=True, + ) + return probe.returncode == 0 + + +# macOS ships bash 3.2, which predates `mapfile`, while the workflow runs on +# ubuntu-latest where the builtin exists. Skipping here would leave the errexit +# regression unverified on every macOS checkout, so instead the builtin is +# emulated for the one form the shard step uses (`mapfile -t NAME < FILE`). The +# shim is defined only when the real builtin is absent, so CI still exercises +# bash's own mapfile. Substituting the one line that cannot run locally, rather +# than the behaviour under test, is the same trade the other workflow-shell +# tests in this suite make. +_MAPFILE_SHIM = r"""\ +if ! declare -F mapfile >/dev/null 2>&1; then + mapfile() { + local _name="" _line _arr=() + while [ "$#" -gt 0 ]; do + case "$1" in + -t) shift ;; + *) _name="$1"; shift; break ;; + esac + done + while IFS= read -r _line || [ -n "$_line" ]; do + _arr[${#_arr[@]}]="$_line" + done + if [ -n "$_name" ]; then + eval "$_name=(\"\${_arr[@]}\")" + fi + } +fi +""" + + +def _shard_prelude() -> str: + """Shell prelude for executing the shard step's real block.""" + return "" if _shell_has_mapfile() else _MAPFILE_SHIM + + +def _run_under_errexit(script: str, *, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess: + """Execute a workflow `run:` block with the shell options GitHub applies. + + GitHub runs `shell: bash` as `bash --noprofile --norc -eo pipefail`. The + blocks set `-o pipefail` themselves, so errexit is the missing half, and + errexit is what this suite is about. + """ + shell = posix_shell() + assert shell is not None, "a real POSIX shell is required to execute workflow blocks" + return subprocess.run( + [shell, "--noprofile", "--norc", "-e", "-c", script], + cwd=cwd, + env=env, + capture_output=True, + text=True, + ) + + +def _stub_uv(write_report: bool) -> str: + """A shell function replacing `uv`, so the real block runs verbatim. + + The stub stands in for the pytest invocation only. Running the real pytest + would make this test depend on the suite it is meant to observe. Defining a + function keeps the `run:` block unedited, so the pipeline shape, the errexit + exposure, and the PIPESTATUS handling are all still the workflow's own. + """ + report = "" + if write_report: + report = f"printf '%s' '{_REAL_JUNIT}' > \"$junit\"; " + return ( + "uv() {\n" + ' local junit=""\n' + ' for a in "$@"; do case "$a" in --junitxml=*) junit="${a#--junitxml=}";; esac; done\n' + f" {report}" + ' exit "$STUB_PYTEST_EXIT"\n' + "}\n" + ) + + +def _shard_workspace(tmp_path: Path, *, node_ids: list[str], stamps: dict[str, str] | None = None) -> Path: + """Lay out the workspace the shard steps expect.""" + workspace = tmp_path / "workspace" + artifacts = workspace / "release-canary-artifacts" + artifacts.mkdir(parents=True) + (artifacts / "shard-0-nodeids.txt").write_text("\n".join(node_ids) + ("\n" if node_ids else ""), encoding="utf-8") + (workspace / "gh_output.txt").touch() + for name, value in (stamps or {}).items(): + (workspace / name).write_text(value, encoding="utf-8") + return workspace + + +def _shard_env(workspace: Path, **extra: str) -> dict[str, str]: + env = { + "PATH": os.environ["PATH"], + "HOME": os.environ.get("HOME", "/root"), + "GITHUB_WORKSPACE": str(workspace), + "GITHUB_OUTPUT": str(workspace / "gh_output.txt"), + "SHARD_INDEX": "0", + "SHARD_COUNT": "6", + "CHECKED_SHA": "deadbeef" * 5, + "COLLECTED_COUNT": "42", + } + env.update(extra) + return env + + +def _run_shard_step(tmp_path: Path, *, pytest_exit: int, write_report: bool, node_ids: list[str]) -> tuple[int, Path]: + workspace = _shard_workspace(tmp_path, node_ids=node_ids) + script = _shard_prelude() + _stub_uv(write_report) + _steps()["Run credential-free non-fast shard"]["run"] + result = _run_under_errexit( + script, + cwd=workspace, + env=_shard_env(workspace, STUB_PYTEST_EXIT=str(pytest_exit)), + ) + assert result.returncode == pytest_exit, ( + f"the shard step must exit with pytest's status ({pytest_exit}), got {result.returncode}.\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + return result.returncode, workspace + + +def _summary_json(workspace: Path, *, pytest_exit_code: str = "") -> dict: + """Run the summary step and return the shard summary JSON it wrote.""" + script = _steps()["Write shard summary"]["run"] + result = _run_under_errexit( + script, + cwd=workspace, + env=_shard_env(workspace, PYTEST_EXIT_CODE=pytest_exit_code), + ) + assert result.returncode == 0, ( + f"the summary step must succeed regardless of the shard outcome; stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + return json.loads((workspace / "release-canary-artifacts" / "shard-0-summary.json").read_text(encoding="utf-8")) + + +def test_failing_shard_still_records_its_evidence(tmp_path: Path) -> None: + """must_preserve: a red shard must publish exit code, timing, and capture state. + + This is the errexit regression. Without `set +e` around the pytest pipeline + the step aborts at the pipe, so none of these artifacts exist and the summary + reports no durations for a shard that measured them. + """ + skip_without_posix_shell() + + _status, workspace = _run_shard_step( + tmp_path, pytest_exit=1, write_report=True, node_ids=["tests/unit/test_a.py::test_one"] + ) + + output = (workspace / "gh_output.txt").read_text(encoding="utf-8") + assert "pytest_exit_code=1" in output, "a failing shard must still publish its pytest exit code" + assert (workspace / ".canary-tests-end-mono").exists(), "a failing shard must still stamp its test end time" + assert (workspace / ".canary-junit-captured").read_text(encoding="utf-8").strip() == "true", ( + "pytest wrote a real JUnit report, so capture state must be true; a missing marker would " + "make the summary claim no per-test durations exist when they do" + ) + + +def test_failing_shard_summary_reports_captured_durations(tmp_path: Path) -> None: + """The published summary must not invert capture state for a failed shard.""" + skip_without_posix_shell() + + _status, workspace = _run_shard_step( + tmp_path, pytest_exit=1, write_report=True, node_ids=["tests/unit/test_a.py::test_one"] + ) + summary = _summary_json(workspace, pytest_exit_code="1") + + assert summary["pytest_exit_code"] == "1" + assert summary["junit_present"] is True + assert summary["junit_captured"] is True, "the report holds real durations; the summary must say so" + + +def test_missing_junit_falls_back_to_a_marked_empty_report(tmp_path: Path) -> None: + """A capture failure writes a fallback report and reports it as not captured.""" + skip_without_posix_shell() + + _status, workspace = _run_shard_step( + tmp_path, pytest_exit=1, write_report=False, node_ids=["tests/unit/test_a.py::test_one"] + ) + + assert (workspace / ".canary-junit-captured").read_text(encoding="utf-8").strip() == "false" + summary = _summary_json(workspace, pytest_exit_code="1") + # The fallback makes the file present, so presence alone is not evidence of + # durations. This pairing is the whole reason the two fields are separate. + assert summary["junit_present"] is True + assert summary["junit_captured"] is False + + +def test_shard_with_no_assigned_tests_reports_no_capture(tmp_path: Path) -> None: + """An empty shard is not a capture failure and carries no durations.""" + skip_without_posix_shell() + + status, workspace = _run_shard_step(tmp_path, pytest_exit=0, write_report=False, node_ids=[]) + + assert status == 0 + summary = _summary_json(workspace, pytest_exit_code="0") + assert summary["junit_present"] is True + assert summary["junit_captured"] is False + assert summary["assigned_count"] == "0" + + +def test_passing_shard_still_reports_its_status(tmp_path: Path) -> None: + """The errexit fix must not make a green shard red or drop its evidence.""" + skip_without_posix_shell() + + status, workspace = _run_shard_step( + tmp_path, pytest_exit=0, write_report=True, node_ids=["tests/unit/test_a.py::test_one"] + ) + + assert status == 0 + assert "pytest_exit_code=0" in (workspace / "gh_output.txt").read_text(encoding="utf-8") + summary = _summary_json(workspace, pytest_exit_code="0") + assert summary["junit_present"] is True + assert summary["junit_captured"] is True + + +def test_summary_degrades_when_the_test_step_never_ran(tmp_path: Path) -> None: + """A shard that aborted before its stamps must yield empty fields, not a failure.""" + skip_without_posix_shell() + + workspace = _shard_workspace(tmp_path, node_ids=["tests/unit/test_a.py::test_one"]) + summary = _summary_json(workspace) + + assert summary["setup_seconds"] == "" + assert summary["test_seconds"] == "" + assert summary["junit_present"] is False + assert summary["junit_captured"] is False + + +def test_durations_come_from_monotonic_stamps(tmp_path: Path) -> None: + """Float monotonic stamps must yield integer elapsed seconds. + + The stamps are seconds since boot with a fractional part, so the summary + cannot use shell integer arithmetic on them; it must subtract as floats and + publish whole seconds. + + The fractional parts are chosen so that stripping them before subtracting + gives a different answer: 100055.25 - 100000.75 is 54.50, which truncates + to 54, while dropping the fractions first gives 100055 - 100000 = 55. A + test whose stamps agree under both cannot tell the two apart, so this one + must not use them. + """ + skip_without_posix_shell() + + workspace = _shard_workspace( + tmp_path, + node_ids=["tests/unit/test_a.py::test_one"], + stamps={ + ".canary-setup-start-mono": "100000.75", + ".canary-tests-start-mono": "100055.25", + ".canary-tests-end-mono": "100355.10", + }, + ) + summary = _summary_json(workspace) + + assert summary["setup_seconds"] == "54", "54.50 elapsed truncates to 54 whole seconds" + assert summary["test_seconds"] == "299", "299.85 elapsed truncates to 299 whole seconds" + assert summary["setup_start_mono"] == "100000.75" + assert summary["tests_end_mono"] == "100355.10" + + +def test_a_partial_stamp_leaves_only_the_missing_duration_empty(tmp_path: Path) -> None: + """A stamp lost mid-job must not corrupt the duration it does not bound.""" + skip_without_posix_shell() + + workspace = _shard_workspace( + tmp_path, + node_ids=["tests/unit/test_a.py::test_one"], + stamps={ + ".canary-setup-start-mono": "100000.75", + ".canary-tests-start-mono": "100055.25", + }, + ) + summary = _summary_json(workspace) + + assert summary["setup_seconds"] == "54", "setup is bounded by two present stamps" + assert summary["test_seconds"] == "", "the end stamp is missing, so no duration may be inferred" + + +def test_a_non_true_capture_marker_degrades_to_false(tmp_path: Path) -> None: + """Garbage in the marker file must never be published as captured.""" + skip_without_posix_shell() + + workspace = _shard_workspace(tmp_path, node_ids=["tests/unit/test_a.py::test_one"]) + (workspace / ".canary-junit-captured").write_text("yes\n", encoding="utf-8") + summary = _summary_json(workspace) + + assert summary["junit_captured"] is False + + +def test_summary_json_is_valid_and_typed(tmp_path: Path) -> None: + """The summary must parse, with the two junit fields as real JSON booleans.""" + skip_without_posix_shell() + + _status, workspace = _run_shard_step( + tmp_path, pytest_exit=0, write_report=True, node_ids=["tests/unit/test_a.py::test_one"] + ) + _summary_json(workspace) + raw = (workspace / "release-canary-artifacts" / "shard-0-summary.json").read_text(encoding="utf-8") + summary = json.loads(raw) + + assert summary["shard_index"] == 0 + assert isinstance(summary["junit_present"], bool) + assert isinstance(summary["junit_captured"], bool) + assert '"setup_start_epoch"' not in raw, "wall-clock epoch naming must not return" + + +# -------------------------------------------------------------------------- +# The same blocks against a real pytest run. +# +# Everything above controls the pytest outcome by stubbing `uv`, which pins the +# workflow's control flow but says nothing about what a real pytest actually +# writes. Two acceptance criteria are about the artifact's content, not the +# shell's: that a report carries per-test durations, and that a report without +# them is never published as a capture. Those need a real pytest. +# +# The substitution here is one level thinner: `uv run --` forwards to the real +# interpreter instead of exiting with a fixed code. The pytest invocation, its +# marker expression, --junitxml, the tee pipeline, PIPESTATUS, and every +# evidence write below them are the workflow's own, unedited. +# -------------------------------------------------------------------------- + +_FIXTURE_MARKED = """\ +import time + +import pytest + + +@pytest.mark.slow +def test_marked(): + time.sleep(0.4) + assert True +""" + +_FIXTURE_UNMARKED = """\ +import pytest + + +def test_deselected_by_the_marker_expression(): + assert True +""" + + +def _real_uv_shim() -> str: + """Forward `uv run --` to the real interpreter so a real pytest runs.""" + return 'uv() {\n if [ "$1" = "run" ] && [ "$2" = "--" ]; then shift 2; fi\n "$@"\n}\n' + + +def _junit_testcases(xml_path: Path) -> list[tuple[str, str | None]]: + """Return (name, time) for every testcase in a JUnit report.""" + root = ET.parse(xml_path).getroot() + return [(node.get("name", ""), node.get("time")) for node in root.iter("testcase")] + + +def _run_real_shard(tmp_path: Path, *, fixture: str, node_id: str) -> tuple[int, Path]: + """Run the real shard block against a real pytest with one fixture test.""" + workspace = _shard_workspace(tmp_path, node_ids=[node_id]) + (workspace / "fixture.py").write_text(fixture, encoding="utf-8") + # The fixture lives outside the repository, so pytest finds no repo ini file + # and inherits no addopts. Give it the marker registration the workflow's + # marker expression needs, and keep the run hermetic. + (workspace / "pytest.ini").write_text( + "[pytest]\nmarkers =\n slow: canary fixture marker\naddopts = -p no:cacheprovider\n", + encoding="utf-8", + ) + script = _shard_prelude() + _real_uv_shim() + _steps()["Run credential-free non-fast shard"]["run"] + result = _run_under_errexit(script, cwd=workspace, env=_shard_env(workspace)) + return result.returncode, workspace + + +def test_a_real_pytest_run_publishes_real_per_test_durations(tmp_path: Path) -> None: + """The artifact must hold measured durations, not just exist.""" + skip_without_posix_shell() + pytest.importorskip("pytest", reason="the real shard block drives a real pytest") + + status, workspace = _run_real_shard(tmp_path, fixture=_FIXTURE_MARKED, node_id="fixture.py::test_marked") + assert status == 0 + + junit_xml = workspace / "release-canary-artifacts" / "shard-0-junit.xml" + assert junit_xml.is_file(), "the real pytest must write the report the step asked for" + + testcases = _junit_testcases(junit_xml) + assert [name for name, _ in testcases] == ["test_marked"] + durations = [time for _, time in testcases] + assert all(time is not None for time in durations), "every testcase must carry a duration" + # The fixture sleeps 0.4s and the attribute is written with millisecond + # rounding, so a real measurement is bounded well away from zero. This is + # what distinguishes a captured duration from an absent or stubbed one; the + # margin keeps it from flaking on a coarse or busy clock. + assert float(durations[0]) >= 0.3, f"a real duration must reflect the work done: {durations[0]}" + + summary = _summary_json(workspace, pytest_exit_code="0") + assert summary["junit_present"] is True + assert summary["junit_captured"] is True + assert summary["assigned_count"] == "1" + + +def test_a_real_pytest_that_collected_nothing_is_not_published_as_a_capture( + tmp_path: Path, +) -> None: + """A real report with no testcases carries no durations and must say so. + + pytest exits 5 when the marker expression deselects everything, and still + writes a well-formed non-empty report with zero elements. A + presence check alone would publish that as a capture, which is the exact + failure mode junit_captured exists to prevent: a consumer sizing shards + would read real timing evidence where there is none. + """ + skip_without_posix_shell() + pytest.importorskip("pytest", reason="the real shard block drives a real pytest") + + status, workspace = _run_real_shard( + tmp_path, + fixture=_FIXTURE_UNMARKED, + node_id="fixture.py::test_deselected_by_the_marker_expression", + ) + assert status == 5, "pytest exits 5 when it collected nothing to run" + + junit_xml = workspace / "release-canary-artifacts" / "shard-0-junit.xml" + assert junit_xml.stat().st_size > 0, "a real zero-test report is still a non-empty file" + assert _junit_testcases(junit_xml) == [], "and it holds no durations" + + summary = _summary_json(workspace, pytest_exit_code="5") + assert summary["pytest_exit_code"] == "5", "the shard result must still be published" + assert summary["junit_present"] is True, "the real report is genuine evidence pytest ran" + assert summary["junit_captured"] is False, "but it carries no durations to size a shard from" + + +def test_the_real_setup_stamp_step_survives_errexit_without_a_kernel_file( + tmp_path: Path, +) -> None: + """The stamp step must write its file and exit 0 whether or not /proc exists. + + This runs the real block against the real kernel. On Linux the file is + readable and the stamp is a monotonic float; where it is not, the step must + degrade to an empty stamp rather than abort, because it runs under errexit + before the shard does. Either way the summary stays writable. + """ + skip_without_posix_shell() + + workspace = _shard_workspace(tmp_path, node_ids=[]) + script = _steps()["Record setup start"]["run"] + result = _run_under_errexit(script, cwd=workspace, env=_shard_env(workspace)) + assert result.returncode == 0, f"the stamp step must not fail the job: {result.stderr}" + + stamp_file = workspace / ".canary-setup-start-mono" + assert stamp_file.is_file(), "the stamp file must exist so the summary can degrade gracefully" + stamp = stamp_file.read_text(encoding="utf-8").strip() + + if Path("/proc/uptime").is_file(): + # The runner this workflow ships on. The stamp must be a real monotonic + # reading, not a wall-clock integer and not an empty fallback. + assert stamp, "/proc/uptime is readable, so a real stamp must be recorded" + value = float(stamp) + assert value > 0.0, "seconds since boot must be positive" + assert "." in stamp, f"the stamp must keep its fractional part: {stamp}" + else: + assert stamp == "", f"with no kernel file the stamp degrades to empty, got {stamp!r}" + summary = _summary_json(workspace) + assert summary["setup_seconds"] == "", "an empty stamp must not invent a duration" diff --git a/tests/unit/workflows/test_site_deploy.py b/tests/unit/workflows/test_site_deploy.py new file mode 100644 index 0000000000..5dd5da966c --- /dev/null +++ b/tests/unit/workflows/test_site_deploy.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS_DIR = ROOT / ".github" / "workflows" +SITE_DEPLOY_PATH = WORKFLOWS_DIR / "site-deploy.yml" +DEPLOY_PAGES_SHA = "368f82528645a54fb793d4d04e342629a3f51346" +UPLOAD_PAGES_SHA = "fc324d3547104276b827a68afc52ff2a11cc49c9" + + +def _workflow() -> dict[str, Any]: + return yaml.safe_load(SITE_DEPLOY_PATH.read_text(encoding="utf-8")) + + +def _jobs() -> dict[str, Any]: + return _workflow()["jobs"] + + +def _steps(job: str) -> list[dict[str, Any]]: + return _jobs()[job]["steps"] + + +def test_workflow_is_dispatch_only_with_the_decided_inputs() -> None: + triggers = _workflow().get("on") or _workflow().get(True) + assert set(triggers) == {"workflow_dispatch"} + inputs = triggers["workflow_dispatch"]["inputs"] + assert inputs["mode"]["type"] == "choice" + assert inputs["mode"]["options"] == ["preview", "deploy", "rollback"] + assert inputs["rollback_receipt_run_id"]["required"] is False + assert inputs["rollback_phase"]["options"] == ["full", "ui-first"] + assert inputs["bootstrap"]["default"] is False + assert inputs["current_generation_unknown"]["type"] == "boolean" + assert inputs["current_generation_unknown"]["default"] is False + + +def test_production_modes_share_the_legacy_group_and_preview_holds_a_private_one() -> None: + concurrency = _workflow()["concurrency"] + assert concurrency["cancel-in-progress"] is False + group = concurrency["group"] + assert group == "${{ inputs.mode == 'preview' && format('site-preview-{0}', github.run_id) || 'pages-deploy' }}" + assert "'pages-deploy'" in group + for name, job in _jobs().items(): + assert "concurrency" not in job, f"{name} must not open a second lock inside the workflow lock" + + +def test_artifact_names_are_unique_per_run_attempt_and_consumers_read_job_outputs() -> None: + text = SITE_DEPLOY_PATH.read_text(encoding="utf-8") + names = re.findall(r"^\s+name: (site-deploy-[^\n]+)$", text, flags=re.MULTILINE) + assert names + for name in names: + assert "${{ github.run_attempt }}" in name, name + outputs = _jobs()["build"]["outputs"] + assert set(outputs) == {"build_artifact", "site_artifact", "legacy_artifact"} + for step in _steps("probe") + _steps("preview") + _steps("deploy"): + downloaded = str(step.get("with", {}).get("name", "")) + if "download-artifact" in step.get("uses", ""): + assert downloaded.startswith("${{ needs."), downloaded + + +def test_receipt_is_uploaded_before_the_deployment_status_is_posted() -> None: + steps = _steps("probe") + names = [step.get("name", "") for step in steps] + finalize = names.index("Probe production and write the receipt") + upload = names.index("Upload the receipt") + record = names.index("Record the receipt on the deployment") + assert finalize < upload < record + assert "scripts.site_deploy finalize" in steps[finalize]["run"] + assert steps[upload]["id"] == "receipt_upload" + assert steps[upload]["with"]["if-no-files-found"] == "error" + assert "steps.receipt_upload.outcome == 'success'" in steps[record]["if"] + assert "steps.finalize.outcome == 'success'" in steps[record]["if"] + assert "scripts.site_deploy record" in steps[record]["run"] + assert "record" not in steps[finalize]["run"] + + +def test_rollback_downloads_go_through_the_digest_checked_fetch_command() -> None: + restore = next(step for step in _steps("build") if step.get("name") == "Restore the retained generation") + assert "gh run download" not in restore["run"] + assert "scripts.site_deploy fetch-run" in restore["run"] + assert "--receipt-sha256" in restore["run"] + + +def test_github_token_is_scoped_to_steps_that_call_the_api() -> None: + build = _jobs()["build"] + assert "GH_TOKEN" not in build["env"] + holders = [step.get("name") for step in build["steps"] if "GH_TOKEN" in step.get("env", {})] + assert holders == ["Restore the retained generation"] + for name, job in _jobs().items(): + assert "GH_TOKEN" not in job.get("env", {}), name + + +def test_pinned_actions_carry_no_trailing_version_comments() -> None: + text = SITE_DEPLOY_PATH.read_text(encoding="utf-8") + assert not re.search(r"uses:\s+\S+@[0-9a-f]{40}\s+#", text) + + +def test_exactly_one_job_carries_the_github_pages_environment() -> None: + with_environment = {name: job["environment"] for name, job in _jobs().items() if "environment" in job} + assert list(with_environment) == ["deploy"] + assert with_environment["deploy"]["name"] == "github-pages" + + +def test_probe_and_receipt_job_has_no_environment_and_can_write_deployment_statuses() -> None: + probe = _jobs()["probe"] + assert "environment" not in probe + assert probe["permissions"] == {"contents": "read", "actions": "read", "deployments": "write"} + assert probe["needs"] == ["resolve", "build", "deploy"] + commands = " ".join(str(step.get("run", "")) for step in probe["steps"]) + assert "scripts.site_deploy finalize" in commands + receipt_upload = next( + step for step in probe["steps"] if step.get("with", {}).get("name", "").startswith("site-deploy-receipt") + ) + assert receipt_upload["with"]["retention-days"] == 90 + + +def test_permissions_are_least_privilege_per_job() -> None: + workflow = _workflow() + jobs = _jobs() + assert workflow["permissions"] == {"contents": "read"} + assert jobs["resolve"]["permissions"] == {"contents": "read", "actions": "read", "deployments": "read"} + assert jobs["build"]["permissions"] == {"contents": "read", "actions": "read"} + assert jobs["preview"]["permissions"] == {"contents": "read", "actions": "read"} + assert jobs["deploy"]["permissions"] == { + "contents": "read", + "actions": "read", + "deployments": "read", + "pages": "write", + "id-token": "write", + } + writers = {name for name, job in jobs.items() if any(value == "write" for value in job["permissions"].values())} + assert writers == {"deploy", "probe"} + assert jobs["deploy"]["permissions"].get("deployments") != "write" + assert "pages" not in jobs["probe"]["permissions"] + + +def test_preview_job_has_no_environment_and_no_write_scope() -> None: + preview = _jobs()["preview"] + assert "environment" not in preview + assert all(value == "read" for value in preview["permissions"].values()) + assert preview["if"] == "inputs.mode == 'preview'" + run = " ".join(str(step.get("run", "")) for step in preview["steps"]) + assert "scripts.site_deploy preview" in run + + +def test_deploy_job_is_never_reached_by_preview_and_rechecks_the_generation_first() -> None: + deploy = _jobs()["deploy"] + assert "inputs.mode != 'preview'" in deploy["if"] + names = [step.get("name") or step.get("uses", "") for step in deploy["steps"]] + recheck = next(index for index, name in enumerate(names) if name == "Recheck the deployed generation") + publish = next(index for index, step in enumerate(deploy["steps"]) if "deploy-pages" in step.get("uses", "")) + assert recheck < publish + assert "scripts.site_deploy recheck" in deploy["steps"][recheck]["run"] + assert deploy["steps"][publish]["uses"].split("@")[1].split()[0] == DEPLOY_PAGES_SHA + + +def test_pages_artifact_is_uploaded_only_outside_preview_with_the_shared_pin() -> None: + upload = next(step for step in _steps("build") if "upload-pages-artifact" in step.get("uses", "")) + assert upload["uses"].split("@")[1].split()[0] == UPLOAD_PAGES_SHA + assert upload["if"] == "inputs.mode != 'preview'" + assert upload["with"]["include-hidden-files"] is True + + +def test_every_run_retains_the_exact_artifact_for_rollback() -> None: + retained = next( + step + for step in _steps("build") + if str(step.get("with", {}).get("name", "")).startswith("site-deploy-artifact-") + ) + assert retained["with"]["retention-days"] == 90 + assert retained["with"]["include-hidden-files"] is True + assert "if" not in retained + + +def test_all_actions_are_pinned_by_full_commit_sha() -> None: + text = SITE_DEPLOY_PATH.read_text(encoding="utf-8") + refs = re.findall(r"uses:\s+([^\s#]+)", text) + assert refs + for ref in refs: + name, _, sha = ref.partition("@") + assert re.fullmatch(r"[0-9a-f]{40}", sha), f"{name} must be pinned by a 40-character commit SHA" + + +def test_job_graph_orders_resolve_build_deploy_probe() -> None: + jobs = _jobs() + assert "needs" not in jobs["resolve"] + assert jobs["build"]["needs"] == "resolve" + assert jobs["deploy"]["needs"] == ["resolve", "build"] + assert jobs["preview"]["needs"] == ["resolve", "build"] + + +def test_production_modes_are_restricted_to_develop() -> None: + guard = _steps("resolve")[0] + assert guard["if"] == "inputs.mode != 'preview' && github.ref != 'refs/heads/develop'" + + +def test_no_user_input_is_interpolated_into_shell_commands() -> None: + for job_name, job in _jobs().items(): + for step in job["steps"]: + run = str(step.get("run", "")) + assert "${{ inputs." not in run, f"{job_name}: inputs must reach shell through env" + + +def test_gates_run_before_any_artifact_is_marked_for_deployment() -> None: + steps = _steps("build") + names = [step.get("name", step.get("uses", "")) for step in steps] + gates = names.index("Run gates") + pages = next(index for index, step in enumerate(steps) if "upload-pages-artifact" in step.get("uses", "")) + assert gates < pages + assert "scripts.site_deploy gates" in steps[gates]["run"] + + +def test_workflow_is_the_only_new_pages_writer_and_legacy_writers_keep_the_group() -> None: + writers = {path.name for path in WORKFLOWS_DIR.glob("*.yml") if "deploy-pages" in path.read_text(encoding="utf-8")} + assert "site-deploy.yml" in writers + for name in ("docs.yml", "publication-deploy.yml", "publication-transaction.yml"): + assert "group: pages-deploy" in (WORKFLOWS_DIR / name).read_text(encoding="utf-8") + + +def test_build_refuses_a_candidate_whose_control_plane_differs_from_the_dispatch() -> None: + steps = _steps("build") + names = [step.get("name", "") for step in steps] + guard = steps[names.index("Refuse a control plane that differs from the dispatched revision")] + assert guard["env"]["DISPATCH_SHA"] == "${{ github.sha }}" + for path in ("deploy", "scripts/site_deploy", "scripts/publication", "scripts/assemble_public_site.py"): + assert path in guard["run"] + assert names.index("Refuse a control plane that differs from the dispatched revision") < names.index("Run gates") diff --git a/tests/unit/workflows/test_stacked_pr_base_guard.py b/tests/unit/workflows/test_stacked_pr_base_guard.py index a0252c8abe..7503bbb038 100644 --- a/tests/unit/workflows/test_stacked_pr_base_guard.py +++ b/tests/unit/workflows/test_stacked_pr_base_guard.py @@ -1,18 +1,18 @@ -"""A PR based on a feature branch must not present an empty check list. - -Every other PR workflow filters on `branches: [develop]` (or release / -published-results), so a PR whose base is another feature branch triggers -nothing at all. The PR page looks calm rather than alarming - no failing -checks, because no checks - and the content reaches develop only when the -parent merges, never having been validated on its own. - -`pr-base-guard.yml` is the one check such a PR does get. These tests pin the -two properties that make it work: it carries no branch filter (so it always -reports), and it rejects a base that is not an integration branch. +"""A PR based on a feature branch must fail loudly inside the single CI workflow. + +The flip retires ``pr-base-guard.yml`` as a standalone workflow and folds the +same policy into ``ci.yml`` as the ``base-guard`` job, because ``ci.yml`` +deliberately carries no branch filter: a stacked PR gets the ordinary six +unit checks instead of an empty check list. Without the folded guard its +content could slide into develop under a parent PR, never validated as its +own integration-base diff. These tests pin the two properties that make the +folded guard work: it runs on every PR whatever its base, and it rejects a +base that is not an integration branch. """ from __future__ import annotations +import subprocess from pathlib import Path from typing import Any @@ -23,7 +23,9 @@ REPO_ROOT = Path(__file__).resolve().parents[3] WORKFLOWS = REPO_ROOT / ".github" / "workflows" -GUARD = WORKFLOWS / "pr-base-guard.yml" +WORKFLOW = WORKFLOWS / "ci.yml" +JOB = "base-guard" +STEP_NAME = "Check base branch" INTEGRATION_BRANCHES = {"develop", "release", "published-results"} @@ -40,12 +42,18 @@ def _triggers(workflow: dict[str, Any]) -> dict[str, Any]: return triggers -def _load(path: Path) -> dict[str, Any]: - return yaml.safe_load(path.read_text(encoding="utf-8")) +def _load() -> dict[str, Any]: + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def _job() -> dict[str, Any]: + jobs = _load()["jobs"] + assert JOB in jobs, f"ci.yml has no {JOB!r} job; a stacked PR would present no base-policy failure" + return jobs[JOB] -def test_stacked_pr_base_guard_exists() -> None: - assert GUARD.is_file(), "pr-base-guard.yml is missing; a stacked PR would present an empty check list" +def test_stacked_pr_base_guard_lives_in_ci_workflow() -> None: + _job() def test_stacked_pr_base_guard_has_no_branch_filter() -> None: @@ -55,11 +63,11 @@ def test_stacked_pr_base_guard_has_no_branch_filter() -> None: match it, and the list would go stale as branches are added. Absence cannot go stale. """ - pull_request = _triggers(_load(GUARD))["pull_request"] or {} + pull_request = _triggers(_load())["pull_request"] or {} assert "branches" not in pull_request, ( - "pr-base-guard filters on branches, so the stacked PRs it exists to catch would not trigger it" + "ci.yml filters on branches, so the stacked PRs base-guard exists to catch would not trigger it" ) - assert "paths" not in pull_request, "pr-base-guard is path-filtered, so a stacked PR could still report nothing" + assert "paths" not in pull_request, "ci.yml is path-filtered, so a stacked PR could still report nothing" def test_stacked_pr_base_guard_reevaluates_when_a_pr_is_retargeted() -> None: @@ -68,14 +76,28 @@ def test_stacked_pr_base_guard_reevaluates_when_a_pr_is_retargeted() -> None: Without `edited`, a PR opened against develop and later repointed at a feature branch keeps its stale green result. """ - types = (_triggers(_load(GUARD))["pull_request"] or {}).get("types", []) + types = (_triggers(_load())["pull_request"] or {}).get("types", []) assert "edited" in types, "guard does not re-evaluate on retarget; a repointed PR keeps a stale pass" assert "opened" in types -def test_stacked_pr_base_guard_accepts_only_integration_branches() -> None: - """The guard's accept list must be exactly the branches CI actually covers.""" - body = GUARD.read_text(encoding="utf-8") +def test_stacked_pr_base_guard_reevaluates_when_draft_status_changes() -> None: + types = (_triggers(_load())["pull_request"] or {}).get("types", []) + assert "ready_for_review" in types, "guard does not re-evaluate when a draft becomes ready" + assert "converted_to_draft" in types, "guard does not re-evaluate when a ready PR becomes a draft" + assert sorted(types) == sorted( + ["opened", "synchronize", "reopened", "edited", "ready_for_review", "converted_to_draft"] + ), "ci.yml pull_request types changed; every listed event re-runs the guard and the CI lanes" + + +def test_stacked_pr_base_guard_names_integration_branches_and_can_fail() -> None: + job = _job() + assert job.get("if") == "${{ github.event_name == 'pull_request' }}", ( + "base-guard must run on every PR event so a stacked base always reports" + ) + step = next((s for s in job.get("steps", []) if s.get("name") == STEP_NAME), None) + assert step is not None, f"base-guard has no {STEP_NAME!r} step" + body = str(step.get("run", "")) accepted = {branch for branch in INTEGRATION_BRANCHES if branch in body} assert accepted == INTEGRATION_BRANCHES, ( f"guard does not name every integration branch: missing {INTEGRATION_BRANCHES - accepted}" @@ -83,30 +105,73 @@ def test_stacked_pr_base_guard_accepts_only_integration_branches() -> None: assert "exit 1" in body, "guard never fails, so a stacked PR would still show an all-green check list" -def test_every_other_pr_workflow_still_filters_to_integration_branches() -> None: - """Pin the premise this guard exists for. +def test_stacked_pr_base_guard_feeds_the_tooling_result() -> None: + """A failing guard must fail the tooling unit, not just its own job. - If the other workflows ever stop filtering, stacked PRs would get real CI - and this guard becomes redundant rather than load-bearing - which is worth - noticing deliberately rather than discovering later. + The merge ruleset requires the unit context, not the raw job, so a guard + outside the tooling aggregation would be advisory noise a stacked PR + could merge past. """ - filtered: list[str] = [] - for path in sorted(WORKFLOWS.glob("*.yml")): - if path.name == GUARD.name: - continue - workflow = _load(path) - if not isinstance(workflow, dict): - continue - triggers = workflow.get("on", workflow.get(True)) - if not isinstance(triggers, dict): - continue - pull_request = triggers.get("pull_request") - if pull_request is None: - continue - branches = (pull_request or {}).get("branches") - if branches: - filtered.append(path.name) - assert set(branches) <= INTEGRATION_BRANCHES, ( - f"{path.name} filters on {branches}, which is outside the known integration branches" - ) - assert filtered, "no PR workflow filters on an integration branch; this guard's premise no longer holds" + workflow = _load() + tooling = workflow["jobs"]["tooling"] + assert JOB in tooling["needs"], ( + f"tooling result does not aggregate {JOB}; a stacked-PR failure would not gate merge" + ) + run_text = "\n".join(str(step.get("run", "")) for step in tooling.get("steps", [])) + assert "base-guard" in run_text, ( + "tooling result never evaluates base-guard; a stacked-PR failure would not gate merge" + ) + + +def _run_guard(tmp_path: Path, base_ref: str, *, draft: bool) -> subprocess.CompletedProcess[str]: + step = next(s for s in _job()["steps"] if s.get("name") == STEP_NAME) + env = step.get("env", {}) + assert env.get("BASE_REF") == "${{ github.base_ref }}" + assert env.get("IS_DRAFT") == "${{ github.event.pull_request.draft }}" + script = tmp_path / "base-guard.sh" + script.write_text(step["run"], encoding="utf-8") + return subprocess.run( + ["bash", str(script)], + env={"PATH": "/usr/bin:/bin", "BASE_REF": base_ref, "IS_DRAFT": "true" if draft else "false"}, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.mark.parametrize("draft", [True, False]) +@pytest.mark.parametrize("base", sorted(INTEGRATION_BRANCHES)) +def test_stacked_pr_base_guard_passes_integration_bases(tmp_path: Path, base: str, draft: bool) -> None: + assert _run_guard(tmp_path, base, draft=draft).returncode == 0 + + +def test_stacked_pr_base_guard_passes_a_draft_on_a_feature_base(tmp_path: Path) -> None: + result = _run_guard(tmp_path, "fix/parent-branch", draft=True) + assert result.returncode == 0, result.stdout + result.stderr + assert "::error::" not in result.stdout + + +def test_stacked_pr_base_guard_fails_a_ready_pr_on_a_feature_base(tmp_path: Path) -> None: + result = _run_guard(tmp_path, "fix/parent-branch", draft=False) + assert result.returncode != 0 + assert "::error::" in result.stdout + assert "fix/parent-branch" in result.stdout + assert "retarget this PR at develop" in result.stdout + + +def test_stacking_rule_is_stated_consistently_in_agent_and_policy_docs() -> None: + policy = (REPO_ROOT / "docs" / "development" / "pr-base-branch-policy.md").read_text(encoding="utf-8") + agents = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8") + index = (REPO_ROOT / "docs" / "development" / "index.md").read_text(encoding="utf-8") + assert "unsupported" not in index.lower() + assert "do not support" not in policy.lower() + assert "never open a pr with `--base`" not in policy.lower() + for text in (policy, agents): + assert "git rebase --onto origin/develop " in text + assert "converted_to_draft" in policy + assert "gh pr ready" in policy + assert "git rebase --onto " in policy + assert "mark ready" in agents + allowed = policy.split("## Allowed bases")[1].split("\n## ")[0] + assert "draft PR in a stack" in allowed + assert "connector review" in policy diff --git a/tests/unit/workflows/test_sweep_coverage.py b/tests/unit/workflows/test_sweep_coverage.py deleted file mode 100644 index 1da5c532bb..0000000000 --- a/tests/unit/workflows/test_sweep_coverage.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Tests for the hourly sweep early exit on covered tips. - -Covers the lookup decision table in scripts/sweep_coverage.py and the -workflow wiring in develop-post-merge.yml: a tip with a success/failure -push run (or a queue certification under ci-dedupe-01's exact rule) lets -the scheduled sweep exit early with a step-summary note, while a -cancelled/missing/running push run, an uncovered tip, or any lookup -error runs the full slim gates. Push and dispatch never exit early, and -mutation jobs stay schedule-excluded exactly as before. -""" - -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path - -import pytest -import yaml - -pytestmark = [pytest.mark.unit, pytest.mark.fast] - -REPO_ROOT = Path(__file__).resolve().parents[3] -SCRIPTS = REPO_ROOT / "scripts" -if str(SCRIPTS) not in sys.path: - sys.path.insert(0, str(SCRIPTS)) - -POST_MERGE = REPO_ROOT / ".github" / "workflows" / "develop-post-merge.yml" -GAP_DETECTOR = REPO_ROOT / ".github" / "workflows" / "develop-post-merge-gap-detector.yml" - -_SWEEP_SPEC = importlib.util.spec_from_file_location("sweep_coverage", SCRIPTS / "sweep_coverage.py") -assert _SWEEP_SPEC is not None and _SWEEP_SPEC.loader is not None -sweep = importlib.util.module_from_spec(_SWEEP_SPEC) -sys.modules[_SWEEP_SPEC.name] = sweep -_SWEEP_SPEC.loader.exec_module(sweep) - -SHA = "c" * 40 -REPO = "BenchBox-dev/BenchBox" -TOKEN = "test-token" -COVERED_GUARD = "needs.sweep-coverage.outputs.covered != 'true'" - - -class _FakeResponse: - def __init__(self, payload: object) -> None: - self._payload = payload - - def read(self) -> bytes: - return json.dumps(self._payload).encode("utf-8") - - def __enter__(self) -> _FakeResponse: - return self - - def __exit__(self, *args: object) -> bool: - return False - - -def _fake_urlopen(routes: dict[str, object], failures: set[str] | None = None): - failures = failures or set() - - def _open(request: object, timeout: object = None) -> _FakeResponse: - url = request.full_url if hasattr(request, "full_url") else str(request) # type: ignore[union-attr] - for prefix, payload in routes.items(): - if url.startswith(prefix): - if prefix in failures: - raise OSError(f"boom for {prefix}") - return _FakeResponse(payload) - raise AssertionError(f"unexpected API call: {url}") - - return _open - - -def _run_record( - run_id: int, - *, - event: str = "push", - conclusion: str | None = "success", - head_sha: str = SHA, - path: str = ".github/workflows/develop-post-merge.yml", -) -> dict[str, object]: - return { - "id": run_id, - "event": event, - "conclusion": conclusion, - "head_sha": head_sha, - "path": path, - "created_at": "2026-09-17T12:00:00Z", - } - - -def _merge_group_run(run_id: int) -> dict[str, object]: - return { - "id": run_id, - "event": "merge_group", - "conclusion": "success", - "head_sha": SHA, - "path": ".github/workflows/pr.yml", - "created_at": "2026-09-17T11:00:00Z", - } - - -def _routes( - push_runs: list[dict[str, object]], - *, - certify_runs: list[dict[str, object]] | None = None, - certify_jobs: list[dict[str, object]] | None = None, -) -> dict[str, object]: - routes: dict[str, object] = { - f"https://api.github.com/repos/{REPO}/actions/workflows/.github%2Fworkflows%2Fdevelop-post-merge.yml/runs": { - "workflow_runs": push_runs - }, - f"https://api.github.com/repos/{REPO}/actions/workflows/.github%2Fworkflows%2Fpr.yml/runs": { - "workflow_runs": certify_runs or [] - }, - } - if certify_jobs is not None: - routes[f"https://api.github.com/repos/{REPO}/actions/runs/555/jobs"] = {"jobs": certify_jobs} - return routes - - -def _required_job_ok() -> list[dict[str, object]]: - return [{"name": "ci-required-result", "conclusion": "success"}] - - -def _cover(*args: object, **kwargs: object) -> dict[str, object]: - """Call the lookup as a schedule run unless the test says otherwise.""" - kwargs.setdefault("event", "schedule") - return sweep.find_coverage(*args, **kwargs) # type: ignore[arg-type] - - -# --------------------------------------------------------------------------- -# lookup decision table -# --------------------------------------------------------------------------- - - -def test_successful_push_run_covers_tip() -> None: - result = _cover(SHA, REPO, TOKEN, _fake_urlopen(_routes([_run_record(101)]))) - assert result == { - "covered": True, - "covering_run_id": 101, - "reason": "push run 101 of develop-post-merge.yml concluded success on this exact SHA", - } - - -def test_failed_push_run_does_not_cover_tip() -> None: - result = _cover(SHA, REPO, TOKEN, _fake_urlopen(_routes([_run_record(102, conclusion="failure")]))) - assert result["covered"] is False - assert result["covering_run_id"] is None - - -def test_cancelled_push_run_is_not_coverage() -> None: - routes = _routes([_run_record(103, conclusion="cancelled"), _run_record(104, conclusion="startup_failure")]) - result = _cover(SHA, REPO, TOKEN, _fake_urlopen(routes)) - assert result["covered"] is False - assert result["covering_run_id"] is None - - -def test_running_push_run_is_not_coverage() -> None: - routes = _routes([_run_record(105, conclusion=None)]) - result = _cover(SHA, REPO, TOKEN, _fake_urlopen(routes)) - assert result["covered"] is False - - -def test_dropped_push_runs_full_gates() -> None: - # Simulated dropped push: no push run exists for the tip and the tip is - # not queue-certified either, so the sweep must run the full gates. - routes = _routes([], certify_runs=[]) - result = _cover(SHA, REPO, TOKEN, _fake_urlopen(routes)) - assert result["covered"] is False - assert result["covering_run_id"] is None - - -def test_queue_certified_tip_covers_without_push_run() -> None: - routes = _routes([], certify_runs=[_merge_group_run(555)], certify_jobs=_required_job_ok()) - result = _cover(SHA, REPO, TOKEN, _fake_urlopen(routes)) - assert result["covered"] is True - assert result["covering_run_id"] == 555 - - -def test_lookup_error_fails_open() -> None: - routes = _routes([_run_record(101)]) - prefix = f"https://api.github.com/repos/{REPO}/actions/workflows/.github%2Fworkflows%2Fdevelop-post-merge.yml/runs" - result = _cover(SHA, REPO, TOKEN, _fake_urlopen(routes, failures={prefix})) - assert result["covered"] is False - assert "lookup failed open" in str(result["reason"]) - - -def test_non_schedule_event_never_exits_early() -> None: - def _no_calls(request: object, timeout: object = None) -> _FakeResponse: - raise AssertionError("no API call expected for a non-schedule event") - - for event in ("push", "workflow_dispatch"): - result = sweep.find_coverage(SHA, REPO, TOKEN, _no_calls, event=event) - assert result == { - "covered": False, - "covering_run_id": None, - "reason": f"event {event!r} never exits early; running the full gates", - } - - -def test_missing_inputs_fail_open() -> None: - result = _cover("", REPO, TOKEN, _fake_urlopen({})) - assert result["covered"] is False - - -def test_main_exits_zero_and_reports_covering_run(tmp_path: Path) -> None: - output = tmp_path / "github_output.txt" - summary = tmp_path / "summary.md" - routes = _routes([_run_record(101)]) - with pytest.MonkeyPatch.context() as patch: - patch.setattr(sweep.urllib.request, "urlopen", _fake_urlopen(routes)) - code = sweep.main( - [ - "--sha", - SHA, - "--repo", - REPO, - "--token", - TOKEN, - "--event", - "schedule", - "--github-output", - str(output), - "--summary", - str(summary), - ] - ) - assert code == 0 - written = output.read_text(encoding="utf-8") - assert "covered=true" in written - assert "covering_run_id=101" in written - note = summary.read_text(encoding="utf-8") - assert "101" in note - assert "early exit" in note - - -def test_main_exits_zero_on_lookup_error(tmp_path: Path) -> None: - output = tmp_path / "github_output.txt" - routes = _routes([_run_record(101)]) - prefix = f"https://api.github.com/repos/{REPO}/actions/workflows/.github%2Fworkflows%2Fdevelop-post-merge.yml/runs" - with pytest.MonkeyPatch.context() as patch: - patch.setattr(sweep.urllib.request, "urlopen", _fake_urlopen(routes, failures={prefix})) - code = sweep.main( - ["--sha", SHA, "--repo", REPO, "--token", TOKEN, "--event", "schedule", "--github-output", str(output)] - ) - assert code == 0 - assert "covered=false" in output.read_text(encoding="utf-8") - - -# --------------------------------------------------------------------------- -# develop-post-merge.yml wiring -# --------------------------------------------------------------------------- - - -def _post_merge() -> dict[str, object]: - return yaml.safe_load(POST_MERGE.read_text(encoding="utf-8")) # type: ignore[no-any-return] - - -def test_sweep_coverage_job_always_runs_with_actions_read() -> None: - # The job must not be event-gated: downstream jobs read its outputs on - # every event, and references to a skipped job's outputs do not - # evaluate reliably. The script returns covered=false without any API - # call for non-schedule events (tested at the script level). - job = _post_merge()["jobs"]["sweep-coverage"] - assert job.get("if") is None - assert job["permissions"]["actions"] == "read" - assert job["outputs"]["covered"] == "${{ steps.coverage.outputs.covered }}" - assert job["outputs"]["covering_run_id"] == "${{ steps.coverage.outputs.covering_run_id }}" - lookup = next(step for step in job["steps"] if step.get("id") == "coverage") - assert "scripts/sweep_coverage.py" in lookup["run"] - assert lookup["env"]["GH_TOKEN"] == "${{ github.token }}" - - -def test_slim_gates_skip_only_on_covered_schedule() -> None: - jobs = _post_merge()["jobs"] - for name in ("lint", "fast-test", "explorer-tokens"): - job = jobs[name] - assert "sweep-coverage" in job["needs"] - condition = str(job.get("if") or "") - assert "github.event_name != 'schedule'" in condition - assert COVERED_GUARD in condition - - -def test_push_and_dispatch_never_exit_early() -> None: - # Push runs the gates regardless of coverage; dispatch stays the - # full-run escape hatch. Only a schedule run on a covered tip skips. - jobs = _post_merge()["jobs"] - for name in ("lint", "fast-test", "explorer-tokens"): - condition = str(jobs[name].get("if") or "") - assert "github.event_name != 'schedule'" in condition - if name == "fast-test": - assert "github.event_name != 'push'" in condition - - -def test_medium_test_and_mutations_keep_schedule_exclusion() -> None: - jobs = _post_merge()["jobs"] - assert "github.event_name != 'schedule'" in str(jobs["medium-test"].get("if") or "") - for name in ("close-orphaned-prs", "auto-revert-on-failure", "green-run-cleanup"): - assert "github.event_name != 'schedule'" in str(jobs[name].get("if") or "") - - -def test_hourly_cadence_unchanged() -> None: - on = _post_merge()[True] - crons = [entry["cron"] for entry in on["schedule"]] - assert crons, "expected the hourly sweep cadence to stay" - - -def test_gap_detector_untouched_by_sweep_change() -> None: - text = GAP_DETECTOR.read_text(encoding="utf-8") - assert "sweep-coverage" not in text - assert "sweep_coverage" not in text diff --git a/tests/unit/workflows/test_t2_partition_workflow.py b/tests/unit/workflows/test_t2_partition_workflow.py new file mode 100644 index 0000000000..c4edc377ab --- /dev/null +++ b/tests/unit/workflows/test_t2_partition_workflow.py @@ -0,0 +1,168 @@ +"""Pin full tier selection, gate conservation, and raw binary framing placement.""" + +from __future__ import annotations + +from collections import Counter +from math import prod +from pathlib import Path + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.fast] +ROOT = Path(__file__).resolve().parents[3] + + +def _jobs(filename: str) -> dict: + return yaml.safe_load((ROOT / ".github/workflows" / filename).read_text())["jobs"] + + +def test_correctness_partitions_preserve_each_gate_once() -> None: + from benchbox.core.equivalence.cross_surface import GATES + + job = _jobs("ci.yml")["correctness-gate"] + assert job["strategy"] == {"fail-fast": False, "matrix": {"partition": ["a", "b"]}} + expected = { + "make test-correctness-gate", + "make tpchavoc-equivalence-report", + "make tpchavoc-dataframe-equivalence-report", + } + for gate in GATES: + target = gate.replace("_", "-") + if gate == "joinorder": + target = "joinorder-synthetic" + expected.add(f"make {target}-cross-surface-equivalence-report") + # TPC-DS runs its pandas backend as a second step so each step stays within the gate budget. + expected.add("make tpcds-pandas-cross-surface-equivalence-report") + steps = [step for step in job["steps"] if str(step.get("run", "")).startswith("make ")] + assert Counter(step["run"] for step in steps) == Counter(dict.fromkeys(expected, 1)) + groups = { + name: {step["run"] for step in steps if step["if"] == f"matrix.partition == '{name}'"} for name in ("a", "b") + } + assert groups["a"] and groups["b"] + assert groups["a"].isdisjoint(groups["b"]) + assert groups["a"] | groups["b"] == expected + assert not any(step.get("continue-on-error") for step in steps) + + +def test_required_local_cases_run_before_merge_and_gate_core() -> None: + jobs = _jobs("ci.yml") + job = jobs["required-local-cases"] + assert job["needs"] == "ci-paths" + assert job["if"] == jobs["correctness-gate"]["if"] + assert job["runs-on"] == "ubuntu-latest" + assert job["timeout-minutes"] == 20 + assert "strategy" not in job + runs = [step["run"] for step in job["steps"] if "run" in step] + assert runs.count("make test-required-local-cases") == 1 + assert not job.get("continue-on-error") + assert not any(step.get("continue-on-error") for step in job["steps"]) + assert "required-local-cases" in jobs["core"]["needs"] + core_text = "\n".join(step.get("run", "") for step in jobs["core"]["steps"]) + condition = "${{ needs.ci-paths.outputs.heavy-needed == 'true' && needs.ci-paths.outputs.unit-core == 'true' }}" + assert f"--expect required-local-cases={condition}" in core_text + + +def test_medium_selection_and_receipts_gate_core() -> None: + jobs = _jobs("ci.yml") + medium = jobs["medium-test"] + assert medium["strategy"] == {"fail-fast": False, "matrix": {"shard_index": [0, 1, 2, 3]}} + assert medium["needs"] == ["ci-paths", "medium-collect"] + selector = "medium and not (slow or stress or resource_heavy or live_integration)" + collect_text = "\n".join(step.get("run", "") for step in jobs["medium-collect"]["steps"]) + medium_text = "\n".join(step.get("run", "") for step in medium["steps"]) + assert selector in collect_text and selector in medium_text + assert "--collect-only -q -n 0" in collect_text + assert "--tb=short --timeout=60 -n 5" in medium_text + assert "-p scripts.pytest_shard_evidence" in medium_text + assert "--collection-summary" in medium_text and "--checked-sha" in medium_text + assert "test -s" in medium_text + assert not medium.get("continue-on-error") + core_text = "\n".join(step.get("run", "") for step in jobs["core"]["steps"]) + for job in ("medium-collect", "medium-test", "correctness-gate"): + assert job in jobs["core"]["needs"] + assert f"--expect {job}=" in core_text + assert "verify-medium" in core_text + + +def test_the_medium_shard_records_memory_and_stalled_stacks() -> None: + medium = _jobs("ci.yml")["medium-test"] + run_step = next(step for step in medium["steps"] if step["name"] == "Run medium speed tier") + assert "sample_resources &" in run_step["run"] + assert 'pkill -P "${sampler}"' in run_step["run"] + assert 'kill "${sampler}"' in run_step["run"] + sampler_body = run_step["run"].split("sample_resources()")[1].split("sample_resources &")[0] + assert "comm" in sampler_body and "args" not in sampler_body + names = [step["name"] for step in medium["steps"]] + memory_step = next( + step for step in medium["steps"] if step["name"] == "Record kernel memory events for the medium shard" + ) + assert memory_step["if"] == "always()" + assert names.index(memory_step["name"]) == names.index(run_step["name"]) + 1 + + +def test_heavy_payload_uses_at_most_twelve_standard_linux_runners() -> None: + jobs = _jobs("ci.yml") + payload = ( + "medium-collect", + "medium-test", + "correctness-gate", + "required-local-cases", + "plan-capture-gate", + "datafusion-integration", + "package-smoke", + "dependency-audit", + ) + count = 0 + for name in payload: + assert jobs[name]["runs-on"] == "ubuntu-latest" + matrix = jobs[name].get("strategy", {}).get("matrix", {}) + count += prod(len(values) for values in matrix.values()) + assert count == 12 + + +def test_native_binary_framing_remains_required_before_merge() -> None: + jobs = _jobs("ci.yml") + native = jobs["tpch-binary-framing"] + assert native["needs"] == "ci-paths" + framing_condition = ( + "${{ needs.ci-paths.outputs.heavy-needed == 'true' || needs.ci-paths.outputs.framing-needed == 'true' }}" + ) + assert native["if"] == framing_condition + assert native["runs-on"] == "${{ matrix.os }}" + assert native["timeout-minutes"] == 15 + assert native["strategy"] == { + "fail-fast": False, + "matrix": {"os": ["macos-latest", "windows-latest"]}, + } + framing = next( + step for step in native["steps"] if step["name"] == "Verify bundled dbgen binaries emit clean framing" + ) + assert framing["run"] == ( + "uv run -- python -m pytest tests/unit/core/tpch/test_tpch_dbgen_framing_binaries.py -m 'unit or slow' -v" + ) + assert not native.get("continue-on-error") + assert not framing.get("continue-on-error") + assert "tpch-binary-framing" in jobs["core"]["needs"] + core_text = "\n".join(step.get("run", "") for step in jobs["core"]["steps"]) + expectation = framing_condition.removeprefix("${{ ").removesuffix(" }}") + assert f"--expect tpch-binary-framing=${{{{ {expectation} }}}}" in core_text + assert "framing-needed" in _jobs("ci.yml")["ci-paths"]["outputs"] + + +def test_three_os_nightly_cells_retain_raw_framing_guard() -> None: + matrix = _jobs("nightly-v2.yml")["matrix"] + assert set(matrix["strategy"]["matrix"]["os"]) == {"ubuntu-latest", "macos-latest", "windows-latest"} + framing = next( + step for step in matrix["steps"] if step["name"] == "Verify bundled dbgen binaries emit clean framing" + ) + assert framing["if"] == "matrix.python-version == '3.12'" + assert ( + framing["run"] + == "uv run -- python -m pytest tests/unit/core/tpch/test_tpch_dbgen_framing_binaries.py -m slow -v" + ) + assert not framing.get("continue-on-error") + from tests.unit.core.tpch.test_tpch_dbgen_framing_binaries import _assert_no_trailing_delimiter + + with pytest.raises(AssertionError, match="trailing"): + _assert_no_trailing_delimiter(["1|ARGENTINA|1|comment|"], "raw fixture") diff --git a/tests/unit/workflows/test_trunk_workflow.py b/tests/unit/workflows/test_trunk_workflow.py new file mode 100644 index 0000000000..ad3311c61f --- /dev/null +++ b/tests/unit/workflows/test_trunk_workflow.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.medium] +ROOT = Path(__file__).resolve().parents[3] + + +def _workflow() -> dict: + return yaml.safe_load((ROOT / ".github/workflows/trunk.yml").read_text(encoding="utf-8")) + + +def _triggers() -> dict: + return _workflow()[True] + + +def test_runs_after_each_push_to_develop_and_on_demand() -> None: + triggers = _triggers() + assert triggers["push"] == {"branches": ["develop"]} + assert "workflow_dispatch" in triggers + assert "pull_request" not in triggers and "merge_group" not in triggers + + +def test_every_push_gets_its_own_queued_run() -> None: + concurrency = _workflow()["concurrency"] + assert "github.ref" in concurrency["group"], "a manual run on another ref would replace the pending run for develop" + assert concurrency["cancel-in-progress"] is False + assert concurrency["queue"] == "max", ( + "without a queue a newer push replaces the pending run, so intermediate commits get no result" + ) + + +def test_is_read_only() -> None: + assert _workflow()["permissions"] == {"contents": "read"} + + +def test_shard_evidence_is_labelled_with_this_workflow() -> None: + commands = [ + step["run"] + for job in _workflow()["jobs"].values() + for step in job["steps"] + if "release_canary_sharding.py" in step.get("run", "") + ] + assert commands + for command in commands: + assert "--workflow trunk.yml" in command + assert "--workflow ci.yml" not in command + + +def test_required_local_cases_run_after_each_merge() -> None: + job = _workflow()["jobs"]["required-local-cases"] + assert job["runs-on"] == "ubuntu-latest" + assert job["timeout-minutes"] == 20 + assert "needs" not in job and "if" not in job and "strategy" not in job + runs = [step["run"] for step in job["steps"] if "run" in step] + assert runs.count("make test-required-local-cases") == 1 + assert not job.get("continue-on-error") + assert not any(step.get("continue-on-error") for step in job["steps"]) + + +def test_builds_the_release_distribution_on_each_push_to_develop() -> None: + job = _workflow()["jobs"]["dist-artifact"] + assert job["name"] == "dist-artifact" + assert job["if"] == "${{ github.event_name == 'push' }}" + assert "needs" not in job + upload = next(step for step in job["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) + assert upload["with"]["name"] == "dist-${{ github.sha }}-attempt-${{ github.run_attempt }}" + assert upload["with"]["retention-days"] == 90 + assert upload["with"]["if-no-files-found"] == "error" + + +def test_distribution_job_alone_reads_actions_and_holds_no_write_permission() -> None: + workflow = _workflow() + assert workflow["jobs"]["dist-artifact"]["permissions"] == {"contents": "read", "actions": "read"} + for name, job in workflow["jobs"].items(): + if name != "dist-artifact": + assert "permissions" not in job + granted = [permission for job in workflow["jobs"].values() for permission in job.get("permissions", {}).values()] + assert set(granted) == {"read"} + + +def test_distribution_job_verifies_exact_distributions_before_binding_and_upload() -> None: + steps = _workflow()["jobs"]["dist-artifact"]["steps"] + runs = [step.get("run", "") for step in steps] + source = next(index for index, run in enumerate(runs) if "bundled_binary_manifest.py" in run) + build = next(index for index, run in enumerate(runs) if "uv build" in run) + bind = next(index for index, run in enumerate(runs) if "release_artifact_consumer.py producer --dist dist" in run) + upload = next( + index for index, step in enumerate(steps) if step.get("uses", "").startswith("actions/upload-artifact@") + ) + assert source < build < bind < upload + assert "scripts/verify_distribution_binaries.py dist/*.whl dist/*.tar.gz" in runs[build] + assert runs[build].index("verify_distribution_binaries.py") < runs[build].index("sha256sum") + assert "sha256sum -- *.whl *.tar.gz > SHA256SUMS" in runs[build] + assert runs[bind].startswith("python -I -S ") + assert steps[bind]["env"] == {"GH_TOKEN": "${{ github.token }}"} + assert [index for index, step in enumerate(steps) if "GH_TOKEN" in str(step.get("env", {}))] == [bind] + path = steps[upload]["with"]["path"] + for entry in ("dist/producer-receipt.json", "dist/SHA256SUMS", "dist/*.whl", "dist/*.tar.gz"): + assert entry in path + + +def test_tpcds_repeat_and_draw_checks_run_after_each_merge() -> None: + commands = [step.get("run", "") for step in _workflow()["jobs"]["tpcds-parity-draws"]["steps"]] + assert "make tpcds-cross-surface-draws-report" in commands diff --git a/tests/unit/workflows/test_website_dependency_audit.py b/tests/unit/workflows/test_website_dependency_audit.py new file mode 100644 index 0000000000..956a84e591 --- /dev/null +++ b/tests/unit/workflows/test_website_dependency_audit.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + +ROOT = Path(__file__).parents[3] +WEBSITE = ROOT / "website" + + +def _package() -> dict[str, Any]: + return json.loads((WEBSITE / "package.json").read_text(encoding="utf-8")) + + +def _jobs(name: str) -> dict[str, Any]: + return yaml.safe_load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8"))["jobs"] + + +def test_ci_and_nightly_run_the_website_high_severity_audit() -> None: + ci_steps = _jobs("ci.yml")["site-build"]["steps"] + nightly_steps = _jobs("nightly.yml")["website-audit"]["steps"] + makefile = (ROOT / "make/documentation.mk").read_text(encoding="utf-8") + + assert any(step.get("run") == "make site-check site-build" for step in ci_steps) + site_check = makefile.split("site-check:", 1)[1].split("\n\n", 1)[0] + assert "npm --prefix website run audit:high" in site_check + audit = [step for step in nightly_steps if step.get("run") == "npm run audit:high"] + assert [step.get("working-directory") for step in audit] == ["website"] + + +def test_audit_script_reuses_the_explorer_gate_against_the_website_package() -> None: + package = _package() + wrapper = (WEBSITE / "scripts/audit-high.mjs").read_text(encoding="utf-8") + + assert package["scripts"]["audit:high"] == "node scripts/audit-high.mjs" + assert '"npm", ["audit", "--json", "--audit-level=high"]' in wrapper + assert 'from "../../results-explorer/scripts/audit-high.mjs"' in wrapper + assert "audit fix --force" not in wrapper + + +def test_audit_allowlist_entries_are_documented_and_expire() -> None: + allowlist = json.loads((WEBSITE / "scripts/audit-high-allowlist.json").read_text(encoding="utf-8")) + + # GHSA-ch52-4w7c-c8xp was cleared by http-cache-semantics 4.3.0; no finding is allowlisted. + assert allowlist == [] + for entry in allowlist: + assert set(entry) == {"id", "package", "reason", "review_by", "link"} + assert re.fullmatch(r"GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}", entry["id"]) + assert entry["id"] in entry["link"] + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", entry["review_by"]) + + +def test_website_package_is_pinned_exactly_on_node_22() -> None: + package = _package() + pinned = {**package["dependencies"], **package["devDependencies"]} + + assert pinned + assert all(re.fullmatch(r"\d+\.\d+\.\d+", version) for version in pinned.values()) + assert package["engines"]["node"] == ">=22.12.0" + assert (WEBSITE / "package-lock.json").is_file() + assert not (ROOT / "package.json").exists() + assert {"@astrojs/check", "typescript"} <= set(package["devDependencies"]) + assert package["scripts"]["check"] == "astro check" + + +def test_only_the_site_build_job_and_website_audit_use_node_22() -> None: + website_jobs = {"site-build", "website-audit"} + for workflow in ("ci.yml", "nightly.yml"): + for name, job in _jobs(workflow).items(): + versions = { + str(step["with"]["node-version"]) + for step in job.get("steps", []) + if "setup-node" in step.get("uses", "") + } + if name in website_jobs: + assert versions == {"22"}, (workflow, name) + else: + assert "22" not in versions, (workflow, name) diff --git a/tests/unit/workflows/test_workflow_expression_contexts.py b/tests/unit/workflows/test_workflow_expression_contexts.py new file mode 100644 index 0000000000..49d1a248a6 --- /dev/null +++ b/tests/unit/workflows/test_workflow_expression_contexts.py @@ -0,0 +1,109 @@ +"""Regression guard for two workflow-file rejections that failed every +triggering run: an unknown context inside a `run:` comment, and the `runner` +context in job-level `env`. + +It does not certify every GitHub evaluation point. Only `runner`, `env`, +`steps`, and `job` are flagged at job level, and `needs`, `matrix`, +`secrets`, and `vars` are permitted there without verifying GitHub resolves +each one in that position. Typo-shaped properties (github.event_pull_request) +and unquoted function arguments pass by design: this checks the head +identifier only. Property-level typo detection belongs to actionlint. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +def _workflows() -> list[Path]: + root = Path(__file__).resolve().parents[3] / ".github" / "workflows" + return sorted([*root.glob("*.yml"), *root.glob("*.yaml")]) + + +WORKFLOWS = _workflows() + +KNOWN_CONTEXTS = { + "github", + "env", + "vars", + "job", + "jobs", + "steps", + "runner", + "secrets", + "strategy", + "matrix", + "needs", + "inputs", +} +# Functions and literals that may start an expression without being a context. +NON_CONTEXT_WORDS = { + "always", + "success", + "failure", + "cancelled", + "contains", + "startsWith", + "endsWith", + "format", + "join", + "toJSON", + "toJson", + "fromJSON", + "hashFiles", + "true", + "false", + "null", +} +# Contexts GitHub does not provide in job-level `env` (evaluated before a runner +# is assigned). +JOB_ENV_FORBIDDEN = {"runner", "env", "steps", "job"} + +EXPRESSION = re.compile(r"\$\{\{(.*?)\}\}", re.DOTALL) +IDENTIFIER = re.compile(r"(? set[str]: + return set(IDENTIFIER.findall(STRING_LITERAL.sub("''", expression))) + + +@pytest.mark.parametrize("workflow", WORKFLOWS, ids=lambda path: path.name) +def test_expressions_name_known_contexts(workflow: Path) -> None: + text = workflow.read_text(encoding="utf-8") + unknown = sorted( + { + name + for expression in EXPRESSION.findall(text) + for name in _identifiers(expression) + if name not in KNOWN_CONTEXTS and name not in NON_CONTEXT_WORDS + } + ) + assert not unknown, f"{workflow.name}: expression names unknown context(s) {unknown}" + + +@pytest.mark.parametrize("workflow", WORKFLOWS, ids=lambda path: path.name) +def test_job_level_env_uses_only_available_contexts(workflow: Path) -> None: + jobs: dict[str, Any] = yaml.safe_load(workflow.read_text(encoding="utf-8")).get("jobs") or {} + offenders = [] + for job_id, job in jobs.items(): + for key, value in ((job or {}).get("env") or {}).items(): + for expression in EXPRESSION.findall(str(value)): + bad = _identifiers(expression) & JOB_ENV_FORBIDDEN + if bad: + offenders.append(f"jobs.{job_id}.env.{key} uses {sorted(bad)}") + assert not offenders, f"{workflow.name}: " + "; ".join(offenders) + + +def test_detector_flags_the_known_failure_shapes() -> None: + assert _identifiers("number") == {"number"} + assert "runner" in _identifiers(" runner.temp ") + assert _identifiers("github.event_name == 'merge_group' && '600' || '0'") == {"github"} + assert _identifiers("matrix.python-version") == {"matrix"} diff --git a/tests/utilities/__init__.py b/tests/utilities/__init__.py index 0f573cef35..2276ccb1bd 100644 --- a/tests/utilities/__init__.py +++ b/tests/utilities/__init__.py @@ -1,31 +1,33 @@ -""" -Copyright 2026 Joe Harris / BenchBox Project +"""Test utilities with lazy public helpers. +Path and state utilities must be usable before optional native probes. Preserve +existing package-level helper imports without loading DuckDB on package import. + +Copyright 2026 Joe Harris / BenchBox Project Licensed under the MIT License. See LICENSE file in the project root for details. """ -# Test utilities package +from __future__ import annotations -from .test_helpers import ( - _benchmark_query_performance, - assert_benchmark_compliance, - assert_olap_features_supported, - assert_valid_sql, - create_test_database, - generate_test_data, - load_tpch_data_to_duckdb, - setup_duckdb_extensions, - validate_query_result, -) +from importlib import import_module +from typing import Any __all__ = [ "_benchmark_query_performance", "assert_valid_sql", "assert_benchmark_compliance", "generate_test_data", - "assert_olap_features_supported", "load_tpch_data_to_duckdb", "setup_duckdb_extensions", "create_test_database", "validate_query_result", + "assert_olap_features_supported", ] + + +def __getattr__(name: str) -> Any: + if name not in __all__: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + helper = getattr(import_module(".test_helpers", __name__), name) + globals()[name] = helper + return helper diff --git a/tests/utilities/fixed_clock.py b/tests/utilities/fixed_clock.py new file mode 100644 index 0000000000..0e7d4d5809 --- /dev/null +++ b/tests/utilities/fixed_clock.py @@ -0,0 +1,104 @@ +"""Fixed-clock helpers for tests whose result must not depend on the time of day. + +``freeze_datetime`` swaps the ``datetime`` name that one consumer module calls, so +only that module sees the fixed instant; the process-wide ``time`` and ``datetime`` +modules keep running. ``set_mtimes`` gives files explicit modification times, so a +test that orders files by mtime does not rely on how fast the files were written. + +Copyright 2026 Joe Harris / BenchBox Project + +Licensed under the MIT License. See LICENSE file in the project root for details. +""" + +from __future__ import annotations + +import os +from collections.abc import Sequence +from datetime import datetime, timezone, tzinfo +from pathlib import Path +from types import ModuleType + +import pytest + +FIXED_NOW = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) +FIXED_EPOCH = FIXED_NOW.timestamp() + +_RealDatetime = datetime + + +class _AcceptsRealDatetimes(type): + """Make ``isinstance(value, datetime)`` in a consumer still accept real datetimes.""" + + def __instancecheck__(cls, obj: object) -> bool: + return isinstance(obj, _RealDatetime) + + def __subclasscheck__(cls, sub: type) -> bool: + return issubclass(sub, _RealDatetime) + + +def _instant(now: datetime, tz: tzinfo | None) -> datetime: + """Return ``now`` as seen from ``tz``; a naive ``now`` is read as UTC. + + With no ``tz`` the result is naive and carries ``now``'s own wall-clock fields. + """ + instant = now if now.tzinfo is not None else now.replace(tzinfo=timezone.utc) + if tz is None: + return instant.replace(tzinfo=None) + return instant.astimezone(tz) + + +def fixed_datetime_class(now: datetime = FIXED_NOW) -> type[datetime]: + """Return a ``datetime`` subclass whose ``now``, ``utcnow`` and ``today`` report ``now``.""" + + class FixedDatetime(_RealDatetime, metaclass=_AcceptsRealDatetimes): + @classmethod + def now(cls, tz: tzinfo | None = None) -> datetime: + return _instant(now, tz) + + @classmethod + def utcnow(cls) -> datetime: + return _instant(now, timezone.utc).replace(tzinfo=None) + + @classmethod + def today(cls) -> datetime: + return _instant(now, None) + + return FixedDatetime + + +def freeze_datetime( + monkeypatch: pytest.MonkeyPatch, + module: ModuleType, + now: datetime = FIXED_NOW, + *, + attr: str = "datetime", +) -> type[datetime]: + """Make ``module``'s ``datetime`` name report ``now``, restored when the test ends. + + Patch the consumer module's own binding, never a dotted path that goes through a + module's ``time`` attribute: that path is the ``time`` module itself and would + freeze every other caller in the process. + """ + target = getattr(module, attr) + if not (isinstance(target, type) and issubclass(target, _RealDatetime)): + raise TypeError( + f"{module.__name__}.{attr} is {target!r}, not the datetime class; patch the name the module calls" + ) + frozen = fixed_datetime_class(now) + monkeypatch.setattr(module, attr, frozen) + return frozen + + +def set_mtimes(files: Sequence[Path], *, newest: float = FIXED_EPOCH, spacing: float = 100.0) -> list[float]: + """Give each file an explicit mtime, oldest first; the last file gets ``newest``. + + Set a time on every competing file, including the newer ones, so the ordering + never depends on the order or speed in which the files were created. + """ + if spacing <= 0: + raise ValueError("spacing must be positive") + last = len(files) - 1 + stamps = [newest - spacing * (last - index) for index in range(len(files))] + for path, stamp in zip(files, stamps): + os.utime(path, (stamp, stamp)) + return stamps diff --git a/tests/utilities/leak_detector.py b/tests/utilities/leak_detector.py new file mode 100644 index 0000000000..3784ca0f3a --- /dev/null +++ b/tests/utilities/leak_detector.py @@ -0,0 +1,131 @@ +"""Detect unit-test process-state leaks after fixture teardown, then restore them. + +Only pytest's owned phase transition in PYTEST_CURRENT_TEST is normalized. +Environment values and provider representations are never included in failures. +Registered globals are read without importing modules or allocating defaults. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Generator, Iterator +from typing import Any + +import pytest + +# The defaults also cover modules first imported during a test. +REGISTERED_GLOBALS: tuple[tuple[str, str, Any], ...] = ( + ("benchbox.utils.printing", "_QUIET", False), + ("benchbox.utils.config_interface", "_config_provider", None), +) +_BASELINE_KEY = pytest.StashKey[dict[str, Any]]() +_LEAK_KEY = pytest.StashKey[list[str]]() + + +def _read_global(module: str, attr: str, default: Any) -> Any: + mod = sys.modules.get(module) + return getattr(mod, attr, default) if mod is not None else default + + +def snapshot() -> dict[str, Any]: + """Read raw state without constructing a configuration provider.""" + return { + "cwd": os.getcwd(), + "env": dict(os.environ), + "globals": {f"{m}.{a}": _read_global(m, a, default) for m, a, default in REGISTERED_GLOBALS}, + } + + +def restore_global(item: pytest.Item, module: str, attr: str, default: Any) -> None: + """Keep reset safety nets without erasing evidence before final teardown. + + For checked tests the outer teardown hook owns the reset. Autouse fixtures + can pull monkeypatch earlier in setup, so resetting in a fixture finalizer + would inspect temporary patches before their legitimate cleanup runs. + Unchecked tests retain the original immediate safety-net behavior. + """ + if item.stash.get(_BASELINE_KEY, None) is not None: + return + mod = sys.modules.get(module) + if mod is not None: + setattr(mod, attr, default) + + +def detect_and_restore(baseline: dict[str, Any]) -> list[str]: + """Restore changed state and return names of the affected state surfaces.""" + problems: list[str] = [] + try: + cwd = os.getcwd() + except FileNotFoundError: + cwd = None + if cwd != baseline["cwd"]: + problems.append("cwd") + os.chdir(baseline["cwd"]) + + env = dict(os.environ) + old = baseline["env"] + for key in sorted(set(old) | set(env)): + if (key in old) != (key in env) or old.get(key) != env.get(key): + problems.append(f"env[{key}]") + if key in old: + os.environ[key] = old[key] + else: + os.environ.pop(key, None) + + for module, attr, default in REGISTERED_GLOBALS: + name = f"{module}.{attr}" + before = baseline["globals"][name] + if _read_global(module, attr, default) is not before: + problems.append(name) + mod = sys.modules.get(module) + if mod is not None: + setattr(mod, attr, before) + return problems + + +def _check_item(item: pytest.Item) -> None: + """Fail the teardown of ``item`` if its function-level teardown left process state changed.""" + baseline = item.stash.get(_BASELINE_KEY, None) + if baseline is None: + return + # pytest changes this value at each phase, outside fixture ownership. + # Accept only its exact expected transition, not arbitrary test writes. + expected = f"{item.nodeid} (teardown)" + if os.environ.get("PYTEST_CURRENT_TEST") == expected: + baseline["env"]["PYTEST_CURRENT_TEST"] = expected + elif "PYTEST_CURRENT_TEST" not in os.environ: + baseline["env"].pop("PYTEST_CURRENT_TEST", None) + problems = detect_and_restore(baseline) + if problems: + item.stash[_LEAK_KEY] = problems + pytest.fail("test leaked process state past teardown (restored): " + ", ".join(problems), pytrace=False) + + +@pytest.fixture(autouse=True) +def _hermetic_state(request: pytest.FixtureRequest) -> Iterator[None]: + """Capture function-fixture state after broader-scoped fixtures are ready. + + The check is a finalizer on the test item, registered here, before any other function-scoped + fixture is set up. Finalizers run last-in first-out, so it runs after every function-level + finalizer (including ones that fail) and before broader-scoped fixtures are torn down. A + module- or session-scoped fixture that legitimately restores state when the last test of its + scope finishes is therefore never blamed on that test. + """ + item = request.node + unit_dir = request.config.rootpath / "tests" / "unit" + if item.path.is_relative_to(unit_dir) or item.get_closest_marker("unit") is not None: + item.stash[_BASELINE_KEY] = snapshot() + item.addfinalizer(lambda: _check_item(item)) + yield + + +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Generator[None, Any, Any]: + report = yield + if report.when == "teardown" and item.stash.get(_LEAK_KEY, None): + # A required isolation error is not an optional/expected failure. + report.outcome = "failed" + if hasattr(report, "wasxfail"): + del report.wasxfail + return report diff --git a/tests/utilities/optional_engines.py b/tests/utilities/optional_engines.py new file mode 100644 index 0000000000..28ff9524d0 --- /dev/null +++ b/tests/utilities/optional_engines.py @@ -0,0 +1,127 @@ +"""Availability checks for optional engines that can be installed yet unusable. + +``pytest.importorskip`` and ``find_spec`` only prove that a package is present. +Two engines can be present and still fail every test that touches them: + +* chDB ships a native library that can fail to load (for example an unsupported + macOS release), which raises ``ImportError``/``OSError`` from the import. +* PySpark needs a supported JDK for its JVM gateway and a Python worker that + matches the driver's minor version. + +The helpers here test usability rather than presence and return a human-readable +reason, so a test module skips with an explicit cause instead of failing. They +never affect required engines (DuckDB, DataFusion, Polars). + +Each result is computed once per process, on first use, so importing this +module never loads a native library. Usage:: + + from tests.utilities.optional_engines import require_chdb + + chdb = require_chdb() # at module level: skips the whole module with the reason +""" + +from __future__ import annotations + +import os +import sys +from functools import lru_cache +from types import ModuleType + +import pytest + +_MAX_REASON_CHARS = 240 + + +def _first_line(exc: BaseException) -> str: + text = str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__ + return text if len(text) <= _MAX_REASON_CHARS else text[: _MAX_REASON_CHARS - 3] + "..." + + +@lru_cache(maxsize=1) +def chdb_skip_reason() -> str | None: + """Return why chDB cannot be used here, or ``None`` when it is usable. + + Goes through the cwd-safe import helper because chDB leaves the process in + its package directory when the native library fails to load. + """ + from benchbox.platforms.clickhouse._dependencies import import_chdb + + try: + module = import_chdb() + except ModuleNotFoundError as exc: + if exc.name == "chdb": + return "chDB not installed" + return f"chDB is installed but a dependency is missing: {_first_line(exc)}" + except (ImportError, OSError) as exc: + return f"chDB is installed but its native library cannot be loaded: {_first_line(exc)}" + + if not hasattr(module, "connect"): + return "chDB is installed but its import is incomplete (no connect attribute)" + try: + connection = module.connect(":memory:") + try: + if str(connection.query("SELECT 1", "CSV")).strip() != "1": + return "chDB is installed but its SQL probe returned an unexpected result" + connection.query("CREATE TABLE benchbox_usability_probe (value UInt8) ENGINE=Memory", "CSV") + connection.query("INSERT INTO benchbox_usability_probe VALUES (1)", "CSV") + if str(connection.query("SELECT value FROM benchbox_usability_probe", "CSV")).strip() != "1": + return "chDB is installed but its table probe returned an unexpected result" + finally: + connection.close() + except (ImportError, OSError, RuntimeError, getattr(module, "ChdbError", RuntimeError)) as exc: + return f"chDB is installed but its connection or SQL runtime is unusable: {_first_line(exc)}" + return None + + +def chdb_usable() -> bool: + return chdb_skip_reason() is None + + +def require_chdb() -> ModuleType: + """Return the chDB module, or skip the calling test (or module) with the reason.""" + from benchbox.platforms.clickhouse._dependencies import import_chdb + + reason = chdb_skip_reason() + if reason is not None: + pytest.skip(reason, allow_module_level=True) + return import_chdb() + + +@lru_cache(maxsize=1) +def pyspark_skip_reason() -> str | None: + """Return why a local Spark session cannot run here, or ``None`` when it can. + + Checks platform support, installation, and a supported JDK without + retaining environment changes. Real sessions configure Java and their + Python worker through the scoped ``pyspark_test_environment`` fixture. + """ + if sys.platform == "win32": + return "PySpark tests skipped on Windows - Hadoop requires winutils.exe setup" + + from benchbox.platforms.pyspark import PYSPARK_AVAILABLE, get_java_skip_reason + + if not PYSPARK_AVAILABLE: + return "PySpark not installed" + + java_home = os.environ.get("JAVA_HOME") + try: + java_reason = get_java_skip_reason() + finally: + if java_home is None: + os.environ.pop("JAVA_HOME", None) + else: + os.environ["JAVA_HOME"] = java_home + if java_reason: + return java_reason + return None + + +def pyspark_usable() -> bool: + return pyspark_skip_reason() is None + + +def require_pyspark() -> None: + """Skip the calling test (or fixture) with the reason PySpark is unusable.""" + reason = pyspark_skip_reason() + if reason is not None: + pytest.skip(reason) diff --git a/tests/utilities/paths.py b/tests/utilities/paths.py new file mode 100644 index 0000000000..98ab6fc900 --- /dev/null +++ b/tests/utilities/paths.py @@ -0,0 +1,14 @@ +"""Repository paths derived from this file's location. + +This module imports only the standard library so tests can locate repository +files without depending on the process working directory or on importing +BenchBox itself. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT: Path = Path(__file__).resolve().parents[2] + +__all__ = ["REPO_ROOT"] diff --git a/tests/utilities/performance_profiler.py b/tests/utilities/performance_profiler.py index f1dd6189ad..a15eaae3df 100644 --- a/tests/utilities/performance_profiler.py +++ b/tests/utilities/performance_profiler.py @@ -30,6 +30,8 @@ import psutil +from tests.utilities.paths import REPO_ROOT + @dataclass class PerformanceMetrics: @@ -521,7 +523,7 @@ def profile_pytest_run(test_command: list[str], output_file: Optional[Path] = No with profiler.profile_test("pytest_run"): # Run pytest with monitoring - result = subprocess.run(test_command, capture_output=True, text=True, cwd=Path.cwd()) + result = subprocess.run(test_command, capture_output=True, text=True, cwd=REPO_ROOT) # Generate report report = profiler.generate_performance_report() diff --git a/tests/utilities/session_isolation.py b/tests/utilities/session_isolation.py new file mode 100644 index 0000000000..955c7e5eea --- /dev/null +++ b/tests/utilities/session_isolation.py @@ -0,0 +1,142 @@ +"""Own a disposable pytest HOME after acquiring the real shared test lock. + +Started by the early pytest plugin, before conftest imports and collection. +Child pytest processes may share a verified ancestor's live lock, never an +unverified environment opt-out. Each process gets its own disposable HOME. +""" + +from __future__ import annotations + +import errno +import os +import sys +import tempfile +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +import pytest + +from scripts.local_validation import _close_lock, read_holder, wait_for_lock, write_holder + +_OWNER = "BENCHBOX_TEST_SESSION_OWNER" +_ENV_KEYS = ("HOME", "USERPROFILE", "BENCHBOX_TEST_LOCK_DIR", _OWNER) +_state: dict[str, Any] | None = None + + +def _ancestor_holds_lock(lock_path: Path) -> bool: + """Verify both lineage and kernel lock liveness before sharing ownership.""" + try: + import psutil + except ImportError: + return False + try: + owner = int(os.environ.get(_OWNER, "0")) + + if owner not in {parent.pid for parent in psutil.Process().parents()}: + return False + if not read_holder(lock_path).startswith(f"pid:{owner} "): + return False + fd = os.open(lock_path, os.O_RDWR) + try: + if sys.platform == "win32": + import msvcrt + + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except OSError as exc: + if exc.errno == errno.EACCES: + return True + raise + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + else: + import fcntl + + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return True + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + except (psutil.Error, OSError, ValueError): + return False + return False + + +def start(acquire_lock: bool = True) -> bool: + """Acquire or verify the shared lock before changing any HOME setting. + + ``acquire_lock=False`` isolates HOME without the lock, for a run that does not compete for + one: a serial run, or one that sets ``BENCHBOX_SKIP_TEST_LOCK``. + """ + global _state + if _state is not None: + return False + previous = {key: os.environ.get(key) for key in _ENV_KEYS} + configured = os.environ.get("BENCHBOX_TEST_LOCK_DIR") + lock_dir = Path(configured).expanduser().resolve() if configured else Path.home() / ".benchbox" + lock_path = lock_dir / "test.lock" + fd: int | None = None + inherited = _ancestor_holds_lock(lock_path) if acquire_lock else False + if acquire_lock and not inherited: + try: + wait = max(0.0, float(os.environ.get("BENCHBOX_TEST_LOCK_WAIT_SECONDS", "3600") or "3600")) + except ValueError: + wait = 3600.0 + fd = wait_for_lock(lock_path, wait) + write_holder(fd, lock_path, phase="pytest-session", gate="hermetic") + try: + home = tempfile.TemporaryDirectory( + prefix="benchbox-pytest-home-", dir=os.environ.get("JCODE_SCRATCH_DIR"), ignore_cleanup_errors=True + ) + except BaseException: + if fd is not None: + _close_lock(fd) + raise + os.environ["HOME"] = home.name + os.environ["USERPROFILE"] = home.name + os.environ["BENCHBOX_TEST_LOCK_DIR"] = str(lock_dir) + if acquire_lock and not inherited: + os.environ[_OWNER] = str(os.getpid()) + _state = {"previous": previous, "home": home, "fd": fd} + return True + + +def own_environment(monkeypatch: pytest.MonkeyPatch, keys: Iterable[str]) -> None: + """Register restoration of named runtime outputs, including absent keys.""" + for key in keys: + value = os.environ.get(key) + monkeypatch.setenv(key, value if value is not None else "") + if value is None: + monkeypatch.delenv(key) + + +def session_home() -> Path: + """Return the owned collection-time HOME, independent of test patches.""" + if _state is None: + raise RuntimeError("test-session isolation is not active") + return Path(_state["home"].name) + + +def active() -> bool: + """Whether this process established owned or verified test-session state.""" + return _state is not None + + +def finish() -> None: + """Restore the caller's environment and release only our own lock.""" + global _state + if _state is None: + return + state, _state = _state, None + try: + for key, value in state["previous"].items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + state["home"].cleanup() + finally: + if state["fd"] is not None: + _close_lock(state["fd"]) diff --git a/tests/utilities/test_runner.py b/tests/utilities/test_runner.py index 0ab3d75615..725d890b27 100644 --- a/tests/utilities/test_runner.py +++ b/tests/utilities/test_runner.py @@ -25,6 +25,8 @@ from enum import Enum from pathlib import Path +from tests.utilities.paths import REPO_ROOT + class BenchmarkCategory(Enum): """Test categories for selective execution.""" @@ -156,7 +158,7 @@ class BenchmarkRunner: """Enhanced test runner with optimization features.""" def __init__(self, project_root: Path | None = None): - self.project_root = project_root or Path.cwd() + self.project_root = project_root or REPO_ROOT self.tests_dir = self.project_root / "tests" self.cache = ResultCache() self.results_history: list[BenchmarkTestResult] = [] diff --git a/uv.lock b/uv.lock index 334dd12569..ddd97b8a00 100644 --- a/uv.lock +++ b/uv.lock @@ -371,6 +371,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/fe/f30ad42bd082b9c6d419c23311a8904a55e248e07c61bf6b91e1691188aa/backports_zstd-1.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a64e796c7eee69dfe45827b2e003b7731785ec890c73ea5f5fbc30a1c362fcad", size = 316244, upload-time = "2026-08-15T17:26:42.614Z" }, ] +[[package]] +name = "bashlex" +version = "0.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/60/aae0bb54f9af5e0128ba90eb83d8d0d506ee8f0475c4fdda3deeda20b1d2/bashlex-0.18.tar.gz", hash = "sha256:5bb03a01c6d5676338c36fd1028009c8ad07e7d61d8a1ce3f513b7fff52796ee", size = 68742, upload-time = "2023-01-18T15:21:26.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/be/6985abb1011fda8a523cfe21ed9629e397d6e06fb5bae99750402b25c95b/bashlex-0.18-py2.py3-none-any.whl", hash = "sha256:91d73a23a3e51711919c1c899083890cdecffc91d8c088942725ac13e9dcfffa", size = 69539, upload-time = "2023-01-18T15:21:24.167Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -386,7 +395,7 @@ wheels = [ [[package]] name = "benchbox" -version = "0.4.1" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "click" }, @@ -691,6 +700,7 @@ velox = [ dev = [ { name = "ablog" }, { name = "ansi2html" }, + { name = "bashlex" }, { name = "chdb", marker = "sys_platform != 'win32'" }, { name = "clickhouse-connect" }, { name = "clickhouse-driver" }, @@ -706,6 +716,7 @@ dev = [ { name = "furo" }, { name = "import-linter" }, { name = "influxdb3-python" }, + { name = "markdown-it-py" }, { name = "mcp" }, { name = "mutmut" }, { name = "myst-parser" }, @@ -717,6 +728,7 @@ dev = [ { name = "pre-commit" }, { name = "presto-python-client" }, { name = "psycopg", extra = ["binary"] }, + { name = "pygments" }, { name = "pyiceberg", extra = ["pyarrow", "sql-sqlite"] }, { name = "pymysql" }, { name = "pyspark" }, @@ -923,6 +935,7 @@ provides-extras = ["dev", "docs", "viz", "explorer", "all", "cloud", "cloudstora dev = [ { name = "ablog", marker = "python_full_version >= '3.11'", specifier = ">=0.11.13" }, { name = "ansi2html", specifier = ">=1.8.0" }, + { name = "bashlex", specifier = ">=0.18" }, { name = "chdb", marker = "sys_platform != 'win32'", specifier = ">=0.10.0" }, { name = "clickhouse-connect", specifier = ">=0.10.0" }, { name = "clickhouse-driver", specifier = ">=0.2.0" }, @@ -938,6 +951,7 @@ dev = [ { name = "furo", specifier = ">=2025.9.25" }, { name = "import-linter", specifier = ">=2.13" }, { name = "influxdb3-python", specifier = ">=0.10.0" }, + { name = "markdown-it-py", specifier = ">=4.0.0" }, { name = "mcp", specifier = ">=2,<3" }, { name = "mutmut", specifier = ">=3.5.0" }, { name = "myst-parser", specifier = ">=3.0.1" }, @@ -949,6 +963,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.6.0" }, { name = "presto-python-client", specifier = ">=0.8.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, + { name = "pygments", specifier = ">=2.18.0" }, { name = "pyiceberg", extras = ["sql-sqlite", "pyarrow"], specifier = ">=0.10.0" }, { name = "pymysql", specifier = ">=1.1.2" }, { name = "pyspark", specifier = ">=3.5.0" }, @@ -4786,11 +4801,11 @@ sql-sqlite = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] @@ -5091,15 +5106,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.1" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" }, + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, ] [[package]] @@ -6767,19 +6781,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.8" +version = "6.5.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/61/53d562a57b28c08eda40b258c0f975e360541943ad7c7bef897a40caafda/tornado-6.5.10.tar.gz", hash = "sha256:a6b1ccd08c04b4a06fb5aeb381be99de5ad1e5375c1785e31d78c880feb57687", size = 537910, upload-time = "2026-09-15T13:47:48.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, - { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, - { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, - { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, - { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, - { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, - { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9261783640e23258694a9ff0795df430a5a7b0a651d3dd53dd0969ad6be16da7", size = 465883, upload-time = "2026-09-15T13:47:35.463Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f5/cd7be26c34a3315532f3aef5f092465da8f59c334dd439d3c14aaef16461/tornado-6.5.10-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:83e6cf438b106c6b3852d70960967bb1b70c87438050dca0981e4b9aa751a4c1", size = 464046, upload-time = "2026-09-15T13:47:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bdf942448169e5336451d0494d7e3d81cfa726d5aa312affdc4682dd62a62f6d", size = 467096, upload-time = "2026-09-15T13:47:38.559Z" }, + { url = "https://files.pythonhosted.org/packages/29/17/cc35dff68272d685cffd8600ffafbd8067e7d05e7348d9f80caddffbbd5f/tornado-6.5.10-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69acca6501eed74582b76dbbceee2a91613f54728e3e418346000d7103101676", size = 468067, upload-time = "2026-09-15T13:47:40.085Z" }, + { url = "https://files.pythonhosted.org/packages/c3/01/6e5349b4e1a53a4b4972a6716785e1fe7407f312063c3972690af8ff301b/tornado-6.5.10-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:66aaa3f57d30c6e6becee83ff28055d5930ac724214bde99393eefda83d5e015", size = 467901, upload-time = "2026-09-15T13:47:41.576Z" }, + { url = "https://files.pythonhosted.org/packages/28/5e/b4facf94370dba006819c8d304376f8b9fbec6b935b5e51bf45823a9790b/tornado-6.5.10-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bd192b959f9128fb99b8898148070ba4574c9589b78bce42d1851131fe85828", size = 467308, upload-time = "2026-09-15T13:47:43.145Z" }, + { url = "https://files.pythonhosted.org/packages/56/ae/047938e828cafc8eca4c908fafb6588fee944e3af39a0af9d7b602499ae5/tornado-6.5.10-cp39-abi3-win32.whl", hash = "sha256:302eb1e0e3e159314eb591920529fdea80acca92df5510a2cec5bbd4f099ec72", size = 468387, upload-time = "2026-09-15T13:47:44.556Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/5901517f05affd752490f6a654ba31b7474664e8dd80bd045a00c220bd88/tornado-6.5.10-cp39-abi3-win_amd64.whl", hash = "sha256:37ae8f150cecfdbf747fc4e12f5e9a97ecd8cf1d4cdb3f119e2de84b11196918", size = 468828, upload-time = "2026-09-15T13:47:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/fd497f3a7f7b74bb04f4b94536b5c9f80742b5d50501fd27977652ddec16/tornado-6.5.10-cp39-abi3-win_arm64.whl", hash = "sha256:ce045d3c298fddd30e89a2777f97039d1b641eb9518ac7b26a4721903539c694", size = 467847, upload-time = "2026-09-15T13:47:47.283Z" }, ] [[package]] @@ -6990,11 +7004,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -7012,17 +7026,18 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.0" +version = "21.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/67/b5d37693e5e666b68100db8fe34f00db28279330db55f739a3c799ff2449/virtualenv-21.14.1.tar.gz", hash = "sha256:719b189804e66678017d9f63bbfc590c44f6b96ab4829513806394f9b375929c", size = 5462838, upload-time = "2026-09-29T23:33:33.991Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, + { url = "https://files.pythonhosted.org/packages/85/a8/22be2d14ef0d7b1e9e7dc1d839a85073fc20917be1747d6f1347d1cfc8c3/virtualenv-21.14.1-py3-none-any.whl", hash = "sha256:6fd04089fc0dc33549e7abdff70fc3b63d4e15799f2dbf3281f80d13b9fce522", size = 5486534, upload-time = "2026-09-29T23:33:32.333Z" }, ] [[package]] diff --git a/website/astro.config.ts b/website/astro.config.ts new file mode 100644 index 0000000000..1e9fff87be --- /dev/null +++ b/website/astro.config.ts @@ -0,0 +1,65 @@ +import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { unified } from "@astrojs/markdown-remark"; +import starlight from "@astrojs/starlight"; +import type { AstroIntegration } from "astro"; +import { defineConfig } from "astro/config"; +import { mystLite } from "./src/plugins/myst-lite.ts"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const publishStatic = (): AstroIntegration => ({ + name: "benchbox-publish-static", + hooks: { + "astro:build:done": ({ dir }) => { + const out = fileURLToPath(dir); + const explorerDist = path.join(repoRoot, "results-explorer", "dist"); + if (!existsSync(explorerDist)) throw new Error(`Results Explorer build is missing: ${explorerDist}`); + cpSync(explorerDist, path.join(out, "results"), { recursive: true }); + cpSync(path.join(repoRoot, "landing", "hero.png"), path.join(out, "hero.png")); + const images = path.join(repoRoot, "docs", "blog", "images"); + mkdirSync(path.join(out, "_images"), { recursive: true }); + for (const name of readdirSync(images)) cpSync(path.join(images, name), path.join(out, "_images", name)); + }, + }, +}); + +export default defineConfig({ + site: "https://benchbox.dev", + trailingSlash: "ignore", + build: { format: "file" }, + markdown: { processor: unified({ remarkPlugins: [mystLite] }) }, + integrations: [ + starlight({ + title: "BenchBox", + pagefind: false, + disable404Route: true, + lastUpdated: false, + customCss: ["./src/styles/tokens.css", "./src/styles/shell.css", "./src/styles/starlight-map.css"], + components: { + Header: "./src/components/starlight/Header.astro", + ThemeProvider: "./src/components/starlight/ThemeProvider.astro", + ThemeSelect: "./src/components/starlight/Empty.astro", + }, + sidebar: [ + { + label: "Usage", + items: [{ label: "Getting Started in 5 Minutes", link: "/docs/usage/getting-started.html" }], + }, + { + label: "Benchmarks", + items: [ + { label: "Industry Benchmarks", link: "/docs/benchmarks/industry-benchmarks.html" }, + { label: "TPC-H Q1", link: "/docs/benchmarks/queries/tpch/q1.html" }, + ], + }, + { + label: "Reference", + items: [{ label: "Additional Utilities", link: "/docs/reference/python-api/additional-utilities.html" }], + }, + ], + }), + publishStatic(), + ], +}); diff --git a/website/e2e/verify.mjs b/website/e2e/verify.mjs new file mode 100644 index 0000000000..744c4b3c44 --- /dev/null +++ b/website/e2e/verify.mjs @@ -0,0 +1,354 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; +import AxeBuilder from "@axe-core/playwright"; +import { chromium } from "@playwright/test"; + +const base = process.env.BASE_URL ?? "http://127.0.0.1:4330"; +const outDir = process.env.OUT_DIR ?? path.join(os.tmpdir(), "benchbox-website-e2e"); +mkdirSync(outDir, { recursive: true }); + +const samplePages = [ + "/", + "/docs/usage/getting-started.html", + "/docs/benchmarks/industry-benchmarks.html", + "/docs/benchmarks/queries/tpch/q1.html", + "/docs/reference/python-api/additional-utilities.html", + "/blog/2026-05-18-v0-3-0-release-overview.html", +]; + +const explorerPages = [ + "/results/", + "/results/platforms/", + "/results/p/duckdb/?benchmark=tpch", + "/results/benchmarks/?sort=results", +]; + +const explorerPattern = /\/results\/|duckdb|\.wasm|\.worker/i; + +const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined }); +const report = {}; + +async function newPage(theme, width = 1280) { + const context = await browser.newContext({ viewport: { width, height: width < 600 ? 800 : 900 }, colorScheme: "light" }); + if (theme) await context.addInitScript((value) => localStorage.setItem("benchbox:theme", value), theme); + const page = await context.newPage(); + const log = { requests: [], console: [], pageErrors: [], workers: [] }; + page.on("console", (message) => { + if (message.type() === "error" || message.type() === "warning") { + log.console.push({ type: message.type(), text: message.text(), url: message.location().url }); + } + }); + page.on("pageerror", (error) => log.pageErrors.push(String(error))); + page.on("worker", (worker) => log.workers.push(worker.url())); + page.on("requestfinished", async (request) => { + const response = await request.response(); + const sizes = await request.sizes().catch(() => null); + let gzip = null; + const type = request.resourceType(); + if (["document", "script", "stylesheet"].includes(type)) { + const body = await response?.body().catch(() => null); + gzip = body ? gzipSync(body).length : null; + } + log.requests.push({ + url: request.url().replace(base, ""), + type, + status: response?.status(), + transfer: sizes ? sizes.responseBodySize + sizes.responseHeadersSize : null, + body: sizes?.responseBodySize ?? null, + gzip, + }); + }); + page.on("requestfailed", (request) => log.requests.push({ url: request.url().replace(base, ""), type: request.resourceType(), failed: request.failure()?.errorText })); + return { page, context, log }; +} + +function summarize(log) { + const sum = (types, key) => + log.requests.filter((r) => types.includes(r.type)).reduce((total, r) => total + (r[key] ?? 0), 0); + const kinds = ["document", "script", "stylesheet"]; + return { + requestCount: log.requests.length, + htmlJsCssTransferBytes: sum(kinds, "transfer"), + htmlJsCssBodyBytes: sum(kinds, "body"), + htmlJsCssGzipBytes: sum(kinds, "gzip"), + imageBytes: sum(["image"], "transfer"), + otherBytes: sum(["font", "fetch", "xhr", "other", "media"], "transfer"), + explorerOrDuckdbRequests: log.requests.filter((r) => explorerPattern.test(r.url) && r.type !== "document").map((r) => r.url), + workers: log.workers, + consoleErrors: log.console, + pageErrors: log.pageErrors, + requests: log.requests.map((r) => `${r.type} ${r.status ?? r.failed} ${r.transfer ?? "-"} ${r.url}`), + }; +} + +report.pages = {}; +for (const route of samplePages) { + const { page, context, log } = await newPage(); + await page.goto(base + route, { waitUntil: "networkidle" }); + await page.waitForTimeout(300); + report.pages[route] = summarize(log); + report.pages[route].title = await page.title(); + report.pages[route].h1 = await page.locator("h1").first().innerText().catch(() => null); + await context.close(); +} + +const widths = [320, 375, 1280]; +const headerSelector = ".site-header a, .site-header button, button.sl-menu-button"; + +function intersects(a, b) { + const w = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + return w > 1 && h > 1; +} + +report.reflow = {}; +for (const width of widths) { + for (const route of samplePages) { + const { page, context } = await newPage(undefined, width); + await page.goto(base + route, { waitUntil: "networkidle" }); + const layout = await page.evaluate((selector) => { + const controls = [...document.querySelectorAll(selector)] + .filter((el) => el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden") + .map((el) => { + const r = el.getBoundingClientRect(); + return { + name: (el.getAttribute("aria-label") || el.textContent || el.dataset.themeOption || "").trim().slice(0, 24), + left: r.left, + right: r.right, + top: r.top, + bottom: r.bottom, + }; + }); + const root = document.scrollingElement; + return { scrollWidth: root.scrollWidth, innerWidth: window.innerWidth, controls }; + }, headerSelector); + const overlaps = []; + for (let i = 0; i < layout.controls.length; i++) { + for (let j = i + 1; j < layout.controls.length; j++) { + if (intersects(layout.controls[i], layout.controls[j])) overlaps.push(`${layout.controls[i].name} x ${layout.controls[j].name}`); + } + } + const outside = layout.controls + .filter((c) => c.left < -0.5 || c.right > layout.innerWidth + 0.5 || c.top < -0.5) + .map((c) => `${c.name} [${Math.round(c.left)}..${Math.round(c.right)}]`); + const result = await new AxeBuilder({ page }).analyze(); + const seriousOrCritical = result.violations + .filter((v) => v.impact === "serious" || v.impact === "critical") + .reduce((total, v) => total + v.nodes.length, 0); + const entry = { + scrollWidth: layout.scrollWidth, + innerWidth: layout.innerWidth, + overflowPx: Math.max(0, layout.scrollWidth - layout.innerWidth), + noHorizontalOverflow: layout.scrollWidth <= layout.innerWidth, + headerControls: layout.controls.length, + overlaps, + outsideViewport: outside, + axeSeriousCritical: seriousOrCritical, + }; + if (width === 375) { + const toggle = page.locator('.theme-toggle [data-theme-option="dark"]').first(); + try { + await toggle.click({ timeout: 3000 }); + entry.themeToggleClickable = (await page.evaluate(() => document.documentElement.dataset.theme)) === "dark"; + await page.locator('.theme-toggle [data-theme-option="system"]').first().click({ timeout: 3000 }); + } catch (error) { + entry.themeToggleClickable = false; + entry.themeToggleError = String(error).split("\n")[0]; + } + await page.locator('.theme-toggle [aria-checked="true"]').first().focus(); + await page.keyboard.press("ArrowRight"); + entry.themeToggleKeyboard = (await page.evaluate(() => document.documentElement.dataset.bbThemeChoice)) === "light"; + const menu = page.locator("button.sl-menu-button"); + if (await menu.count()) { + await menu.first().click({ timeout: 3000 }); + await page.waitForTimeout(300); + entry.menuOpensSidebar = await page.evaluate(() => document.querySelector("#starlight__sidebar")?.matches(":popover-open") ?? false); + } + await page.screenshot({ path: path.join(outDir, `w375-${route.replace(/\W+/g, "_")}.png`) }); + } + if (width === 320) await page.screenshot({ path: path.join(outDir, `w320-${route.replace(/\W+/g, "_")}.png`) }); + report.reflow[`${width} ${route}`] = entry; + await context.close(); + } +} +report.reflowFailures = Object.entries(report.reflow) + .filter( + ([, e]) => + !e.noHorizontalOverflow || + e.overlaps.length || + e.outsideViewport.length || + e.axeSeriousCritical || + e.themeToggleClickable === false || + e.themeToggleKeyboard === false || + e.menuOpensSidebar === false, + ) + .map(([key]) => key); + +report.axe = {}; +for (const theme of ["light", "dark"]) { + for (const route of samplePages) { + const { page, context } = await newPage(theme); + await page.goto(base + route, { waitUntil: "networkidle" }); + const effective = await page.evaluate(() => document.documentElement.dataset.theme); + const result = await new AxeBuilder({ page }).analyze(); + const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 }; + for (const violation of result.violations) counts[violation.impact ?? "minor"] += violation.nodes.length; + report.axe[`${theme} ${route}`] = { + effective, + counts, + violations: result.violations.map((v) => ({ + id: v.id, + impact: v.impact, + nodes: v.nodes.length, + targets: v.nodes.slice(0, 3).map((n) => n.target.join(" ")), + })), + }; + if (theme === "light" || route === "/") await page.screenshot({ path: path.join(outDir, `${theme}-${route.replace(/\W+/g, "_")}.png`), fullPage: false }); + await context.close(); + } +} + +{ + const { page, context } = await newPage(); + const trail = []; + const mark = async (label) => trail.push({ label, url: page.url().replace(base, ""), title: await page.title(), scrollY: await page.evaluate(() => Math.round(scrollY)) }); + await page.goto(base + "/docs/usage/getting-started.html", { waitUntil: "load" }); + await page.evaluate(() => window.scrollTo(0, 1200)); + await page.waitForTimeout(200); + await mark("start (scrolled 1200)"); + await page.getByRole("link", { name: "Industry Benchmarks" }).first().click(); + await page.waitForURL("**/industry-benchmarks.html"); + await mark("clicked sidebar: industry"); + await page.getByRole("link", { name: "TPC-H Q1" }).first().click(); + await page.waitForURL("**/tpch/q1.html"); + await mark("clicked sidebar: q1"); + await page.goBack({ waitUntil: "load" }); + await mark("back 1"); + await page.goBack({ waitUntil: "load" }); + await page.waitForTimeout(200); + await mark("back 2"); + await page.goForward({ waitUntil: "load" }); + await mark("forward 1"); + await page.goForward({ waitUntil: "load" }); + await mark("forward 2"); + report.history = trail; + await context.close(); +} + +{ + const { page, context } = await newPage(); + const trail = []; + const mark = async (label) => trail.push({ label, url: page.url().replace(base, ""), title: await page.title(), h1: await page.locator("h1").first().innerText().catch(() => null) }); + await page.goto(base + "/docs/usage/getting-started.html", { waitUntil: "load" }); + await mark("docs"); + await page.locator(".site-header__link", { hasText: "Results" }).first().click(); + await page.waitForURL("**/results/"); + await page.waitForSelector("tbody tr", { timeout: 30000 }).catch(() => null); + await mark("clicked Results"); + await page.goBack({ waitUntil: "load" }); + await mark("back to docs"); + await page.goForward({ waitUntil: "load" }); + await page.waitForSelector("tbody tr", { timeout: 30000 }).catch(() => null); + await mark("forward to results"); + await page.getByRole("link", { name: "Platforms" }).first().click(); + await page.waitForURL("**/results/platforms/**"); + await page.waitForSelector("tbody tr", { timeout: 30000 }).catch(() => null); + await mark("explorer: Platforms"); + await page.goBack({ waitUntil: "load" }); + await page.waitForSelector("h1", { timeout: 30000 }).catch(() => null); + await mark("explorer: back"); + await page.goForward({ waitUntil: "load" }); + await page.waitForSelector("h1", { timeout: 30000 }).catch(() => null); + await mark("explorer: forward"); + const urls = trail.map((t) => t.url); + report.crossHistory = { + trail, + pass: + urls[1].startsWith("/results/") && + urls[2] === "/docs/usage/getting-started.html" && + urls[3].startsWith("/results/") && + urls[4].startsWith("/results/platforms") && + urls[5] === urls[3] && + urls[6] === urls[4], + }; + await context.close(); +} + +{ + const { page, context, log } = await newPage(); + await page.goto(base + "/docs/usage/getting-started.html", { waitUntil: "networkidle" }); + const before = log.requests.length; + await page.getByRole("button", { name: "Search" }).click(); + await page.locator(".pagefind-ui__search-input").fill("scale factor"); + await page.waitForSelector(".pagefind-ui__result", { timeout: 10000 }); + const results = await page.locator(".pagefind-ui__result-link").evaluateAll((links) => links.map((a) => a.getAttribute("href"))); + await page.screenshot({ path: path.join(outDir, "search.png") }); + report.search = { query: "scale factor", results, requestsAfterOpen: log.requests.slice(before).map((r) => `${r.type} ${r.status} ${r.transfer} ${r.url}`) }; + await context.close(); +} + +{ + const { page, context } = await newPage(); + await page.goto(base + "/docs/usage/getting-started.html", { waitUntil: "load" }); + const states = []; + const read = async (label) => + states.push({ + label, + stored: await page.evaluate(() => localStorage.getItem("benchbox:theme")), + theme: await page.evaluate(() => document.documentElement.dataset.theme), + choice: await page.evaluate(() => document.documentElement.dataset.bbThemeChoice), + }); + await read("initial"); + await page.getByRole("radio", { name: "Dark" }).click(); + await read("clicked Dark"); + await page.goto(base + "/results/", { waitUntil: "load" }); + await page.waitForSelector("#app *"); + await read("explorer after Dark on docs"); + await page.goto(base + "/", { waitUntil: "load" }); + await read("landing"); + await page.getByRole("radio", { name: "System" }).click(); + await read("clicked System"); + report.theme = states; + await context.close(); +} + +report.explorer = {}; +for (const route of explorerPages) { + const { page, context, log } = await newPage(); + const response = await page.goto(base + route, { waitUntil: "load" }); + const initialStatus = response?.status(); + let rows = null; + try { + await page.waitForSelector("tbody tr", { timeout: 30000 }); + rows = await page.locator("tbody tr").count(); + } catch { + rows = 0; + } + await page.waitForLoadState("networkidle").catch(() => null); + const summary = summarize(log); + report.explorer[route] = { + initialStatus, + finalUrl: page.url().replace(base, ""), + h1: await page.locator("h1").first().innerText().catch(() => null), + rows, + wasmAndWorkerRequests: summary.requests.filter((r) => /\.wasm|worker/.test(r)), + dataRequests: summary.requests.filter((r) => /duckdb|\/data\//.test(r) && !/\.wasm|worker/.test(r)), + workers: summary.workers, + consoleErrors: summary.consoleErrors, + pageErrors: summary.pageErrors, + }; + await page.screenshot({ path: path.join(outDir, `explorer-${route.replace(/\W+/g, "_")}.png`) }); + await context.close(); +} + +await browser.close(); +writeFileSync(path.join(outDir, "verify.json"), JSON.stringify(report, null, 2)); +console.log(`wrote ${path.join(outDir, "verify.json")}`); + +const failed = [...report.reflowFailures, ...(report.crossHistory?.pass ? [] : ["crossHistory"])]; +if (failed.length) { + console.error(`verification failures: ${failed.join(", ")}`); + process.exitCode = 1; +} diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 0000000000..97035cd58d --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,7877 @@ +{ + "name": "benchbox-website", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "benchbox-website", + "version": "0.0.0", + "dependencies": { + "@astrojs/markdown-remark": "7.3.1", + "@astrojs/starlight": "0.42.5", + "astro": "7.3.5", + "pagefind": "1.5.2" + }, + "devDependencies": { + "@astrojs/check": "0.9.10", + "@axe-core/playwright": "4.13.0", + "@playwright/test": "1.63.0", + "typescript": "6.0.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/astro2tsx": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/astro2tsx/-/astro2tsx-0.1.2.tgz", + "integrity": "sha512-Uj/M9zbrhu5cIZEvWAGTbQJRKSxXWJ/E59BZwt7F+chrKhbfttW+9rkS/l+1QxoJP1KU3UnpM62M7yszfAI0WA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emnapi/core": "1.11.3", + "@napi-rs/wasm-runtime": "1.2.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/check": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.10.tgz", + "integrity": "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/language-server": "^2.16.7", + "chokidar": "^4.0.3", + "kleur": "^4.1.5", + "yargs": "^18.0.0" + }, + "bin": { + "astro-check": "bin/astro-check.js" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@astrojs/check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@astrojs/check/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@astrojs/compiler-binding": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.5.1.tgz", + "integrity": "sha512-oczyolLUl9mNrtuBuNjuvn5thpHrFLXAHAWGTRg0JDHQ8cGVsVeFekOu2uSoQGgainJIi+A3/9m0DyYwz3Nlnw==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-android-arm64": "0.5.1", + "@astrojs/compiler-binding-darwin-arm64": "0.5.1", + "@astrojs/compiler-binding-darwin-x64": "0.5.1", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.1", + "@astrojs/compiler-binding-linux-arm64-musl": "0.5.1", + "@astrojs/compiler-binding-linux-x64-gnu": "0.5.1", + "@astrojs/compiler-binding-linux-x64-musl": "0.5.1", + "@astrojs/compiler-binding-wasm32-wasi": "0.5.1", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.1", + "@astrojs/compiler-binding-win32-x64-msvc": "0.5.1" + } + }, + "node_modules/@astrojs/compiler-binding-android-arm64": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-android-arm64/-/compiler-binding-android-arm64-0.5.1.tgz", + "integrity": "sha512-nMb2nMIJtcWLNH+fV75Enmi+jp4tIOYOfsv4XtpgdOWkqc9P4uSSdK5u1xb340AzrXo5V18SxLl/nc3vcqtamQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.5.1.tgz", + "integrity": "sha512-5k4+nZ/WRt+dcVow4z7rBuI7iv9A58h/uQ91YJe1PrTivbcCkq3u2Ynx/AZJwII4CsZawBCeGpojkFjutZBhBQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.5.1.tgz", + "integrity": "sha512-0Buw+YST9Ff2EqQyhRMQujuFq/dijLw3zXWhsvI0dJ2pGrT38iVoxTEca2eBX/PfhlwkVWSRJEKO5gMqKHyeHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.5.1.tgz", + "integrity": "sha512-x5LRpyCoMKuu3INr18cn42YhwSOaRpp9b5Ny3eJJ/sWVeejC16Zeshu1JlvlSmEZACLeWxU5JM0RX6T6nI+YyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.5.1.tgz", + "integrity": "sha512-mL7tQEuDk3rGfaKmrO63nw6WBy1TPVioQGmVyjcAr+C+a/k3SVMYBytNSMqH9mZPT/Swj30JaZ2N842Z1NnOEQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.5.1.tgz", + "integrity": "sha512-uLGc9jtxwiDe4cQyhNPQim4ECiB/YzbWlXssfLAv8r374kArfTwBHjwR2+j+XVyF71yCx5mF9YNRclRqdNBgeg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.5.1.tgz", + "integrity": "sha512-G0cywinaQUc8v1MrytvYKUX7svTDMmYtOCZfxjwKPbJi59zIqM+Ll0rWgO0GQWLZH3XoPYo19ZwlP3Xt9ep9YA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.5.1.tgz", + "integrity": "sha512-yMhLL8asmLydoAdnEZxAw6eLfNDs3YJl6jqn0pjPt6zrb2U5qIDLnii4/twThpIFODKkQ6YDpBRTOz/vSfZCxw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", + "@napi-rs/wasm-runtime": "^1.2.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.5.1.tgz", + "integrity": "sha512-aClIG6poFt3Wf/bZykrEAX2nAnKvBHW6RXiKUa69QjBtWH0/FOlOAjnFcxRNlhej2AMWNYFx0o+LGjwMcH8P7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.5.1.tgz", + "integrity": "sha512-/E6uBCagY70YpWqssBC+/FuniNSloeQPRgj7l3LDWiqjxIyTLnIgX1sTt18FU7jrigetpSnC9cHAM6b6IxSgPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.5.1.tgz", + "integrity": "sha512-1k1acnX7DepIiea9U9/Y+d1hdAFfylu1ZQQkkYgRSwBOBAsXInolyZuh/2tax+83vu/A+hOYP6P3+265RvMTvw==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.5.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.11.0.tgz", + "integrity": "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/language-server": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.17.1.tgz", + "integrity": "sha512-gG7bvMVNIXgoSe1pM9dI1HsSDLU1EIc/dSAYQ0DuReLyLkOBjsiqvaRYgQltWaldLxh0bNEqjgreLfjd9OnUxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/astro2tsx": "^0.1.0", + "@astrojs/yaml2ts": "^0.2.4", + "@volar/kit": "~2.4.28", + "@volar/language-core": "~2.4.28", + "@volar/language-server": "~2.4.28", + "@volar/language-service": "~2.4.28", + "muggle-string": "^0.4.1", + "tinyglobby": "^0.2.17", + "volar-service-css": "0.0.71", + "volar-service-emmet": "0.0.71", + "volar-service-html": "0.0.71", + "volar-service-prettier": "0.0.71", + "volar-service-typescript": "0.0.71", + "volar-service-typescript-twoslash-queries": "0.0.71", + "volar-service-yaml": "0.0.71", + "vscode-html-languageservice": "^5.6.2", + "vscode-uri": "^3.2.0" + }, + "bin": { + "astro-ls": "bin/nodeServer.js" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "prettier-plugin-astro": ">=0.11.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + } + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.3.1.tgz", + "integrity": "sha512-QCjsjZQl0+Cgb91+T3Ip9hyUe1r5aLm4udi584rML9YO3syupbAYE+jWpAxYg+z4lFLNUugPrylFxZ+8qFTClg==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/prism": "4.0.2", + "@mdx-js/mdx": "^3.1.1", + "acorn": "^8.16.0", + "estree-util-visit": "^2.0.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-html": "^9.0.5", + "hast-util-to-text": "^4.0.2", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "source-map": "^0.7.6", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/markdown-satteri": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.2.tgz", + "integrity": "sha512-g4QehXnB/MzxQWnSf17NciFInMqEJy9948p8cc7zGg/r7995m0Ln5PMr3Xlnj24iey1LchaoZ3SzTOa3D4P/3w==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "satteri": "^0.10.3" + } + }, + "node_modules/@astrojs/mdx": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-8.0.2.tgz", + "integrity": "sha512-WedIVP2Bu8fXSzxb3ZYZ8xDrWWjf2SRKCneh52Llhoa+O3lcpHPhoZVP3FerlivUaH/0LJz2yVaCNvMdg0u8Bw==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/markdown-satteri": "0.4.2", + "es-module-lexer": "^2.0.0" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0", + "@astrojs/markdown-satteri": "^0.4.0", + "astro": "^7.2.10" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz", + "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/starlight": { + "version": "0.42.5", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.5.tgz", + "integrity": "sha512-S6hjUexk9rrKtjJF3WR8T9dM1woNR7X2XU8e3wj4OpRT4X15AXk/mzPjWe/Qd5+P8whHj2+ves+k06qirBzimg==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-satteri": "^0.4.0", + "@astrojs/mdx": "^8.0.0", + "@astrojs/sitemap": "^3.7.3", + "@pagefind/default-ui": "^1.3.0", + "@types/hast": "^3.0.5", + "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", + "astro-expressive-code": "^0.44.0", + "bcp-47": "^2.1.0", + "hast-util-format": "^1.1.0", + "hast-util-select": "^6.0.4", + "hast-util-to-html": "^9.0.5", + "hast-util-to-string": "^3.0.1", + "hastscript": "^9.0.1", + "i18next": "^26.0.7", + "js-yaml": "^4.1.1", + "klona": "^2.0.6", + "magic-string": "^1.2.3", + "mdast-util-directive": "^3.1.0", + "mdast-util-to-markdown": "^2.1.2", + "mdast-util-to-string": "^4.0.0", + "pagefind": "^1.5.2", + "remark-directive": "^4.0.0", + "satteri": "^0.10.3", + "ultrahtml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0", + "astro": "^7.2.10" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", + "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "package-manager-detector": "^1.6.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/yaml2ts": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.4.tgz", + "integrity": "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.3" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.1.tgz", + "integrity": "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@emmetio/abbreviation": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", + "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@emmetio/css-abbreviation": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", + "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@emmetio/css-parser": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz", + "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/stream-reader": "^2.2.0", + "@emmetio/stream-reader-utils": "^0.1.0" + } + }, + "node_modules/@emmetio/html-matcher": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz", + "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@emmetio/scanner": "^1.0.0" + } + }, + "node_modules/@emmetio/scanner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", + "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emmetio/stream-reader": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz", + "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emmetio/stream-reader-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz", + "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@expressive-code/core": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.2.tgz", + "integrity": "sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^6.0.1", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@expressive-code/plugin-frames": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.2.tgz", + "integrity": "sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.2" + } + }, + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.2.tgz", + "integrity": "sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.2", + "shiki": "^4.0.2" + } + }, + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.2.tgz", + "integrity": "sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.2" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", + "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", + "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/default-ui": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.5.2.tgz", + "integrity": "sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==", + "license": "MIT" + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", + "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", + "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", + "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", + "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", + "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.5.0.tgz", + "integrity": "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.5.0.tgz", + "integrity": "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.5.0.tgz", + "integrity": "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.5.0.tgz", + "integrity": "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz", + "integrity": "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.5.0.tgz", + "integrity": "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.5.0.tgz", + "integrity": "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/@volar/kit": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", + "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", + "typesafe-path": "^0.2.2", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/language-server": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz", + "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", + "path-browserify": "^1.0.1", + "request-light": "^0.7.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/language-service": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz", + "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vscode/emmet-helper": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", + "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "emmet": "^2.4.3", + "jsonc-parser": "^2.3.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.15.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vscode/emmet-helper/node_modules/jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-i18n": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", + "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.0-beta.0" + } + }, + "node_modules/am-i-vibing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", + "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "license": "MIT", + "dependencies": { + "process-ancestry": "^0.1.0" + }, + "bin": { + "am-i-vibing": "dist/cli.mjs" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/astro": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.3.5.tgz", + "integrity": "sha512-1p/ernaq/RuESbM5NMjWzbO6SiebMGrGcYlGjMfBhzzgM+n/X7AUmVyw8BqW2QYCdivpsHwJBcD1fqmlTZjZHg==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-rs": "^0.5.0", + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/markdown-satteri": "0.4.2", + "@astrojs/telemetry": "3.3.3", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "am-i-vibing": "^0.4.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^2.0.1", + "devalue": "^5.8.1", + "diff": "^9.0.0", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.28.0", + "find-proc": "0.2.0", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.3.2", + "jsonc-parser": "^3.3.1", + "magic-string": "^1.0.0", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^1.0.1", + "obug": "^3.0.0", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "shiki": "^4.0.2", + "smol-toml": "^1.8.0", + "svgo": "^4.1.0", + "tinyclip": "^1.0.1", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.5", + "unstorage": "^1.17.5", + "verkit": "^0.4.0", + "vite": "^8.0.13", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.5.4" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/astro-expressive-code": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.2.tgz", + "integrity": "sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==", + "license": "MIT", + "dependencies": { + "rehype-expressive-code": "^0.44.2", + "url-extras": "^0.1.0" + }, + "peerDependencies": { + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz", + "integrity": "sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/direction": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", + "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/emmet": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", + "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "./packages/scanner", + "./packages/abbreviation", + "./packages/css-abbreviation", + "./" + ], + "dependencies": { + "@emmetio/abbreviation": "^2.3.3", + "@emmetio/css-abbreviation": "^2.1.8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.1.tgz", + "integrity": "sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expressive-code": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.2.tgz", + "integrity": "sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.2", + "@expressive-code/plugin-frames": "^0.44.2", + "@expressive-code/plugin-shiki": "^0.44.2", + "@expressive-code/plugin-text-markers": "^0.44.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-proc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.2.0.tgz", + "integrity": "sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", + "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "bcp-47-match": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "css-selector-parser": "^3.0.0", + "devlop": "^1.0.0", + "direction": "^2.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "nth-check": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "license": "BSD-2-Clause" + }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", + "integrity": "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", + "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", + "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.5.2", + "@pagefind/darwin-x64": "1.5.2", + "@pagefind/freebsd-x64": "1.5.2", + "@pagefind/linux-arm64": "1.5.2", + "@pagefind/linux-x64": "1.5.2", + "@pagefind/windows-arm64": "1.5.2", + "@pagefind/windows-x64": "1.5.2" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype-expressive-code": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.2.tgz", + "integrity": "sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==", + "license": "MIT", + "dependencies": { + "expressive-code": "^0.44.2" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.3.tgz", + "integrity": "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/request-light": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shiki": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.5.0.tgz", + "integrity": "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.5.0", + "@shikijs/engine-javascript": "4.5.0", + "@shikijs/engine-oniguruma": "4.5.0", + "@shikijs/langs": "4.5.0", + "@shikijs/themes": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/svgo": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^6.0.0", + "css-tree": "^3.0.1", + "css-what": "^7.0.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "1.6.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-1.0.3.tgz", + "integrity": "sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/typesafe-path": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", + "integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-auto-import-cache": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", + "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.8" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ohash": "^2.0.11", + "undici": "^8.0.0" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/url-extras": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", + "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/verkit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.1.tgz", + "integrity": "sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==", + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/volar-service-css": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz", + "integrity": "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-css-languageservice": "^6.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-emmet": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.71.tgz", + "integrity": "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/css-parser": "^0.4.1", + "@emmetio/html-matcher": "^1.3.0", + "@vscode/emmet-helper": "^2.9.3", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-html": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.71.tgz", + "integrity": "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-html-languageservice": "^5.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-prettier": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.71.tgz", + "integrity": "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0", + "prettier": "^2.2 || ^3.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + }, + "prettier": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.71.tgz", + "integrity": "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-browserify": "^1.0.1", + "semver": "^7.6.2", + "typescript-auto-import-cache": "^0.3.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-nls": "^5.2.0", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript-twoslash-queries": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.71.tgz", + "integrity": "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-yaml": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.71.tgz", + "integrity": "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8", + "yaml-language-server": "~1.23.0" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-html-languageservice": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz", + "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonc-parser": "^3.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.16.0", + "vscode-nls": "^5.0.0", + "vscode-uri": "^3.0.2" + }, + "engines": { + "npm": ">=7.0.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.3.tgz", + "integrity": "sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.4.tgz", + "integrity": "sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.3", + "vscode-languageserver-types": "3.18.4" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.15.tgz", + "integrity": "sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.4.tgz", + "integrity": "sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-language-server": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.23.0.tgz", + "integrity": "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-i18n": "^4.2.0", + "prettier": "^3.8.1", + "request-light": "^0.5.7", + "vscode-json-languageservice": "4.1.8", + "vscode-languageserver": "^9.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.16.0", + "vscode-uri": "^3.0.2", + "yaml": "2.8.3" + }, + "bin": { + "yaml-language-server": "bin/yaml-language-server" + } + }, + "node_modules/yaml-language-server/node_modules/request-light": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz", + "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml-language-server/node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000000..b31b8cea34 --- /dev/null +++ b/website/package.json @@ -0,0 +1,30 @@ +{ + "name": "benchbox-website", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build && pagefind --site dist --glob \"{docs,blog}/**/*.html\"", + "preview": "astro preview", + "check": "astro check", + "audit:high": "node scripts/audit-high.mjs", + "serve": "node scripts/serve-dist.mjs", + "verify": "node e2e/verify.mjs" + }, + "dependencies": { + "@astrojs/markdown-remark": "7.3.1", + "@astrojs/starlight": "0.42.5", + "astro": "7.3.5", + "pagefind": "1.5.2" + }, + "devDependencies": { + "@astrojs/check": "0.9.10", + "@axe-core/playwright": "4.13.0", + "@playwright/test": "1.63.0", + "typescript": "6.0.3" + } +} diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000000..0eba161634 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1 @@ +B diff --git a/website/scripts/audit-high-allowlist.json b/website/scripts/audit-high-allowlist.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/website/scripts/audit-high-allowlist.json @@ -0,0 +1 @@ +[] diff --git a/website/scripts/audit-high.mjs b/website/scripts/audit-high.mjs new file mode 100644 index 0000000000..d1df7704d0 --- /dev/null +++ b/website/scripts/audit-high.mjs @@ -0,0 +1,23 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { main } from "../../results-explorer/scripts/audit-high.mjs"; + +const websiteRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); + +function runNpmAudit() { + const result = spawnSync("npm", ["audit", "--json", "--audit-level=high"], { + cwd: websiteRoot, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (result.error) throw result.error; + return JSON.parse(result.stdout); +} + +function readAllowlist() { + return JSON.parse(readFileSync(resolve(websiteRoot, "scripts", "audit-high-allowlist.json"), "utf8")); +} + +process.exitCode = main({ audit: runNpmAudit, allowlist: readAllowlist }); diff --git a/website/scripts/rst_to_html.py b/website/scripts/rst_to_html.py new file mode 100644 index 0000000000..7c54b54dfe --- /dev/null +++ b/website/scripts/rst_to_html.py @@ -0,0 +1,90 @@ +import json +import re +import sys +from pathlib import Path + +from docutils import nodes +from docutils.core import publish_parts +from docutils.parsers.rst import Directive, directives, roles + +DOCS_ROOT = Path(__file__).resolve().parents[2] / "docs" + + +def target_title(docname: str) -> str: + for suffix in (".md", ".rst"): + candidate = DOCS_ROOT / f"{docname}{suffix}" + if candidate.is_file(): + for line in candidate.read_text(encoding="utf-8").splitlines(): + if line.startswith("# "): + return line[2:].strip() + lines = candidate.read_text(encoding="utf-8").splitlines() + for index, line in enumerate(lines[1:], start=1): + if line and set(line) <= set("=-~") and len(line) >= len(lines[index - 1].strip()) > 0: + return lines[index - 1].strip() + return docname + + +def resolve(current: Path, docname: str) -> str: + base = current.parent.relative_to(DOCS_ROOT) + resolved = (DOCS_ROOT / base / docname).resolve().relative_to(DOCS_ROOT) + return resolved.as_posix() + + +class Toctree(Directive): + has_content = True + option_spec = {"maxdepth": directives.nonnegative_int, "caption": directives.unchanged, "hidden": directives.flag} + + def run(self): + source = Path(self.state.document["source"]) + items = nodes.bullet_list(classes=["toctree"]) + for entry in self.content: + name = entry.strip() + if not name or name.startswith(":"): + continue + target = resolve(source, name) + link = nodes.reference("", target_title(target), refuri=f"/docs/{target}.html") + items += nodes.list_item("", nodes.paragraph("", "", link)) + return [items] + + +class Tags(Directive): + has_content = False + optional_arguments = 1 + final_argument_whitespace = True + + def run(self): + return [] + + +def doc_role(name, rawtext, text, lineno, inliner, options=None, content=None): + match = re.match(r"^(.*?)\s*<([^>]+)>$", text, re.S) + label, docname = (match.group(1), match.group(2)) if match else (None, text) + source = Path(inliner.document["source"]) + target = resolve(source, docname) + return [nodes.reference(rawtext, label or target_title(target), refuri=f"/docs/{target}.html")], [] + + +directives.register_directive("toctree", Toctree) +directives.register_directive("tags", Tags) +roles.register_local_role("doc", doc_role) + +source_path = Path(sys.argv[1]).resolve() +parts = publish_parts( + source=source_path.read_text(encoding="utf-8"), + source_path=str(source_path), + writer_name="html5", + settings_overrides={ + "initial_header_level": 2, + "doctitle_xform": True, + "sectsubtitle_xform": False, + "report_level": 4, + "halt_level": 5, + "syntax_highlight": "none", + }, +) +body = parts["body"] +headings = [ + {"depth": int(m.group(2)), "slug": m.group(1), "text": re.sub(r"<[^>]+>", "", m.group(3)).strip()} + for m in re.finditer(r'
\s*(.*?)', body, re.S) +] +json.dump({"title": parts["title"], "html": body, "headings": headings}, sys.stdout) diff --git a/website/scripts/serve-dist.mjs b/website/scripts/serve-dist.mjs new file mode 100644 index 0000000000..320e3647d6 --- /dev/null +++ b/website/scripts/serve-dist.mjs @@ -0,0 +1,44 @@ +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "dist"); +const port = Number(process.env.PORT ?? 4330); + +const types = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".wasm": "application/wasm", + ".svg": "image/svg+xml", + ".png": "image/png", + ".xml": "application/xml", + ".duckdb": "application/octet-stream", + ".pf_meta": "application/octet-stream", + ".pf_fragment": "application/octet-stream", + ".pf_index": "application/octet-stream", + ".pagefind": "application/octet-stream", +}; + +function resolve(urlPath) { + const clean = path.normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, ""); + const candidates = [clean, path.join(clean, "index.html"), `${clean}.html`]; + for (const candidate of candidates) { + const full = path.join(root, candidate); + if (full.startsWith(root) && existsSync(full) && statSync(full).isFile()) return full; + } + return undefined; +} + +createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + const file = resolve(url.pathname); + const target = file ?? path.join(root, "404.html"); + response.writeHead(file ? 200 : 404, { + "content-type": types[path.extname(target)] ?? "application/octet-stream", + }); + createReadStream(target).pipe(response); +}).listen(port, "127.0.0.1"); diff --git a/website/spike-content/additional-utilities.md b/website/spike-content/additional-utilities.md new file mode 100644 index 0000000000..e381d92a9b --- /dev/null +++ b/website/spike-content/additional-utilities.md @@ -0,0 +1,46 @@ +# Additional Utilities + +Authored contract page for the API reference, written to the page template in the API reference contract decision. + +## `benchbox.utils.scale_factor.format_scale_factor` + + + +Returns the scale-factor token that BenchBox uses in file, directory and schema +names. + +**Import:** `from benchbox.utils.scale_factor import format_scale_factor` · **Extras:** none + +### Parameters + +| Name | Type | Default | Meaning | +| --- | --- | --- | --- | +| `scale_factor` | `float` | required | The benchmark scale factor. | + +### Returns + +`str`: `sf` followed by digits. + +- **Values of 1 or more:** the value without its decimal point (`10` → `sf10`, + `1.5` → `sf15`). +- **Values below 1:** `0` followed by the decimal digits (`0.1` → `sf01`, + `0.01` → `sf001`). +- **Zero, negative integers, NaN and values below 1e-10:** return `sf0`. +- **Negative fractions:** return the same token as their absolute value + (`-0.5` → `sf05`). + +The token is not unique: `1.5` and `15` both return `sf15`. + +### Raises + +`OverflowError` for infinity. + +### Example + +```python +from benchbox.utils.scale_factor import format_scale_factor + +assert format_scale_factor(1) == "sf1" +assert format_scale_factor(0.01) == "sf001" +assert format_scale_factor(2.25) == "sf225" +``` diff --git a/website/src/components/SearchBox.astro b/website/src/components/SearchBox.astro new file mode 100644 index 0000000000..850f1ebc81 --- /dev/null +++ b/website/src/components/SearchBox.astro @@ -0,0 +1,49 @@ +--- +--- + + + +
+
+
+ + diff --git a/website/src/components/SiteHeader.astro b/website/src/components/SiteHeader.astro new file mode 100644 index 0000000000..1c02e231e4 --- /dev/null +++ b/website/src/components/SiteHeader.astro @@ -0,0 +1,35 @@ +--- +import SearchBox from "./SearchBox.astro"; +import ThemeToggle from "./ThemeToggle.astro"; + +interface Props { + wrapper?: "header" | "div"; +} + +const { wrapper: Wrapper = "header" } = Astro.props; +const path = Astro.url.pathname; +const links = [ + { href: "/docs/", label: "Docs", prefix: "/docs" }, + { href: "/blog/", label: "Blog", prefix: "/blog" }, + { href: "/results/", label: "Results", prefix: "/results" }, +]; +--- + + +
+ + +
+ + +
+
+
diff --git a/website/src/components/ThemeScript.astro b/website/src/components/ThemeScript.astro new file mode 100644 index 0000000000..24ccb3fde1 --- /dev/null +++ b/website/src/components/ThemeScript.astro @@ -0,0 +1,15 @@ + diff --git a/website/src/components/ThemeToggle.astro b/website/src/components/ThemeToggle.astro new file mode 100644 index 0000000000..a7d3270cff --- /dev/null +++ b/website/src/components/ThemeToggle.astro @@ -0,0 +1,77 @@ +--- +const options = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +]; +--- + +
+ { + options.map((option) => ( + + )) + } +
+ + diff --git a/website/src/components/starlight/Empty.astro b/website/src/components/starlight/Empty.astro new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/website/src/components/starlight/Empty.astro @@ -0,0 +1,2 @@ +--- +--- diff --git a/website/src/components/starlight/Header.astro b/website/src/components/starlight/Header.astro new file mode 100644 index 0000000000..85b24cdec8 --- /dev/null +++ b/website/src/components/starlight/Header.astro @@ -0,0 +1,5 @@ +--- +import SiteHeader from "../SiteHeader.astro"; +--- + + diff --git a/website/src/components/starlight/ThemeProvider.astro b/website/src/components/starlight/ThemeProvider.astro new file mode 100644 index 0000000000..fe703c5ac8 --- /dev/null +++ b/website/src/components/starlight/ThemeProvider.astro @@ -0,0 +1,5 @@ +--- +import ThemeScript from "../ThemeScript.astro"; +--- + + diff --git a/website/src/content.config.ts b/website/src/content.config.ts new file mode 100644 index 0000000000..32551ac5d7 --- /dev/null +++ b/website/src/content.config.ts @@ -0,0 +1,32 @@ +import { docsSchema } from "@astrojs/starlight/schema"; +import { z } from "astro/zod"; +import { defineCollection } from "astro:content"; +import { docsLoader } from "./loaders/docs-loader.ts"; + +const docs = defineCollection({ + loader: docsLoader([ + { id: "docs/usage/getting-started", file: "docs/usage/getting-started.md" }, + { id: "docs/benchmarks/industry-benchmarks", file: "docs/benchmarks/industry-benchmarks.md" }, + { id: "docs/benchmarks/queries/tpch/q1", file: "docs/benchmarks/queries/tpch/q1.md" }, + { + id: "docs/reference/python-api/additional-utilities", + file: "website/spike-content/additional-utilities.md", + summary: "Authored contract for format_scale_factor, written to the API reference page template.", + }, + ]), + schema: docsSchema({ extend: z.object({ sourcePath: z.string().optional() }) }), +}); + +const blog = defineCollection({ + loader: docsLoader([{ id: "blog/2026-05-18-v0-3-0-release-overview", file: "docs/blog/2026-05-18-v0-3-0-release-overview.md" }]), + schema: z.looseObject({ + title: z.string(), + date: z.string(), + author: z.string().optional(), + tags: z.string().optional(), + description: z.string().optional(), + sourcePath: z.string().optional(), + }), +}); + +export const collections = { docs, blog }; diff --git a/website/src/layouts/Shell.astro b/website/src/layouts/Shell.astro new file mode 100644 index 0000000000..b70db77f34 --- /dev/null +++ b/website/src/layouts/Shell.astro @@ -0,0 +1,37 @@ +--- +import "../styles/tokens.css"; +import "../styles/shell.css"; +import "../styles/starlight-map.css"; +import SiteHeader from "../components/SiteHeader.astro"; +import ThemeScript from "../components/ThemeScript.astro"; + +interface Props { + title: string; + description?: string; + searchable?: boolean; +} + +const { title, description, searchable = false } = Astro.props; +--- + + + + + + + {title} + {description && } + + + + + + +
+ +
+
+

BenchBox is open source under the MIT License.

+
+ + diff --git a/website/src/lib/docutils-slug.ts b/website/src/lib/docutils-slug.ts new file mode 100644 index 0000000000..91b7925560 --- /dev/null +++ b/website/src/lib/docutils-slug.ts @@ -0,0 +1,9 @@ +export function docutilsSlug(text: string): string { + const slug = text + .normalize("NFKD") + .replace(/[^\x00-\x7f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^[-0-9]+|-+$/g, ""); + return slug || "id"; +} diff --git a/website/src/loaders/docs-loader.ts b/website/src/loaders/docs-loader.ts new file mode 100644 index 0000000000..d0eeab7177 --- /dev/null +++ b/website/src/loaders/docs-loader.ts @@ -0,0 +1,87 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import type { Loader } from "astro/loaders"; +import { docutilsSlug } from "../lib/docutils-slug.ts"; + +const REPO_ROOT = path.resolve(process.cwd(), ".."); + +export type DocSource = { id: string; file: string; summary?: string }; + +type Frontmatter = Record; + +function splitFrontmatter(raw: string): { data: Frontmatter; body: string } { + const match = raw.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) return { data: {}, body: raw }; + const data: Frontmatter = {}; + for (const line of match[1].split("\n")) { + const pair = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); + if (pair) data[pair[1]] = pair[2].replace(/^"(.*)"$/, "$1"); + } + return { data, body: raw.slice(match[0].length) }; +} + +function splitTitle(body: string): { title: string; body: string } { + const lines = body.split("\n"); + const index = lines.findIndex((line) => /^# /.test(line)); + if (index < 0) return { title: "Untitled", body }; + const title = lines[index].slice(2).trim(); + lines.splice(index, 1); + return { title, body: lines.join("\n") }; +} + +function rstToHtml(file: string) { + const out = execFileSync("uv", ["run", "python", path.join(process.cwd(), "scripts", "rst_to_html.py"), file], { + cwd: REPO_ROOT, + maxBuffer: 64 * 1024 * 1024, + }); + return JSON.parse(out.toString("utf-8")) as { + title: string; + html: string; + headings: { depth: number; slug: string; text: string }[]; + }; +} + +export function docsLoader(sources: DocSource[]): Loader { + return { + name: "benchbox-docs-loader", + async load({ store, renderMarkdown, parseData, watcher }) { + store.clear(); + for (const source of sources) { + const file = path.isAbsolute(source.file) ? source.file : path.join(REPO_ROOT, source.file); + watcher?.add(file); + if (file.endsWith(".rst")) { + const converted = rstToHtml(file); + const data = await parseData({ + id: source.id, + data: { title: converted.title, description: source.summary, sourcePath: path.relative(REPO_ROOT, file) }, + }); + store.set({ + id: source.id, + data, + rendered: { + html: `\n${converted.html}`, + metadata: { headings: converted.headings }, + }, + }); + continue; + } + const raw = readFileSync(file, "utf-8"); + const front = splitFrontmatter(raw); + const { title, body } = splitTitle(front.body); + const data = await parseData({ + id: source.id, + data: { + ...front.data, + title, + description: front.data.meta_description ?? source.summary, + sourcePath: path.relative(REPO_ROOT, file), + }, + }); + const alias = `\n\n`; + const rendered = await renderMarkdown(alias + body, { fileURL: new URL(`file://${file}`) }); + store.set({ id: source.id, data, body, rendered }); + } + }, + }; +} diff --git a/website/src/pages/404.astro b/website/src/pages/404.astro new file mode 100644 index 0000000000..e87bc70817 --- /dev/null +++ b/website/src/pages/404.astro @@ -0,0 +1,19 @@ +--- +import Shell from "../layouts/Shell.astro"; +--- + + + +
+

Page not found

+

Return to the BenchBox home page.

+
+
diff --git a/website/src/pages/blog/[...slug].astro b/website/src/pages/blog/[...slug].astro new file mode 100644 index 0000000000..baa4cd1e4a --- /dev/null +++ b/website/src/pages/blog/[...slug].astro @@ -0,0 +1,21 @@ +--- +import { getCollection, render } from "astro:content"; +import Shell from "../../layouts/Shell.astro"; +import { docutilsSlug } from "../../lib/docutils-slug.ts"; + +export async function getStaticPaths() { + const posts = await getCollection("blog"); + return posts.map((post) => ({ params: { slug: post.id.replace(/^blog\//, "") }, props: { post } })); +} + +const { post } = Astro.props; +const { Content } = await render(post); +--- + + +
+

{post.data.title}

+ + +
+
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro new file mode 100644 index 0000000000..19ac42c380 --- /dev/null +++ b/website/src/pages/index.astro @@ -0,0 +1,60 @@ +--- +import Shell from "../layouts/Shell.astro"; +--- + + +
+
+

Run Your Own Benchmarks

+

+ Everyone says you should. BenchBox makes it easy to do. Use as CLI, Python library, or MCP for AI assistants. No + Docker. No compilers. Just pip install. +

+
+
+
+
+ CLI, Library, or MCP + +
+
# CLI - Quick benchmarking
+benchbox run --platform duckdb --benchmark tpch
+
+# MCP - AI assistant integration
+"Run TPC-H on DuckDB" # Claude executes via MCP
+
+# Library - Deep Python integration
+from benchbox import TPCH
+
+ +
+
+ BenchBox - Database Benchmarking Toolkit +
+
+
+
+
+ + diff --git a/website/src/plugins/myst-lite.ts b/website/src/plugins/myst-lite.ts new file mode 100644 index 0000000000..802a1f37c4 --- /dev/null +++ b/website/src/plugins/myst-lite.ts @@ -0,0 +1,225 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { docutilsSlug } from "../lib/docutils-slug.ts"; + +const DOCS_ROOT = path.resolve(process.cwd(), "..", "docs"); + +type MdNode = { + type: string; + value?: string; + depth?: number; + data?: { hProperties?: Record; hName?: string }; + lang?: string | null; + url?: string; + align?: (string | null)[]; + children?: MdNode[]; +}; + +type Parse = (source: string) => MdNode; + +const DROPPED_DIRECTIVES = new Set(["tags", "toctree"]); + +function routeFor(file: string): string { + const relative = path.relative(DOCS_ROOT, file).split(path.sep).join("/"); + const stem = relative.replace(/\.(md|rst)$/, ""); + return stem.startsWith("blog/") ? `/${stem}.html` : `/docs/${stem}.html`; +} + +function rewrite(url: string, file: string): string { + if (/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(url)) return url; + const [target, fragment] = url.split("#"); + const absolute = path.resolve(path.dirname(file), target); + if (/\.md$/.test(target)) return routeFor(absolute) + (fragment ? `#${fragment}` : ""); + if (/\.(png|jpe?g|gif|svg|webp)$/i.test(target)) return `/_images/${path.basename(target)}`; + return url; +} + +function textOf(node: MdNode): string { + return node.value ?? (node.children ?? []).map(textOf).join(""); +} + +function titleOf(file: string): string { + const match = readFileSync(file, "utf-8").match(/^# (.+)$/m); + if (!match) throw new Error(`myst-lite: no H1 title in ${file}`); + return match[1].trim(); +} + +function docLink(content: string, file: string): MdNode { + const labelled = content.match(/^(.*\S)\s*<([^<>]+)>$/s); + const target = (labelled ? labelled[2] : content).trim(); + const base = target.startsWith("/") ? DOCS_ROOT : path.dirname(file); + const absolute = path.resolve(base, target.replace(/^\//, "").replace(/\.md$/, "") + ".md"); + return { + type: "link", + url: routeFor(absolute), + children: [{ type: "text", value: labelled ? labelled[1].trim() : titleOf(absolute) }], + }; +} + +function expandRoles(children: MdNode[], file: string): MdNode[] { + const out: MdNode[] = []; + for (let i = 0; i < children.length; i += 1) { + const node = children[i]; + const next = children[i + 1]; + const role = node.type === "text" ? node.value?.match(/\{([a-z:-]+)\}$/) : null; + if (!role || next?.type !== "inlineCode") { + out.push(node); + continue; + } + if (role[1] !== "doc") throw new Error(`myst-lite: unsupported role {${role[1]}} in ${file}`); + const before = (node.value ?? "").slice(0, role.index); + if (before) out.push({ type: "text", value: before }); + out.push(docLink(next.value ?? "", file)); + i += 1; + } + return out; +} + +function parseListTable(body: string, parse: Parse, file: string): MdNode { + const lines = body.split("\n"); + const options = new Map(); + let index = 0; + for (; index < lines.length && /^:[a-z-]+:/.test(lines[index]); index += 1) { + const option = lines[index].match(/^:([a-z-]+):\s*(.*)$/); + if (option) options.set(option[1], option[2].trim()); + } + for (const key of options.keys()) { + if (key !== "header-rows" && key !== "widths") throw new Error(`myst-lite: unsupported list-table option :${key}: in ${file}`); + } + const headerRows = Number(options.get("header-rows") ?? "0"); + if (headerRows !== 0 && headerRows !== 1) throw new Error(`myst-lite: unsupported :header-rows: ${headerRows} in ${file}`); + const rows: string[][] = []; + for (const line of lines.slice(index)) { + if (!line.trim()) continue; + const row = line.match(/^\* - (.*)$/); + const cell = line.match(/^ {2}- (.*)$/); + if (row) rows.push([row[1]]); + else if (cell && rows.length) rows[rows.length - 1].push(cell[1]); + else throw new Error(`myst-lite: unsupported list-table line "${line}" in ${file}`); + } + if (!rows.length || rows.some((row) => row.length !== rows[0].length)) { + throw new Error(`myst-lite: list-table rows are empty or ragged in ${file}`); + } + const tableRows = rows.map((row) => ({ + type: "tableRow", + children: row.map((cell) => ({ type: "tableCell", children: parse(cell).children?.[0]?.children ?? [] })), + })); + return { type: "table", align: rows[0].map(() => null), children: tableRows }; +} + +function transformDirectives(children: MdNode[], parse: Parse, file: string): MdNode[] { + const out: MdNode[] = []; + for (const child of children) { + const name = child.type === "code" ? (child.lang ?? "").match(/^\{([a-z-]+)\}/)?.[1] : undefined; + if (!name) out.push(child); + else if (name === "list-table") out.push(parseListTable(child.value ?? "", parse, file)); + else if (!DROPPED_DIRECTIVES.has(name)) throw new Error(`myst-lite: unsupported directive {${name}} in ${file}`); + } + return out; +} + +function enablesDeflist(file: string): boolean { + const front = readFileSync(file, "utf-8").match(/^---\n([\s\S]*?)\n---\n/); + return !!front && /^myst:\s*\n\s+enable_extensions:\s*\n(\s+-\s+\S+\n)*?\s+-\s+deflist\s*$/m.test(front[1] + "\n"); +} + +function splitLines(children: MdNode[]): MdNode[][] { + const lines: MdNode[][] = [[]]; + for (const child of children) { + if (child.type !== "text") { + lines[lines.length - 1].push(child); + continue; + } + (child.value ?? "").split("\n").forEach((part, index) => { + if (index > 0) lines.push([]); + if (part) lines[lines.length - 1].push({ type: "text", value: part }); + }); + } + return lines; +} + +function trimLine(line: MdNode[]): MdNode[] { + const out = line.map((node) => ({ ...node })); + const first = out[0]; + const last = out[out.length - 1]; + if (first?.type === "text") first.value = (first.value ?? "").trimStart(); + if (last?.type === "text") last.value = (last.value ?? "").trimEnd(); + return out.filter((node) => node.type !== "text" || node.value); +} + +function isDefinitionLine(line: MdNode[]): boolean { + return line[0]?.type === "text" && /^: /.test(line[0].value ?? ""); +} + +function definitionNode(lines: MdNode[][]): MdNode { + const inline: MdNode[] = []; + lines.forEach((line, index) => { + if (index > 0) inline.push({ type: "text", value: "\n" }); + inline.push(...line); + }); + return { + type: "deflistDescription", + data: { hName: "dd" }, + children: [{ type: "paragraph", children: inline }], + }; +} + +function transformDeflists(children: MdNode[], file: string): MdNode[] { + const out: MdNode[] = []; + for (const child of children) { + const lines = child.type === "paragraph" ? splitLines(child.children ?? []) : []; + if (!lines.some(isDefinitionLine)) { + out.push(child); + continue; + } + const previous = out[out.length - 1]; + const firstDefinition = lines.findIndex(isDefinitionLine); + if (firstDefinition === 0 && previous?.type !== "deflist") { + throw new Error(`myst-lite: definition ": ${textOf(child).slice(2, 42)}" has no preceding term in ${file}`); + } + const list: MdNode = previous?.type === "deflist" ? previous : { type: "deflist", data: { hName: "dl" }, children: [] }; + if (list !== previous) out.push(list); + const items = list.children ?? []; + lines.slice(0, firstDefinition).forEach((line) => { + items.push({ type: "deflistTerm", data: { hName: "dt" }, children: trimLine(line) }); + }); + let current: MdNode[][] = []; + const flush = () => { + if (current.length) items.push(definitionNode(current)); + current = []; + }; + for (const line of lines.slice(firstDefinition)) { + if (isDefinitionLine(line)) { + flush(); + const stripped = line.map((node) => ({ ...node })); + stripped[0].value = (stripped[0].value ?? "").slice(2); + current.push(trimLine(stripped)); + } else { + current.push(trimLine(line)); + } + } + flush(); + list.children = items; + } + return out; +} + +type WalkContext = { file: string; parse: Parse; deflist: boolean }; + +function walk(node: MdNode, context: WalkContext): void { + const { file, parse } = context; + if (node.children) { + const directives = transformDirectives(node.children, parse, file); + node.children = expandRoles(context.deflist ? transformDeflists(directives, file) : directives, file); + node.children.forEach((child) => walk(child, context)); + } + if (node.type === "heading") node.data = { hProperties: { id: docutilsSlug(textOf(node)) } }; + if ((node.type === "link" || node.type === "image") && node.url) node.url = rewrite(node.url, file); +} + +export function mystLite(this: { parse: Parse }) { + const parse = this.parse.bind(this); + return (tree: unknown, vfile: { path?: string }) => { + if (vfile.path) walk(tree as MdNode, { file: vfile.path, parse, deflist: enablesDeflist(vfile.path) }); + }; +} diff --git a/website/src/styles/shell.css b/website/src/styles/shell.css new file mode 100644 index 0000000000..933dd42184 --- /dev/null +++ b/website/src/styles/shell.css @@ -0,0 +1,360 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body.shell { + margin: 0; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + line-height: 1.6; +} + +:where(.shell) a { + color: var(--accent-primary); +} + +.site-header { + position: sticky; + top: 0; + z-index: 1000; + height: var(--header-height); + background: var(--header-bg); + border-bottom: 1px solid var(--border-primary); + backdrop-filter: blur(10px); +} + +.site-header__inner { + display: flex; + align-items: center; + gap: 1.5rem; + max-width: 1200px; + height: 100%; + margin: 0 auto; + padding: 0 1rem; +} + +.site-header__logo { + color: var(--accent-primary); + font-family: var(--font-mono); + font-size: 1.25rem; + font-weight: 700; + text-decoration: none; +} + +.site-header__nav { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + margin-right: auto; +} + +.site-header__link { + color: var(--text-secondary); + font-size: 0.9375rem; + font-weight: 500; + text-decoration: none; +} + +.site-header__link:hover, +.site-header__link[aria-current="page"] { + color: var(--text-primary); +} + +.site-header__tools { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.search-button, +.theme-toggle button, +.search-close { + border: 1px solid var(--border-primary); + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 0.8125rem; + padding: 0.3rem 0.6rem; +} + +.search-button:hover, +.theme-toggle button:hover { + color: var(--text-primary); +} + +.theme-toggle { + display: inline-flex; +} + +.theme-toggle button { + border-radius: 0; + margin-left: -1px; +} + +.theme-toggle button:first-child { + border-radius: 6px 0 0 6px; + margin-left: 0; +} + +.theme-toggle button:last-child { + border-radius: 0 6px 6px 0; +} + +.theme-toggle button[aria-checked="true"] { + background: var(--accent-secondary); + border-color: var(--accent-secondary); + color: #ffffff; +} + +:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +.search-dialog { + width: min(40rem, calc(100vw - 2rem)); + max-height: 80vh; + overflow: auto; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + --pagefind-ui-scale: 0.9; + --pagefind-ui-primary: var(--accent-primary); + --pagefind-ui-text: var(--text-primary); + --pagefind-ui-background: var(--bg-secondary); + --pagefind-ui-border: var(--border-primary); + --pagefind-ui-tag: var(--bg-tertiary); +} + +.search-dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} + +.search-dialog form { + display: flex; + justify-content: flex-end; + margin: 0 0 0.5rem; +} + +.site-footer { + border-top: 1px solid var(--border-primary); + color: var(--text-secondary); + font-size: 0.875rem; + padding: 2rem 1rem; + text-align: center; +} + +.hero { + position: relative; + padding: 4rem 1rem; + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); + overflow: hidden; +} + +.hero::before { + content: ""; + position: absolute; + inset: 0; + background-image: + radial-gradient(circle at 25% 25%, var(--hero-glow-primary) 0%, transparent 25%), + radial-gradient(circle at 75% 75%, var(--hero-glow-secondary) 0%, transparent 25%); + pointer-events: none; +} + +.hero__inner { + position: relative; + max-width: 1200px; + margin: 0 auto; +} + +.hero__title { + margin: 0 0 0.5rem; + background: linear-gradient(135deg, var(--text-primary) 0%, var(--accent-primary) 100%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-size: clamp(2rem, 4vw, 3rem); + font-weight: 800; + line-height: 1.2; +} + +.hero__subtitle { + max-width: 48rem; + margin: 0 0 2rem; + color: var(--text-secondary); +} + +.hero__layout { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 3rem; + align-items: center; +} + +.hero__image { + display: block; + width: 100%; + max-width: 300px; + height: auto; + margin: 0 auto; + filter: drop-shadow(var(--card-shadow)); +} + +.code-block { + max-width: 600px; + overflow: hidden; + border: 1px solid var(--code-border); + border-radius: 8px; + background: var(--code-bg); + color: var(--code-fg); +} + +.code-block__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--code-border); + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 0.875rem; +} + +.code-block pre { + margin: 0; + padding: 1rem; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.5; +} + +.hero__badges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.5rem; +} + +.badge { + padding: 0.3rem 0.75rem; + border: 1px solid var(--border-primary); + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.8125rem; + text-decoration: none; +} + +.prose { + max-width: 48rem; + margin: 0 auto; + padding: 2rem 1rem 4rem; +} + +.prose h1 { + line-height: 1.2; +} + +.prose h2, +.prose h3 { + margin-top: 2rem; +} + +.prose img { + max-width: 100%; + height: auto; +} + +.prose table { + width: 100%; + border-collapse: collapse; + overflow-wrap: anywhere; +} + +.prose th, +.prose td { + padding: 0.4rem 0.75rem; + border: 1px solid var(--border-primary); + text-align: left; +} + +.prose th { + background: var(--table-header-bg); +} + +.prose :not(pre) > code { + padding: 0.1rem 0.3rem; + border-radius: 4px; + background: var(--bg-tertiary); + font-family: var(--font-mono); + font-size: 0.875em; +} + +.post-meta { + color: var(--text-secondary); + font-size: 0.875rem; +} + +@media (max-width: 50rem) { + .hero__layout { + grid-template-columns: 1fr; + } + + .theme-toggle button { + padding: 0.3rem 0.4rem; + } + + .site-header__inner { + gap: 0.75rem; + } + + .site-header__nav { + gap: 0.75rem; + } +} + +@media (max-width: 40rem) { + :root { + --header-height: 6rem; + } + + .site-header__inner { + flex-wrap: wrap; + align-content: center; + gap: 0.25rem 0.75rem; + } + + .site-header__logo { + font-size: 1rem; + } + + .site-header__nav { + gap: 0.625rem; + margin-right: 0; + } + + .site-header__link { + display: inline-flex; + align-items: center; + min-height: 1.75rem; + font-size: 0.875rem; + } + + .site-header__tools { + flex: 1 0 100%; + justify-content: space-between; + gap: 0.5rem; + } + + .search-button, + .theme-toggle button { + min-height: 1.75rem; + } +} diff --git a/website/src/styles/starlight-map.css b/website/src/styles/starlight-map.css new file mode 100644 index 0000000000..bcb009d850 --- /dev/null +++ b/website/src/styles/starlight-map.css @@ -0,0 +1,62 @@ +:root { + --sl-font: var(--font-sans); + --sl-font-mono: var(--font-mono); + --sl-nav-height: var(--header-height); + --sl-color-accent: var(--accent-secondary); + --sl-color-accent-high: var(--accent-primary); + --sl-color-accent-low: var(--bg-tertiary); + --sl-color-bg: var(--bg-primary); + --sl-color-bg-nav: var(--header-bg); + --sl-color-bg-sidebar: var(--bg-primary); + --sl-color-hairline: var(--border-primary); + --sl-color-hairline-light: var(--border-primary); + --sl-color-hairline-shade: var(--border-primary); + --sl-color-text: var(--text-secondary); + --sl-color-text-accent: var(--accent-primary); + --sl-color-white: var(--text-primary); + --sl-color-black: var(--bg-primary); + --sl-color-gray-1: var(--text-primary); + --sl-color-gray-2: var(--text-secondary); + --sl-color-gray-3: var(--text-secondary); + --sl-color-gray-4: var(--text-muted); + --sl-color-gray-5: var(--border-primary); + --sl-color-gray-6: var(--bg-tertiary); + --sl-color-gray-7: var(--bg-secondary); +} + +.sl-markdown-content :not(pre) > code { + color: var(--text-primary); +} + +.sl-markdown-content table.toctree, +.sl-markdown-content ul.toctree { + list-style: disc; +} + +.page > header .site-header { + position: static; + width: 100%; + height: 100%; + border: 0; + background: none; + backdrop-filter: none; +} + +.page > header .site-header__inner { + max-width: none; + padding: 0; +} + +.sl-markdown-content ul.simple > li > p { + margin: 0; +} + +.sl-markdown-content table { + display: table; + width: 100%; +} + +.sl-markdown-content table, +.sl-markdown-content table * { + overflow-wrap: anywhere; +} diff --git a/website/src/styles/tokens.css b/website/src/styles/tokens.css new file mode 100644 index 0000000000..ef85c51323 --- /dev/null +++ b/website/src/styles/tokens.css @@ -0,0 +1,43 @@ +:root { + color-scheme: light dark; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + --font-mono: "SF Mono", Monaco, Inconsolata, "Roboto Mono", "Fira Code", monospace; + --bg-primary: #f5f7fb; + --bg-secondary: #ffffff; + --bg-tertiary: #eef2f7; + --text-primary: #0f172a; + --text-secondary: #475569; + --text-muted: #64748b; + --accent-primary: #1d4ed8; + --accent-secondary: #1e40af; + --border-primary: #d8e0ea; + --code-bg: #0f172a; + --code-fg: #f8fafc; + --code-border: #243044; + --table-header-bg: #f1f5f9; + --hero-glow-primary: rgba(37, 99, 235, 0.12); + --hero-glow-secondary: rgba(13, 148, 136, 0.1); + --card-shadow: 0 10px 26px rgba(15, 23, 42, 0.08); + --header-bg: rgba(255, 255, 255, 0.95); + --header-height: 4rem; +} + +:root[data-theme="dark"] { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --text-primary: #f0f6fc; + --text-secondary: #a8b3bf; + --text-muted: #8b949e; + --accent-primary: #58a6ff; + --accent-secondary: #1f6feb; + --border-primary: #30363d; + --code-bg: #161b22; + --code-fg: #f0f6fc; + --code-border: #30363d; + --table-header-bg: #1c2128; + --hero-glow-primary: rgba(88, 166, 255, 0.1); + --hero-glow-secondary: rgba(31, 111, 235, 0.1); + --card-shadow: 0 10px 30px rgba(0, 0, 0, 0.26); + --header-bg: rgba(13, 17, 23, 0.95); +} diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000000..1662b9133f --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist", "node_modules"], + "compilerOptions": { "allowImportingTsExtensions": true } +}