Skip to content

feat(advise): schema-qualified keying, join/group rules, and wrapped reads - #12

Merged
hanslemm merged 25 commits into
mainfrom
feat/advise-batch-2
Jul 27, 2026
Merged

feat(advise): schema-qualified keying, join/group rules, and wrapped reads#12
hanslemm merged 25 commits into
mainfrom
feat/advise-batch-2

Conversation

@hanslemm

Copy link
Copy Markdown
Owner

Batch 2 of 3 follow-ups to sqlquality advise. Three deliverables, plus a fourth the reviews
forced into existence.

Multi-schema keying

Every catalog fact advise collected was keyed on the bare table name, so two schemas each
holding an orders aliased into one another — the last catalog row won the row estimate, and
qualify() resolved columns against the union of both column sets. --schema accepted only one
value, and rejected two with an honest explanation.

A Relation(schema, table) value type now threads through extract → aggregate → catalog → the
rules → CLI and report, and advise --schema public --schema sales works.

The load-bearing detail, found by probing sqlglot before writing the plan: qualify() leaves
Table.db empty for a bare table reference
, even when the nested schema resolves it
unambiguously. Since production SQL relies on search_path and says from orders, a
Table.db-only implementation would key almost every real workload under schema="", match no
catalog fact, and silently suppress every proposal. Schema resolution therefore reads the
introspected schema map, and only trusts Table.db when it is both present and in that map —
qualify() does not validate UPDATE/DELETE targets, so UPDATE other.orders SET … would
otherwise manufacture a phantom relation.

Ambiguity is counted and reported rather than guessed: a bare name held by two introspected
schemas increments skipped_ambiguous and the run says which remedy applies.

Two roles that were collected and thrown away

ColumnRole.JOIN and ColumnRole.GROUP were classified, cost-weighted, and counted in every
cost_share denominator — and read by no rule at all.

  • ADV007 proposes an index on a hot join key. Postgres does not index the referencing side of
    a foreign key, so this gap is common and expensive.
  • ADV008 proposes an index to feed a hot GROUP BY already sorted. Capped at MEDIUM with no
    HIGH branch, and the cap is unreachable by construction (verified across a 1,080-combination
    sweep): whether the planner picks GroupAggregate over HashAggregate depends on work_mem,
    group count and aggregates, none of which this tool can see. HIGH would be a claim about the
    planner rather than the catalog.

A collapse layer, because the rule set contradicted itself

With three index-creating rules, advise could recommend (customer_id, created_at) and
(customer_id) in the same report, both at HIGH — and then advise DROP INDEX on the narrower
one next run. Proposals are now collapsed by prefix within a relation, using the tool's own
definition of redundancy, and no caveat stated by a discarded proposal may vanish from the report.
Where two proposals cover the same column set in a different order, they are disclosed rather
than collapsed: different leading columns genuinely serve different probes.

Determinism is verified rather than assumed — all 24 permutations of a colliding set, and 400
shuffles of an 11-element set across six PYTHONHASHSEED values, produce the same SHA-256.

Wrapped reads

DECLARE … CURSOR FOR SELECT and COPY (…) TO are ordinary reads with real predicates, but both
begin with a keyword the noise filter dropped — so on any workload using server-side cursors
(every psycopg2 cursor(name=…), which is what Django and SQLAlchemy emit for large result sets)
those reads were counted as "filtered" and discarded. They are now unwrapped before the noise
check, which also means an inner introspection query is still caught.

Honest about what this delivers: measured on PG16, FETCH carries ~72.9 ms while the DECLARE
carries ~0.3 ms, so a cursor read contributes its columns but little cost. Documented.

What the reviews found

Each task was reviewed and its findings fixed; the whole-branch review then found seven more, as
it did on both predecessor branches.

The one that mattered was a cross-task regression no per-task review could see: cursor unwrapping
made ADV001 emit a worse index at HIGH, because a read costing 0.003% of the window welded a
third column into a composite that could no longer satisfy the hot query's ORDER BY. ADV001 was
the only index rule with neither a per-column cost floor nor a joint co-occurrence requirement; it
now carries the same running fingerprint intersection ADV008 uses, verified against
over-correction with an A/B harness over seven constructed cases.

