sqlquality 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. {sqlquality-0.3.0 → sqlquality-0.4.0}/PKG-INFO +238 -10
  2. {sqlquality-0.3.0 → sqlquality-0.4.0}/README.md +237 -9
  3. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/__init__.py +1 -1
  4. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/cli.py +318 -1
  5. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/models.py +34 -1
  6. sqlquality-0.4.0/src/sqlquality/report.py +1006 -0
  7. sqlquality-0.4.0/src/sqlquality/verify.py +1983 -0
  8. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/base.py +59 -0
  9. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/fingerprint.py +40 -0
  10. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/postgres.py +198 -26
  11. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/redshift.py +142 -0
  12. sqlquality-0.4.0/tests/fixtures/verify/disappeared.after.json +71 -0
  13. sqlquality-0.4.0/tests/fixtures/verify/disappeared.before.json +91 -0
  14. sqlquality-0.4.0/tests/fixtures/verify/improved.after.json +71 -0
  15. sqlquality-0.4.0/tests/fixtures/verify/improved.before.json +91 -0
  16. sqlquality-0.4.0/tests/fixtures/verify/limit_truncated.after.json +59 -0
  17. sqlquality-0.4.0/tests/fixtures/verify/limit_truncated.before.json +91 -0
  18. sqlquality-0.4.0/tests/fixtures/verify/not_applied.after.json +91 -0
  19. sqlquality-0.4.0/tests/fixtures/verify/not_applied.before.json +91 -0
  20. sqlquality-0.4.0/tests/fixtures/verify/regenerate.py +420 -0
  21. sqlquality-0.4.0/tests/fixtures/verify/regressed.after.json +71 -0
  22. sqlquality-0.4.0/tests/fixtures/verify/regressed.before.json +91 -0
  23. sqlquality-0.4.0/tests/fixtures/verify/unchanged_nested.after.json +71 -0
  24. sqlquality-0.4.0/tests/fixtures/verify/unchanged_nested.before.json +91 -0
  25. sqlquality-0.4.0/tests/integration/test_verify_degraded_after_live.py +158 -0
  26. sqlquality-0.4.0/tests/integration/test_verify_headline_live.py +210 -0
  27. sqlquality-0.4.0/tests/integration/test_verify_live.py +282 -0
  28. sqlquality-0.4.0/tests/integration/test_verify_redaction_live.py +222 -0
  29. sqlquality-0.4.0/tests/test_advise_physical_state_wiring.py +235 -0
  30. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_models.py +54 -1
  31. sqlquality-0.4.0/tests/test_report_markdown.py +826 -0
  32. sqlquality-0.4.0/tests/test_verify.py +3322 -0
  33. sqlquality-0.4.0/tests/test_verify_cli.py +1335 -0
  34. sqlquality-0.4.0/tests/test_verify_goldens.py +388 -0
  35. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_postgres.py +264 -0
  36. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_redshift.py +146 -0
  37. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_redshift_rules.py +68 -0
  38. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_rules.py +130 -5
  39. sqlquality-0.3.0/src/sqlquality/report.py +0 -330
  40. sqlquality-0.3.0/tests/test_report_markdown.py +0 -436
  41. {sqlquality-0.3.0 → sqlquality-0.4.0}/.gitignore +0 -0
  42. {sqlquality-0.3.0 → sqlquality-0.4.0}/.pre-commit-hooks.yaml +0 -0
  43. {sqlquality-0.3.0 → sqlquality-0.4.0}/LICENSE +0 -0
  44. {sqlquality-0.3.0 → sqlquality-0.4.0}/pyproject.toml +0 -0
  45. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/adapters/__init__.py +0 -0
  46. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/adapters/base.py +0 -0
  47. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/adapters/postgres.py +0 -0
  48. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/adapters/redshift.py +0 -0
  49. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/antipatterns.py +0 -0
  50. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/changeset.py +0 -0
  51. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/complexity.py +0 -0
  52. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/config.py +0 -0
  53. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/dbtproject.py +0 -0
  54. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/delta.py +0 -0
  55. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/dialects.py +0 -0
  56. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/gate.py +0 -0
  57. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/keys.py +0 -0
  58. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/linter.py +0 -0
  59. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/llm.py +0 -0
  60. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/py.typed +0 -0
  61. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/sqlast.py +0 -0
  62. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/__init__.py +0 -0
  63. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/aggregate.py +0 -0
  64. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/connection.py +0 -0
  65. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/dbt.py +0 -0
  66. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/extract.py +0 -0
  67. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/profiles.py +0 -0
  68. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/secrets.py +0 -0
  69. {sqlquality-0.3.0 → sqlquality-0.4.0}/src/sqlquality/workload/session.py +0 -0
  70. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/fixtures/manifest_v12.json +0 -0
  71. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/integration/__init__.py +0 -0
  72. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/integration/conftest.py +0 -0
  73. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/integration/docker-compose.yml +0 -0
  74. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/integration/test_advise_live.py +0 -0
  75. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/integration/test_introspection_live.py +0 -0
  76. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/integration/test_redshift_connect_live.py +0 -0
  77. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/integration/test_redshift_introspection_bindable_live.py +0 -0
  78. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_adapters_postgres.py +0 -0
  79. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_adapters_redshift.py +0 -0
  80. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_advise_cli.py +0 -0
  81. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_antipatterns.py +0 -0
  82. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_changeset.py +0 -0
  83. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_check_gate.py +0 -0
  84. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_ci_integration_job.py +0 -0
  85. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_cli.py +0 -0
  86. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_complexity.py +0 -0
  87. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_config.py +0 -0
  88. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_dbtproject.py +0 -0
  89. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_delta.py +0 -0
  90. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_dialects.py +0 -0
  91. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_gate.py +0 -0
  92. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_integration_fixture.py +0 -0
  93. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_keys.py +0 -0
  94. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_lint_cli.py +0 -0
  95. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_linter.py +0 -0
  96. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_llm.py +0 -0
  97. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_llm_anthropic.py +0 -0
  98. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_perf_cli.py +0 -0
  99. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_perf_suggest.py +0 -0
  100. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_precommit_hooks.py +0 -0
  101. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_report.py +0 -0
  102. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_sqlast.py +0 -0
  103. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_aggregate.py +0 -0
  104. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_connection.py +0 -0
  105. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_dbt.py +0 -0
  106. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_extract.py +0 -0
  107. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_fingerprint.py +0 -0
  108. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_redaction.py +0 -0
  109. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_secrets.py +0 -0
  110. {sqlquality-0.3.0 → sqlquality-0.4.0}/tests/test_workload_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlquality
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Measure dbt model performance and complexity, and gate changes on the delta.
5
5
  Project-URL: Homepage, https://github.com/hanslemm/sqlquality