The live suite kept earning its cost. It proved the two-schema separation on real catalog rows
(orders_pkey exists under the identical name in both schemas), confirmed the reltuples = -1
sentinel and negative-n_distinct paths live rather than by fixture, and caught a fix that had to
be reverted: AND s.toplevel removed a COPY double-count but excluded every nested statement —
and since that is the only way Postgres exposes SQL inside a function, it produced a different
proposal graded high
while the genuinely hot columns vanished. Confidently wrong is worse than
an inflated cost share on a non-default setting.

Two limitations were found and documented rather than papered over: the cursor cost attribution
above, and that under pg_stat_statements.track = all both a COPY (…) TO and every PL/pgSQL
call are counted twice.

Seven findings on this branch were "a test asserting over a set that checks one member" — a
four-statement SQL property checking one statement, an evidence contract pinned for two of six
rules, a composed-rules test covering two of seven that let a completely unwired rule pass. Where
a test now asserts over a set, every member was mutated independently.

Behaviour changes

Safe because advise is unreleased (every mention is under [Unreleased]):

  • ADV001 proposes narrower indexes where columns do not co-occur — one proposal in nine differed
    on the live seeded workload.
  • --json's analyzed.query_groups is now the analysed count rather than the window total,
    matching what the terminal always printed. The total moved to query_groups_in_window.
  • MIN_ROWS_FOR_INDEX, _covered, and the partial/expression-index guards are unchanged.

Verification

  • 576 passed, 14 deselected — deselected, not skipped; the default suite needs no extras and no
    Docker
  • 14 passed for the integration suite against live postgres:16
  • ruff check, ruff format --check, mypy src/sqlquality all clean

Follow-ups, not in this PR

  • ADV004 never discloses that it skipped coverage checks, and it is the only index-creating
    rule that never calls _covered. Pre-existing; the fix is a design question, not wiring.
  • CI runs uv sync --all-extras, so no job exercises the Docker-free, extra-free default suite
    that this branch depends on. Carried from Batch 1.

🤖 Generated with Claude Code

hanslemm and others added 25 commits July 27, 2026 15:17
Introduce Relation(schema, table) and resolve it in the extract layer via
the introspected schema map, not Table.db alone -- qualify() leaves db
empty for the normal bare-table case, which previously would have keyed
every real workload under schema="". Also catch SchemaError (not an
OptimizeError subclass) alongside OptimizeError so an ambiguous bare name
raises UnqualifiableQuery instead of crashing the run.

Tasks 2-4 carry Relation into aggregate, catalog facts, rules and the CLI;
until then test_workload_aggregate.py, test_workload_rules.py,
test_advise_cli.py and test_models.py fail on the changed ColumnUsage
field, as expected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ation.__str__

Code review of the Relation task found four gaps between what the tests pin
and what the implementation actually guarantees:

- _collect_dml's `len(tables) == 1` guard was unpinned; a multi-table
  UPDATE ... FROM with a bare column would silently misattribute it to
  tables[0] with no test catching the regression.
- test_explicitly_qualified_table_uses_the_schema_it_names survived deleting
  resolve_relation's `if table.db:` branch outright, because its fixture
  table lived in only one schema. Rewritten against a name held by two
  schemas so only the explicit-db branch can produce the right answer.
- Relation.__str__ had no test at all.
- resolve_relation trusted an explicit table.db without checking it was
  actually introspected. Probed sqlglot 30.12 directly: qualify() validates
  this for SELECT-scope columns but not for UPDATE/DELETE targets or their
  bare columns, so `UPDATE other.orders SET status = 'x' WHERE id = 1`
  against a schema map missing "other" sails through untouched and would
  manufacture a phantom Relation("other", "orders"). Added the missing
  membership check plus regression tests.

All four new/changed tests were confirmed to go red under the mutation the
review specified before being restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aggregate() now keys usage by Relation instead of bare table name, so two
introspected schemas holding the same table name (e.g. sales.orders and
staging.orders) produce two entries rather than a merged, aliased one.

extract_usage's ambiguous-bare-name case (sqlglot's SchemaError, matched
loosely on "ambiguous mapping" since there is no error code) is now raised
as AmbiguousRelation, a UnqualifiableQuery subclass, and counted in a new
Aggregation.skipped_ambiguous rather than folded into skipped_unqualifiable
or left to crash the run — the remedy for each is different: qualify the
query / run once per schema, versus widen the introspected schema.

star_tables is rewritten against the nested schema map to return relations
too, declining a name held by two schemas rather than guessing.
…guous DML targets

Review findings on the relation-keyed aggregate rollup:

- star_tables text-matched the schema's table names against raw SQL, which
  cannot see a schema qualifier and diverged from resolve_relation in both
  directions: `select * from nosuch.items` resolved through a bare-name
  collision with an unrelated schema (the exact phantom resolve_relation's
  table.db guard exists to refuse), while `select * from sales.orders`
  naming one side of a same-table-name collision was dropped as if
  unqualified. Rewritten to parse each starred statement and resolve its
  exp.Table nodes through resolve_relation directly, so the two agree by
  construction.
- The usage sort key's `u.relation` component was untested; two relations
  tied on cost/column/role now have a pinned, canonical order.
- _collect_dml's sole-target resolution silently dropped an ambiguous bare
  DML target (qualify() does not validate UPDATE/DELETE targets, so no
  SchemaError was ever raised for it) with no counter incremented — the
  one case that made a statement look analysed when it was not. It now
  raises AmbiguousRelation, same as the SELECT-path ambiguity.
- Added the missing denominator-semantics test for the ambiguous path, and
  replaced hardcoded "select_star" literals in tests with FLAG_SELECT_STAR.
CAP_SCHEMA, CAP_TABLE_FACTS, CAP_NDV and CAP_INDEXES filtered on schema but
never returned it, so a row from sales.orders and one from staging.orders
were indistinguishable and the last one silently won. Each statement now
selects its schema as the first column, and fetch_schema/fetch_table_facts/
fetch_indexes are reworked to key by the schema-qualified Relation Tasks 1-2
introduced. TableFacts.name becomes TableFacts.relation to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- test_every_relation_returning_statement_selects_its_schema was hollow:
  it grepped the whole statement, so the schema-in-WHERE-clause substring
  passed even with the schema stripped from the SELECT list. Now checks
  the select list specifically (text between SELECT and the first FROM).
- fetch_schema's abstract docstring in base.py still described the flat
  shape this task removed; updated to the nested shape and why.
- propose()'s facts param in base.py was still dict[str, TableFacts],
  which was the actual cause of the one cli.py mypy error; now
  dict[Relation, TableFacts], clearing it.
- Corrected the over-fetch comment: the schema filter does still exclude
  unintrospected schemas: what over-fetches is a same-named table in a
  different *requested* schema not itself in `relations`.
- ruff format --check was failing on four hunks in the touched files;
  reformatted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each propose_* rule now keys on Relation and derives its DDL's schema from
relation.schema instead of a single run-wide schema= keyword, so proposals
for same-named tables in different schemas no longer collide or get
stamped with the wrong schema.
…rsing

Addresses Task 4 review findings:
- ADV002/ADV003/ADV004 evidence carrying schema/table was untested for those
  three rules; added assertions confirmed RED under the mutations the review
  specified.
- ADV006 named a wide table a starred statement never referenced whenever two
  schemas shared the table's bare name, since it matched on raw SQL text. Now
  resolves each statement's exp.Table nodes (parsed with the adapter's own
  dialect) against the wide set, the same policy star_tables already applies,
  falling back to bare-name text matching only when a statement fails to parse.
- Added an end-to-end propose() test using a non-public relation.
Relation-keyed catalog facts (Tasks 1-4) removed the reason --schema was
capped at one: _validate_schemas now dedupes and preserves order instead
of exiting 2. skipped_ambiguous is surfaced in the coverage line, a new
_ambiguity_warning naming the remedy, the JSON payload and the markdown
report. Fixed report.py's "tables" key, which put a non-JSON-serializable
Relation into the --json payload after the whole analysis had already run.