6
6
  Project-URL: Issues, https://github.com/hanslemm/sqlquality/issues
@@ -38,8 +38,8 @@ runs per-engine static **performance anti-pattern** checks (with optional
38
38
  captured-`EXPLAIN` analysis), sqlfluff-backed **linting**, and optional, advisory
39
39
  **LLM suggestions**.
40
40
 
41
- sqlquality **never executes your SQL**. `complexity`, `lint`, `perf` and `check` are
42
- fully offline and never open a connection. `advise` is the one exception: it opens a
41
+ sqlquality **never executes your SQL**. `complexity`, `lint`, `perf`, `check` and `verify`
42
+ are fully offline and never open a connection. `advise` is the one exception: it opens a
43
43
  **read-only** session to read query history and catalog metadata, using only a fixed set
44
44
  of built-in introspection statements. Run `sqlquality advise --dry-run` to print every
45
45
  statement it can issue, without connecting.
@@ -63,6 +63,7 @@ Requires Python 3.11+.
63
63
  - [lint](#lint)
64
64
  - [perf](#perf)
65
65
  - [advise](#advise)
66
+ - [verify](#verify)
66
67
  - [check](#check-the-ci-gate)
67
68
  - [Configuration](#configuration-sqlqualityyml)
68
69
  - [Exit codes](#exit-codes)
@@ -124,6 +125,7 @@ sqlquality check Gate a dbt change on the complexity delta of its changed
124
125
  sqlquality lint Lint SQL files for best-practice violations (SQLFluff); --fix rewrites them.
125
126
  sqlquality perf Analyze a SQL file for performance anti-patterns (+ optional EXPLAIN plan).
126
127
  sqlquality advise Propose database optimizations from query history and catalog metadata.
128
+ sqlquality verify Diff two `advise --json` artifacts: was each proposal applied, and did it help?
127
129
  ```
128
130
 
129
131
  The `--dialect` / `-d` flag is validated against sqlglot's dialect registry on every
@@ -617,9 +619,12 @@ DROP INDEX "public"."idx_orders_customer_ref";
617
619
  ```
618
620
 
619
621
  `--json` emits the same evidence as a structured payload (`analyzed`, `degraded`,
620
- `engine`, `proposals`, `redacted`, `skipped`, `window`, plus `dbt` when — and only when — a
621
- manifest was loaded). This is the first proposal from
622
- the run above the real payload lists all five under `proposals`:
622
+ `engine`, `physical_state`, `proposals`, `query_groups`, `redacted`, `skipped`, `window`,
623
+ plus `dbt` when — and only when — a manifest was loaded). This is the first proposal from
624
+ the run above, and the block is abridged in exactly two places: the real payload lists all
625
+ five proposals under `proposals`, and the `query_groups` list is trimmed to one entry. The
626
+ ADV001 object is complete — its `evidence` key set is the one a real ADV001 carries — but each
627
+ rule's `evidence` holds whatever that rule measured, so the key set varies by code.
623
628
 
624
629
  ```console
625
630
  $ sqlquality advise --dsn postgresql://readonly@db.internal/analytics --json
@@ -628,17 +633,32 @@ $ sqlquality advise --dsn postgresql://readonly@db.internal/analytics --json
628
633
  "query_groups": 3,
629
634
  "query_groups_in_window": 3,
630
635
  "tables": [
631
- "orders"
636
+ "public.orders"
632
637
  ],
633
638
  "total_cost_ms": 925000.0
634
639
  },
635
640
  "degraded": [],
636
641
  "engine": "postgres",
642
+ "physical_state": {
643
+ "public.orders": {
644
+ "indexes": [
645
+ {
646
+ "columns": [
647
+ "id"
648
+ ],
649
+ "is_partial": false,
650
+ "is_unique": true,
651
+ "name": "orders_pkey"
652
+ }
653
+ ],
654
+ "is_ordinary_table": true
655
+ }
656
+ },
637
657
  "proposals": [
638
658
  {
639
659
  "code": "ADV001",
640
660
  "confidence": "high",
641
- "ddl": "CREATE INDEX ON \"orders\" (\"status\");",
661
+ "ddl": "CREATE INDEX ON \"public\".\"orders\" (\"status\");",
642
662
  "evidence": {
643
663
  "calls": 15000,
644
664
  "co_occurring_fingerprints": 1,
@@ -646,18 +666,33 @@ $ sqlquality advise --dsn postgresql://readonly@db.internal/analytics --json
646
666
  "status"
647
667
  ],
648
668
  "cost_share": 0.6702702702702703,
669
+ "expression_indexes": [],
670
+ "fingerprint_digests": [
671
+ "d2e8aa0a67af"
672
+ ],
649
673
  "leading_ndv": 500.0,
674
+ "partial_indexes_skipped": [],
650
675
  "roles": [
651
676
  "equality"
652
677
  ],
653
678
  "row_estimate": 5200000,
679
+ "schema": "public",
654
680
  "table": "orders"
655
681
  },
656
682
  "rationale": "These columns carry the table's hottest predicates and no existing index leads with them. Equality columns come first so the range column can be scanned last.",
657
- "title": "Add index on orders(status)"
683
+ "title": "Add index on public.orders(status)"
658
684
  }
659
685
  /* … 4 more proposal objects, same shape … */
660
686
  ],
687
+ "query_groups": [
688
+ {
689
+ "calls": 15000,
690
+ "digest": "d2e8aa0a67af",
691
+ "mean_ms": 41.333333333333336,
692
+ "total_time_ms": 620000.0
693
+ }
694
+ /* … 2 more query groups, same shape … */
695
+ ],
661
696
  "redacted": true,
662
697
  "skipped": {
663
698
  "noise": 0,
@@ -665,10 +700,53 @@ $ sqlquality advise --dsn postgresql://readonly@db.internal/analytics --json
665
700
  "unqualifiable": 0,
666
701
  "ambiguous": 0
667
702
  },
668
- "window": "since stats reset at 2026-07-19 03:00:00+00"
703
+ "window": {
704
+ "description": "since stats reset at 2026-07-19 03:00:00+00",
705
+ "engine": "postgres",
706
+ "limit": 500,
707
+ "since": null,
708
+ "since_duration_seconds": null,
709
+ "stats_reset_at": "2026-07-19 03:00:00+00"
710
+ }
669
711
  }
670
712
  ```
671
713
 
714
+ **Three of those keys exist so that [`verify`](#verify) can diff two runs**, and they are the
715
+ only part of the payload whose contract is about a *later* run rather than this one:
716
+
717
+ - **`window`** is an object, not the prose sentence 0.3.0 wrote there: `description` (that
718
+ same sentence, unchanged), `engine`, `stats_reset_at`, `since`,
719
+ `since_duration_seconds` (the *requested* `--since` duration, e.g. `604800.0` for `7d`,
720
+ as distinct from `since`'s absolute cutoff) and `limit`. Postgres reports `since` and
721
+ `since_duration_seconds` as `null` always: it cannot apply `--since` at all, so echoing
722
+ the flag back would claim a filter that was never applied.
723
+ - **`physical_state`** records, per `"schema.table"`, what the run's catalog reads already
724
+ saw — no extra round trip. On Postgres that is `is_ordinary_table` plus each existing
725
+ index's `name`, `columns`, `is_partial` and `is_unique`; on Redshift it is
726
+ `is_ordinary_table`, `sortkey1`, `diststyle`, `unsorted` and `stats_off`. Every field is a
727
+ **three-way** signal: `null` means *this run could not tell you* (the relation's facts were
728
+ never fetched, or the read was denied — see `degraded`), while `false`/`[]` is a real
729
+ measurement. `verify` reads a `null` as unknown and never as "no".
730
+
731
+ On Postgres `is_ordinary_table: false` means a view, a foreign table or a partitioned
732
+ parent, because the catalog read behind it filters `relkind = 'r'`. **On Redshift the same
733
+ `false` means less**: it comes from the relation's presence in `svv_table_info`, which
734
+ omits external (Spectrum) tables *and* genuinely empty local tables, and nothing available
735
+ distinguishes those — so a Redshift `false` conflates "not a table" with "a table nobody
736
+ has written to yet". The two engines are deliberately not at parity here.
737
+ - **`query_groups`** is every query group this run analysed — `digest`, `calls`,
738
+ `total_time_ms` and `mean_ms` (`null`, never `0.0`, when `calls` is `0`) — not only the
739
+ ones some proposal cites, and each index-rule proposal names the ones behind it in
740
+ `evidence.fingerprint_digests`. This is a **different key** from
741
+ `analyzed.query_groups`, which is (and stays) an integer count.
742
+
743
+ `analyzed.query_groups` is how many groups were understood; `analyzed.query_groups_in_window`
744
+ is how many the window held.
745
+
746
+ All three keys are **always present** — `{}`, `[]` and a full object rather than omitted when
747
+ empty. That is what lets `verify` tell an artifact written before these keys existed (which it
748
+ refuses) from one that genuinely has nothing to report.
749
+
672
750
  **Coverage is always disclosed**, not just when it is bad — the terminal, markdown and
673
751
  JSON paths all print how many query groups were actually understood:
674
752
 
@@ -805,6 +883,125 @@ A manifest that is missing, unreadable or malformed degrades to "no enrichment"
805
883
  line on stderr — `advise` never aborts an otherwise-successful run over an optional input,
806
884
  since by the time the manifest loads the whole catalog analysis has already run.
807
885
 
886
+ ### verify
887
+
888
+ Closes `advise`'s feedback loop. Every proposal `advise` makes is a hypothesis; `verify`
889
+ diffs a baseline `advise --json` artifact against a later one and reports, per proposal,
890
+ whether the advice was **applied** and whether the queries that justified it actually got
891
+ **faster**.
892
+
893
+ It is **fully offline**: it reads two files, opens no connection and needs no credentials,
894
+ so anyone reviewing a change can run it — including people who will never have production
895
+ access. The baseline is an ordinary `advise --json` run; there is no separate file format.
896
+
897
+ ```console
898
+ $ sqlquality advise --dsn postgresql://readonly@db.internal/analytics --json > before.json
899
+ # ... create the proposed index, let the workload run ...
900
+ $ sqlquality advise --dsn postgresql://readonly@db.internal/analytics --json > after.json
901
+ $ sqlquality verify before.json after.json
902
+ Window relation: nested — both runs report the same stats_reset_at and neither restricted
903
+ its window, so the after run's cumulative pg_stat_statements counters contain the before
904
+ run's. Every pre-change execution is still averaged into the after mean, so a real
905
+ improvement is understated here, sometimes badly: a proposal that genuinely helped can read
906
+ as 'unchanged'. Verdicts are capped at medium confidence for that reason. For an undiluted
907
+ comparison, call pg_stat_statements_reset() yourself right after applying a change and take
908
+ the after artifact from there — sqlquality never writes to your database, this reset
909
+ included.
910
+ Run order is taken from the argument order: BEFORE, then AFTER. ...
911
+ workload: before 5000.0 ms across 1 query group(s); after 2000.0 ms across 1 query group(s)
912
+ Verify — postgres (1 proposal, 1 applied, 1 improved)
913
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━┓
914
+ ┃ proposal ┃ applied ┃ outcome ┃ mean per call ┃ conf ┃
915
+ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━┩
916
+ │ ADV001 public.orders (status) │ yes │ improved │ 50.0 → 2.0 ms │ medium │
917
+ └───────────────────────────────┴─────────┴──────────┴───────────────┴────────┘
918
+ ```
919
+
920
+ The table, the per-proposal notes and the disclosures above go to stdout and stderr
921
+ respectively; `--json` emits the same content (verdicts, counts, window relation and every
922
+ caveat) as one machine-readable payload, and `--markdown PATH` writes a report for a ticket
923
+ or PR comment. `verify` **reports and never gates**: exit 0 whenever a comparison was
924
+ reported, exit 2 when it was refused, never 1. There is deliberately no `--gate` flag.
925
+
926
+ **`applied` is observed, not declared.** It comes from the physical state each run recorded
927
+ (`physical_state` in the artifact) — an index that now leads with the proposed columns, a
928
+ dropped index that is gone, a `sortkey1`/`diststyle` that matches the *proposed target*
929
+ rather than merely having changed. `applied: unknown` is a distinct answer from
930
+ `applied: no`: "we could not tell whether you did it" is not "you did not do it", and the
931
+ rules that have nothing observable at all (ADV005 and ADV006 advise a query rewrite, ADV303
932
+ needs a dbt manifest an offline command does not have) are always `unknown` rather than
933
+ guessed at.
934
+
935
+ **Outcomes** are `improved`, `unchanged`, `regressed`, `disappeared`, `not_applied` and
936
+ `unobservable`. The most valuable is **applied but unchanged** — the work was done and it
937
+ did not help, which the note says in as many words.
938
+
939
+ **Mean time per call is the metric; `cost_share` is not.** `pg_stat_statements` is
940
+ cumulative and carries no per-statement timestamps, so a group's share of the window falls
941
+ simply because a week of other traffic accumulated. `cost_share` is the right metric for
942
+ *prioritising* work (which is why `advise` uses it) and close to useless for *measuring* an
943
+ improvement, so it rides along as context — whether the finding still matters — and never as
944
+ the verdict. The workload-context line prints both runs' total window cost and group count
945
+ so a global workload shift is visible rather than deduced.
946
+
947
+ **Confidence comes from how comparable the two windows are:**
948
+
949
+ | windows | grade | why |
950
+ |---|---|---|
951
+ | disjoint (the counters were reset between the runs) | high | independent samples |
952
+ | comparable duration (both runs requested the same `--since`) | high | the same window length by construction |
953
+ | nested (Postgres cumulative, never reset) | medium | pre-change executions dilute the mean, so a real gain is **understated** |
954
+ | incomparable (a one-sided or mismatched `--since`, an unknown `stats_reset_at`) | low | the windows cannot be placed relative to each other |
955
+
956
+ The **nested** case is the common one — you baselined last Tuesday and never reset the
957
+ counters — and it is the one to know about: a proposal that genuinely helped can read as
958
+ `unchanged` there. `verify` prints the caveat on every such run and suggests
959
+ `pg_stat_statements_reset()` for an undiluted comparison. sqlquality never runs it for you;
960
+ it never writes to your database.
961
+
962
+ **Run order is taken from the argument order** (`BEFORE` first). An `advise` artifact
963
+ carries no run timestamp — deliberately, so that two runs over an unchanged workload produce
964
+ comparable bytes — so a swapped pair is only detectable in one case, which is refused: both
965
+ runs report a `stats_reset_at` and the after run's is *earlier*, which cannot happen, since a
966
+ server's statistics-reset instant does not move backwards. Otherwise `verify` says it cannot
967
+ tell rather than inferring an order it has no evidence for.
968
+
969
+ **`verify` refuses rather than guesses** — exit 2, naming the cause:
970
+
971
+ | refused | why |
972
+ |---|---|
973
+ | an unreadable, non-UTF-8, malformed, or non-object JSON file | it is not an `advise --json` artifact |
974
+ | an artifact missing the keys 0.4.0 added (`window` as an object with all its fields, `physical_state`, `query_groups`) | 0.3.0 and intermediate builds wrote less; regenerate the baseline rather than let a verdict rest on absent data |
975
+ | the same artifact twice — identical path, or byte-identical content | it would report every proposal unchanged, which reads as a finding rather than a mistake; two genuinely distinct runs cannot be byte-identical, since the counters accumulate |
976
+ | two artifacts from different engines | they describe two different servers, so nothing in one corresponds to anything in the other |
977
+ | two runs that disagree about `--keep-literals` | redaction changes the canonical query text every digest is computed from, so the same query group is recorded under different identifiers; its "absence" would be a fact about the flag, not about your database |
978
+ | a demonstrably swapped pair (see above) | the comparison would be reversed |
979
+
980
+ Everything else that weakens the comparison is **disclosed rather than folded into a
981
+ verdict**: a `--limit` mismatch (a group missing from one artifact may be a sampling
982
+ artifact rather than a real disappearance, so `disappeared` is not graded on that alone),
983
+ each run's `degraded` capabilities (a read that could not run produces the same emptiness a
984
+ real change would), recommendations whose key matched more than one proposal *within their
985
+ own artifact* (reported as unmatched — neither disappeared nor new), and proposals only the
986
+ after run makes (no verdict, because there is nothing to compare them against).
987
+
988
+ **An after-only proposal is only called a *new* finding when both runs' coverage supports
989
+ that.** Three things withhold the claim:
990
+
991
+ - the **before** run's reads were degraded, so it may never have had the evidence to make that
992
+ recommendation — its absence is then a fact about that run, not about your database;
993
+ - the **before** run's window sampled fewer (or an unknown number of) query groups, for the
994
+ same reason;
995
+ - the **after** run's reads were degraded in a way that can *relax* a rule rather than silence
996
+ it. A rule that cannot evaluate a threshold proposes anyway at reduced confidence — the right
997
+ posture for `advise`, which discloses rather than withholds — so a run that could not read
998
+ table sizes, or could not see an index that already covers the predicate, can make a
999
+ recommendation a fully-observed run would not have made at all.
1000
+
1001
+ In each case `verify` still lists those proposals and says why it will not call them new. This
1002
+ is the same treatment a query group's absence from the *after* run already gets before
1003
+ `disappeared` may be graded; the directions of the same reasoning are deliberately symmetric.
1004
+
808
1005
  ### check (the CI gate)
809
1006
 
810
1007
  Scores each changed model on both a candidate and a baseline dbt manifest, and gates
@@ -929,6 +1126,11 @@ Per-command nuances of code `1`:
929
1126
  produced — proposals are advisory and never gate. It exits 2 on a usage, config,
930
1127
  connection or input error (unresolvable credentials, connection failure, missing
931
1128
  driver, malformed `--since`, out-of-range `--timeout`). It never exits 1.
1129
+ - **`verify`** exits 0 whenever a comparison was reported, whatever the verdicts say — a
1130
+ regression does not gate, and there is deliberately no `--gate` flag. It exits 2 when it
1131
+ refuses the pair (see [verify](#verify) for the full list: an unreadable or pre-0.4.0
1132
+ artifact, the same artifact twice, two engines, two redaction settings, a demonstrably
1133
+ swapped pair) or when a `--markdown` path cannot be written. It never exits 1.
932
1134
 
933
1135
  ## CI recipe (a gate that actually gates)
934
1136
 
@@ -1221,3 +1423,29 @@ LLM suggestions unavailable: The 'anthropic' package is required for AnthropicPr
1221
1423
  model is not reported until the downstream one is gone, so a fully dead chain unwinds one
1222
1424
  model per run, from its leaf. Conservative by construction: it never flags a model that
1223
1425
  something declares a dependency on.
1426
+ - **`verify` cannot tell which of two artifacts is older, except in one case.** An `advise`
1427
+ artifact carries no run timestamp — deliberately: two runs over an unchanged workload
1428
+ produce comparable bytes, which is what makes them diffable at all — so `verify` takes run
1429
+ order from the argument order. The one detectable swap is refused: both runs report a
1430
+ `stats_reset_at` and the after run's is earlier, which a server cannot do. Pass the earlier
1431
+ artifact first; nothing in the pair will catch it for you if you do not.
1432
+ - **On the common nested-window Postgres path `verify` understates a real improvement.**
1433
+ `pg_stat_statements` is cumulative, so unless the counters were reset between the two runs
1434
+ the later window contains the earlier one and every pre-change execution is still averaged
1435
+ into the after mean. A proposal that genuinely helped can therefore read as `unchanged`.
1436
+ This is disclosed on every such run and capped at medium confidence rather than engineered
1437
+ around, because the alternative is writing to your database: `verify` suggests
1438
+ `pg_stat_statements_reset()` and never performs it.
1439
+ - **`verify` cannot observe whether ADV005, ADV006 or ADV303 were acted on.** The first two
1440
+ advise a query rewrite, whose only trace is the group's fingerprint changing because the
1441
+ SQL changed; ADV303 needs a dbt manifest an offline command does not have. Their verdict is
1442
+ `unobservable`, and a vanished query group is reported as *possibly* addressed at low
1443
+ confidence — disappearance is not proof.
1444
+ - **`verify`'s Redshift verdicts are weaker than its Postgres ones, beyond the standing
1445
+ "never run against a live cluster" caveat.** ADV002/ADV003/ADV104/ADV105/ADV301 name no
1446
+ individual query group, so none of them can ever be graded `improved`, `unchanged` or
1447
+ `regressed` — only `not_applied` or `unobservable`, with the applied signal beside it — and
1448
+ on Redshift that leaves ADV101/ADV102/ADV103 as the only rules whose speed change is
1449
+ gradable at all. `is_ordinary_table` also means less there than on Postgres (see the
1450
+ [`advise --json`](#advise) payload notes above), and the four Redshift physical fields
1451
+ beneath it are unreadable until it is known.