Also: aggregate() now counts a bare `SELECT *` over a table two
introspected schemas both hold as skipped_ambiguous too (previously
uncounted anywhere, since a starless qualify() never raises for it), and
_wide_relations_touched's unreachable, buggy parse-failure fallback is
deleted rather than left untested (ingest() never lets an unparseable
statement reach workload.stats in the first place).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_analyzed_count ignored skipped_ambiguous, so an ambiguous statement
counted as both "analyzed" in the coverage line and "unexplained" in
_coverage_warning's share simultaneously. Worst case this silently
suppressed the low-coverage warning exactly when ambiguity alone crossed
the threshold (25/100 real share vs. an inflated-to-exactly-20% share).

Also widen the bare-ambiguous-table gate in aggregate(): it required
FLAG_SELECT_STAR, so `select count(*) from orders` and `select 1 from
orders` over a colliding schema produced zero usage without ever being
counted by either skip counter. Dropped the flag conjunct; any statement
that resolves to zero usage because of a genuinely ambiguous bare table
now counts, star or not.

Added a CLI-level test proving _ambiguity_warning is actually wired into
advise()'s command body, not just unit-testable in isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dence ladder

Review round 2 on ADV007: it could emit HIGH while falsely claiming "No
existing index leads with it" next to a partial or expression index that
does lead with the join column -- ADV001 already discloses both, ADV007
had neither. Add the same evidence/rationale disclosures, verbatim ADV001
wording.

Also close three unpinned-guarantee gaps: the propose() wiring test only
checked a 2-of-7 superset (deleting ADV007's whole block stayed green),
the MEDIUM (unknown-NDV) branch had no test forbidding HIGH, and the
cost_share < min_cost_share guard had no test forbidding "if False". All
four now have red-then-green tests; README:800/808 corrected to note the
disclosures apply to both index-creating rules.
Adds propose_grouping_indexes, wired into propose() after propose_join_keys.
One composite index (not one per column, unlike ADV007) ordered by cost
descending since redaction does not preserve GROUP BY column position;
capped at MEDIUM/LOW, deliberately no HIGH branch, since whether Postgres
uses the index for grouping depends on the planner's GroupAggregate vs
HashAggregate choice, which the catalog cannot see. Discloses partial and
expression indexes leading with the grouping columns, matching ADV001/ADV007.

14 new tests pin every confidence rung, the co-occurrence guard, the
cost_share threshold, max_arity, and both disclosures; the composed-rules
test now pins all eight rule codes. Updated --min-cost-share help text and
the README rule table/limitations to reflect ADV008.
The extension rule checked each candidate column against only the seed's
fingerprints, so a transitive chain -- a grouped with b in one query, a
grouped with c in another -- welded (a, b, c) into one composite that no
query in the workload actually groups by, while evidence still reported
cost and fingerprint counts that read as support. Fixed by tracking a
running fingerprint intersection and requiring every new column to share
it, so the composite only ever grows to a size some single query supports.

evidence now carries "co_occurring_fingerprints" (the honest joint count,
same name/meaning as ADV004's) alongside the existing per-column
"fingerprints", which never implied joint support on its own.

Added tests for the transitive arrangement, the honest joint-support
count, and the seed-shares-nothing case; proved both new tests RED against
the reintroduced pairwise-with-seed bug.
report.py renders evidence as generic sorted k=v pairs with no per-rule
text, so ADV008 emitting both "fingerprints" (per-column max) and
"co_occurring_fingerprints" (the honest joint overlap) side by side left
nothing in the artifact telling a reader which one actually supports a
multi-column proposal -- someone could still read fingerprints: 2 as two
query groups backing a composite only one query group groups by.

Dropped "fingerprints" from ADV008's evidence, matching ADV004, which
already omits it for the same reason: both propose an index justified by
columns appearing together, so only the joint overlap supports the claim.
ADV001/ADV007 keep it -- each proposes for one candidate, so a per-column
count is the whole truth there. Documented the split in the docstring.

Extended the joint-support test to assert "fingerprints" is absent, and
confirmed it goes RED with the key restored.
_dedupe_by_ddl only reconciled proposals that render byte-identical DDL,
which was enough while ADV002/ADV003 were the only pair that could tie.
ADV007 and ADV008 (Tasks 6-7) broke that: ADV001 and ADV008 can now tie at
equal confidence on identical DDL, and ADV001/ADV007/ADV008 can each reach
a plain index from different evidence, so a strict-prefix pair (e.g.
ADV001's (customer_id, created_at) alongside ADV007's (customer_id)) could
ship in the same report -- advising a CREATE today and, via ADV003, a DROP
on the very next run.

- _CODE_PREFERENCE breaks equal-confidence DDL ties deterministically
  instead of by list order; the stale docstring claiming ties were
  unreachable is corrected.
- _collapse_index_prefixes folds a proposal's columns into any wider
  proposal they are a strict prefix of, within one relation, excluding
  ADV004's partial indexes (a WHERE predicate makes it a different object).
- _disclose_column_set_overlaps annotates same-column-set-different-order
  pairs (e.g. (status, region) vs (region, status)) instead of silently
  shipping both with no acknowledgement they overlap -- neither is a prefix
  of the other, so no collapse or future ADV003 pass would ever catch it.
- _fold_discarded makes both collapses non-lossy: a discarded proposal's
  full rationale and confidence are appended to the survivor's, so a
  caveat only the losing rule stated (e.g. a low-NDV selectivity warning)
  never silently disappears.

All three additions are order-independent, verified with permuted-input
tests rather than by reading the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…epeating itself

Review findings on the proposal-collapse rework:

- _disclose_column_set_overlaps never sorted its notes, so with two or more
  same-set partners the concatenated rationale depended on input order (six
  permutations gave six texts). Latent today -- ADV001 and ADV008 emit at most
  one composite per relation and ADV007 is single-column -- but order
  independence is the whole point of the tie-break this task restored, so it is
  sorted by the same canonical key _collapse_index_prefixes already uses.
- Three guards no test noticed, each of which the suite stayed green without:
  the cross-relation guards in _collapse_index_prefixes and
  _disclose_column_set_overlaps, and the empty-columns guard in
  _index_creation_columns. A guard nothing pins is one refactor from gone.
- test_prefix_collision_collapses_regardless_of_which_rule_appended_first was
  vacuous: its fixture absorbed a single proposal, so the sorted(absorbed, ...)
  it appeared to pin was a no-op and reversing it changed nothing. It now
  absorbs two.
- ADV001 emitted LOW for a low leading NDV with a rationale byte-identical to
  its HIGH and MEDIUM text, while ADV007 explained itself for the identical
  reason. Batch 2 created that asymmetry by giving ADV007 the caveat, so ADV001
  now states it in the same words -- an operator should not have to notice the
  explanation depends on which rule proposed the index. Confidence values are
  unchanged; only the text.
- Folding a discarded proposal's rationale in verbatim repeated whole sentences,
  because all three index-creating rules share verbatim disclosure wording: a
  real three-way collision produced a 1384-character paragraph containing the
  same 40-word sentence three times. Folding is now sentence-wise, skipping only
  exact repeats already present, in first-occurrence order. Nothing unique is
  lost, and a discarded proposal that adds no new sentence still has its
  disagreeing confidence recorded.

Verified: all seven reverting mutations turn exactly their own test RED from
purged caches; determinism re-checked after the change, with all 24 permutations
of a four-element colliding set and 400 shuffles of an eleven-element set (prefix
chain, second relation, ADV004 partial, ddl=None, identical-DROP pair) each
producing one distinct output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing them

DECLARE ... CURSOR FOR SELECT ... and COPY (SELECT ...) TO ... are ordinary
reads with real predicates, but both start with a keyword is_noise drops --
and every psycopg2 server-side cursor (cursor(name=...), what Django and
SQLAlchemy emit for large result sets) produces a DECLARE. Add unwrap() to
recover the inner query via text surgery (sqlglot cannot parse DECLARE at
all) and run it in ingest before is_noise, so a cursor declaration is judged
on the query it declares rather than on its wrapper. A DECLARE/COPY wrapping
our own introspection is still filtered, since is_noise now runs against the
unwrapped text.

Updates the test_advise_cli.py coverage-line test and the _coverage_line
docstring, which documented this as a known limitation.
Review of the DECLARE/COPY unwrap found the cursor half recovers predicates
but not cost: Postgres attributes a cursor's scan work to the FETCH
statements that follow, which stay filtered, so a DECLARE enters the
workload at near-zero cost and the default --min-cost-share can suppress it
outright. Rewrite unwrap's docstring, the README limitation, and the
markdown report's skip line to say so, and to stop claiming the noise
filter matches "by statement prefix" now that DECLARE/COPY are unwrapped
before it runs.

Also fix a real double-count: under pg_stat_statements.track = all, one
COPY (...) TO produces both a verbatim top-level row and a normalised
nested-query row for the same execution, and they redact to different
fingerprints. Filter CAP_WORKLOAD to s.toplevel, which requires PostgreSQL
14+ (toplevel doesn't exist before then) — raise the documented floor
accordingly rather than silently breaking the 13+ contract the hint used
to advertise, since 13 is already past its EOL.

Round out unwrap()'s test coverage: pin the COPY prefix anchor against a
later '(', the TO/FROM write exclusion, and .strip() (load-bearing, not
cosmetic — greedy .* under DOTALL lets trailing whitespace into the query
and that changes fingerprint grouping). Drop three parametrize members that
looked like coverage but couldn't discriminate any plausible mutation of
the regexes they sat next to.
…nstead

Live PG16 testing under track = all with real PL/pgSQL functions showed the
toplevel filter is worse than the bug it fixed: toplevel = false is the
only way Postgres ever exposes a function body's nested SQL, so filtering
to toplevel silently emptied a genuinely hot, function-wrapped query's
evidence entirely and let a colder query win a high-confidence proposal in
its place — confidently wrong, with no disclosure (degraded stayed empty,
coverage looked complete). No s.query text pattern distinguishes a COPY's
nested duplicate from a function's nested statement; both attempted
predicates either kept the duplicate or discarded valid COPY reads under
the default track = top. Revert AND s.toplevel and the 14+ floor bump
(hint, SQL comment, README prose, --dry-run sample) back to 13+ everywhere,
and document the COPY double-count as an accepted, known limitation in the
README instead. Replaced the toplevel-behavior tests with a pin that the
filter stays absent and a regression pin for the documented double-count.

Also: added real ingest-level tests that FETCH and CLOSE are filtered as
noise (a prior docstring claimed this was "already exercised... by the
ordering tests below" — it wasn't; deleting fetch|close from
_LEADING_NOISE left the full suite green). Corrected "near-zero calls,
time and rows" for a DECLARE to note calls is accurate — only time and
rows are misattributed to the FETCH statements that follow.
…ainst real postgres

Fixes test_introspection_live.py's pre-Task-3 API (bare-string keys, flat fetch_schema
shape) that had been rotting silently since it is deselected by default. Seeds orders
into both public and staging with different row counts, an unindexed join key and
GROUP BY, an unused index, and a DECLARE ... CURSOR FOR read, then adds four live tests
proving multi-schema keying, ADV007/ADV008, and cursor-unwrapping against a real
Postgres rather than canned fixtures. Brings CHANGELOG and the design spec's deviations
section in line with what Batch 2 shipped; README was audited and found already correct.
…in their claims

Review found 3 of 6 live assertions were decoration: reverting DECLARE unwrapping,
removing either ADV007 or ADV008, or leaving reltuples=-1 unfixed all left the suite
green. Fixes: the cursor's inner query now filters a predicate no other seeded statement
uses, so its query group can only exist if unwrapping ran; ADV007/ADV008 are asserted
individually instead of as a disjunction; public.order_items gets
autovacuum_enabled=false (a bare "don't ANALYZE it" was observed live to be
non-deterministic -- autovacuum analyzed it ~2.5s after seeding) and a dedicated test
pins row_estimate is None plus the resulting LOW-confidence ADV007 proposal. Also adds a
live assertion on fetch_schema's nested shape and non-vacuity guards to the two tests
that lacked them, and brings the design spec's dataclass bodies (not just its deviations
section) in line with the shipped Relation-keyed types.

Each of the three fixed claims was confirmed to go RED under the exact regression it now
guards against, then restored and reconfirmed GREEN, before this commit.
…minors

F1 (High) — ADV001 required no joint support, so it welded the hottest equality
columns and the hottest range column of a relation together whether or not any
single query used them together. A DECLARE ... CURSOR FOR read at 0.003% of window
cost turned (customer_id, created_at) into (customer_id, tenant_id, created_at) —
which cannot satisfy the hot query's ORDER BY created_at — and reported
fingerprints: 1 as if one query group backed all three when zero did. ADV001 now
carries a running intersection of fingerprint_ids as ADV008 does, and reports the
honest joint count under co_occurring_fingerprints with no per-column count beside
it (ADV004/ADV008's existing split).

F2 (Med-High) — the source comment and README justified the absent s.toplevel
filter with a claim measurement disproves: a COPY's nested row keeps its wrapper
while a PL/pgSQL body is recorded bare, so a narrow predicate does exist. The
filter stays out — naming s.toplevel raises the floor to PG14 and would cost a
PG13 user the whole workload capability — but for that stated price, not for
impossibility. Also documents the larger, genuinely unfixable half: under
track = all every PL/pgSQL call double-counts, halving every cost_share.

F3 (Med) — _stub_adapter hard-coded self.schemas = ("public",) after the CLI had
resolved --schema, so no default test could see adapter.schemas = schemas being
deleted. The stub no longer overwrites it, and two tests assert the tuple each
schema-scoped catalog query received. Subsumes M7.

F4 (Med) — ADV003 iterated every relation in `existing`, including rows arriving
through fetch_indexes' bare-name over-fetch, so it could propose DROP INDEX for a
relation the workload never touched. Scoped to hot_tables like ADV002, and
fetch_indexes' "no consumer" comment corrected.

F5 (Med) — markdown and JSON labelled the raw query-group count "analyzed" where
the terminal said "analyzed 6 of 8". One helper in models, three call sites.

F6 (Med) — _fold_discarded claimed a collapsed proposal "reached the same index",
never true for prefix collapse. The two collapse kinds now word their attribution
differently, naming the narrower column list.

F7 (Low) — the proposal-collapse layer is documented in README, CHANGELOG and the
design spec, including that a rule can fire and contribute no proposal and that an
absorbed proposal's evidence is discarded.

Minors: M2 pins the qualified-reference guard in _references_an_ambiguous_bare_table;
M3 deletes the dead mentions_table wrapper and retargets its test at
mentions_identifier; M4 corrects the false premise in _wide_relations_touched's
docstring and pins the real one; M6 deletes the dead DEFAULT_SCHEMA constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… that passed vacuously

Re-review of the final fix wave. None of these change emitted advice.

- ADV008's docstring still said its joint-support evidence was "deliberately
  unlike ADV001", which stopped being true when ADV001 gained the same running
  fingerprint intersection. ADV001, ADV004 and ADV008 are now all on the
  joint-support side of that split; ADV007 and ADV005 speak for a single column
  and keep their per-column count.
- test_the_toplevel_tradeoff_... searched the whole of postgres.py for
  "postgresql 14", which _row_estimate's unrelated reltuples docstring satisfies
  — so the assertion passed with the sentence it exists to protect deleted. It is
  now scoped to the CAP_WORKLOAD comment block, and the enumeration of three
  stale phrasings is cut to the one that was actually there: whack-a-mole
  substrings give false confidence, and the positive assertion is what pins the
  reasoning.
- Nothing in the suite read a catalog statement's second bind parameter, so
  replacing the table list with a bogus value in all three relation-scoped
  statements left every test green. A real server asked about the wrong relations
  returns nothing, which is indistinguishable from a table with no statistics and
  no indexes, and suppresses proposals with no message. Now asserted per
  statement rather than in aggregate.
- Dropped the assertion pinning the PL/pgSQL measurement to 68.21/67.67 ms. Two
  runs of the same fixture gave 68.21/67.67 and 46.44/46.37: the shape
  reproduces, the numbers are that machine's. A test that fails on an honest
  re-measurement teaches people to edit tests instead of reading them. The README
  now says "on one PostgreSQL 16 run" and notes the absolute figures vary.

Verified: feeding a bogus table list to all three catalog statements, and
removing the version cost from the CAP_WORKLOAD comment, each turn exactly their
own test RED from purged caches. 576 passed, 14 deselected; integration 14 passed
against live postgres:16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hanslemm
hanslemm merged commit a6fbe33 into main Jul 27, 2026
4 checks passed
@hanslemm
hanslemm deleted the feat/advise-batch-2 branch July 27, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant