Run-scoped eligibility, the value-comparing split, and an archive for superseded votes - #604
Conversation
4b0cf83 to
b116cf4
Compare
5212509 to
9dad341
Compare
9dad341 to
5f34ad5
Compare
… superseded votes
"Prior runs must not suppress work in a new run. The CURRENT run's own
output must, so a killed run resumes rather than redoing completed batches."
- owner directive, 2026-07-29
"Keep at least one prior generation of votes, whose votes are NOT counted."
- ratified separately
WHY THESE THREE ARE ONE COMMIT AND NOT THREE
They are not independently reviewable, and shipping any one alone is worse than
shipping none:
- Un-suppressing eligibility ALONE buys exactly nothing. The calculator
recomputes the verdict and the pre-write split then drops it before the
write, and because `purge_and_write_votes` scopes its purge to the rows
being written, a dropped row purges NOTHING - the stale vote survives
verbatim, with no error, no counter moving, and a recomputation's worth of
work discarded. Two independent suppression layers, and the second silently
defeats a fix to the first.
- The archive has no effect until the split permits an overwrite, and the
owner ruled it must not ship separately from the work that creates
superseded rows in the first place.
The run_id AUDIT this depends on is a separate, genuinely independent PR
(`fix/run-id-population-gaps`), which this is stacked on.
LAYER 1 - RUN-SCOPED ELIGIBILITY
Every Stage D printing-channel calculator asked "have I EVER voted on / abstained
on this card?". That predicate grows monotonically, so each pass could only ever
see a subset of the previous pass's pool, and a repaired engine could never
re-examine anything the broken one had answered. Not hypothetical:
`stage-d-illustration` had to be version-bumped v1 -> v2 purely to escape its own
non-rescannable scan-log rows after its layout_class gate turned out to be
reading a border colour - 3,409 wrongly-skipped cards were otherwise unreachable
to a repaired v1. Under run-scoping the repair alone would have sufficed.
Both self-suppressing excludes - the printing-tag vote exclude and the
non-rescannable `CardScanLog` exclude - now additionally match the CURRENT
run_id, in `_eligible_cards_queryset` (join-key + fallback),
`_slow_path_eligible_cards_queryset`, `_eligible_illustration_cards_queryset`
and `_eligible_base_queryset` (opt-in there; only lands passes a run_id today -
`run_pilot`/`run_name_frequency_elimination` are a different workload with their
own fetch budgets and resume semantics, and flipping them is a separate decision
with a separate blast radius).
`run_id=None` keeps the pre-change behaviour BYTE-IDENTICALLY, deliberately and
not vestigially: `stream_backstop_sweep.verify_chunk` asks "is there ANY Stage D
backlog", a question about the catalogue rather than about a run, and answering
it run-scoped would report the whole catalogue as backlog on every fresh run_id.
A BUG THIS ALMOST SHIPPED WITH, FOUND BY COMPILING THE SQL RATHER THAN REASONING
ABOUT IT. The obvious spelling is
`.exclude(printing_tags__anonymous_id=X, printing_tags__run_id=Y)`. Django does
NOT combine those into one subquery the same related row must satisfy - it emits
`NOT (EXISTS(... anonymous_id=X ...) AND EXISTS(... run_id=Y ...))`, two
INDEPENDENT clauses. A card carrying THIS identity's vote from an OLD run plus
some OTHER identity's vote from THIS run satisfies both halves and is wrongly
excluded, re-creating the exact cross-run suppression this work removes, in the
hardest direction to notice: fewer cards processed, no error, no counter. It is
the same negated-multi-valued-lookup trap `_eligible_base_queryset`'s own
docstring already documented for the scan-log exclusion. The scoped path uses an
explicit `pk__in` subquery instead; `TestTheCompiledSqlTrap` pins both the SQL
shape and the behaviour.
LAYER 2 - THE SPLIT COMPARES THE VALUE, NOT JUST THE KEY
`_split_new_printing_tag_votes` compared `(card_id, anonymous_id)` alone. It now
compares the whole SET of `(printing_id, is_no_match)` a batch proposes for a
group against what is stored - the shape
`local_illustration._split_new_illustration_votes` has always had, whose own
docstring calls it "THE ONE DIFFERENCE, AND IT IS LOAD-BEARING".
- existing rows, SAME verdict -> skipped, counted in `already_voted`.
Re-running over a converged catalogue stays a no-op, which is what stops
run-scoping becoming an overwrite-everything churn machine.
- existing rows, DIFFERENT verdict -> kept. The purge moves the stale
generation into the archive and the new one lands.
- no existing row -> kept.
Compared PER GROUP, ALL-OR-NOTHING, because one identity can legally hold
several rows for a card (`cardprintingtag_unique_printing_vote` constrains the
triple, not the pair - `run_illustration_calculator`'s loop over
`verdict.printing_pks` is a live caller shaped that way) and the purge is
family-keyed on `card_id`. Keeping only PART of a group would delete the rest
and never re-insert them.
The "skip-and-count, not retract-and-recast" reasoning is untouched: it was
always conditioned on both racing invocations computing the SAME verdict, which
is exactly the case still skipped. What no longer gets swallowed is the case
that reasoning already named as NOT covered - a genuinely changed conclusion.
`local_lands_identify._split_new_votes` needed no change: it already compares the
full (card, printing, anonymous_id) triple, so a changed answer already reached
its purge. A test now pins that rather than leaving it a coincidence.
LAYER 3 - `ArchivedCardPrintingTag`
`purge_stale_machine_votes` copies every row it is about to delete into the
archive first, stamped with the run that overwrote it. That function is THE choke
point for "a machine vote is superseded by a later machine vote", so no caller
can supersede without archiving and no new caller has to remember to.
`vote_write.purge_and_write_votes` supplies `superseded_by_run_id`, derived from
the batch it is writing, and its existing `transaction.atomic()` now covers three
statements instead of two - the same cancel-safety property, one statement wider.
A batch that cannot name a single run_id records NULL rather than a guess: a
wrong stamp is worse than a missing one, since the diff report and issue #575's
janitor both select on it and a plausible wrong value is indistinguishable from a
right one after the fact.
WHY A SEPARATE TABLE AND NOT RETAINED GENERATIONS IN THE LIVE ONE - MEASURED, NOT
PREFERRED. Of the thirteen modules that read `CardPrintingTag`, NINE bypass
`vote_consensus.resolve_vote_weight` entirely: `views.py`, `catalog_stats.py`,
`local_calculate_verdicts.py`, `models.py`, `local_identify_printing_tags.py`,
`soak_gate.py`, `harvest_probe.py`, `illustration_vote.py`,
`local_lands_identify.py`. A zero-weight-by-run_id rule (migration 0097's
pattern) protects only the four that route through weight resolution. A retained
generation left in the live table would still be DISPLAYED by views, COUNTED by
catalog-stats, and - fatally - would make eligibility treat the card as already
voted, re-creating the very suppression this work removes. Keeping the live table
single-generation means no consumer can be wrong about it: no unique-constraint
change, no audit of thirteen modules, no new rule any future reader has to know.
Rows are unreachable from `Card`/`CanonicalCard` (`related_name="+"` on both
FKs), are not an `AbstractWeightedVote` subclass, and copy `created_at` verbatim
rather than re-stamping it, with `archived_at` as the separate honest answer to
"when did this stop being live". Append-only, no unique constraints - the same
shape `CardScanLog` already has. Human votes never reach it: `calculator_family`
returns None for the UUIDs humans use and the purge returns before touching
anything.
Retention is issue #575's janitor's ("keep the N most recent runs per calculator,
sweep the oldest, operator-authorised with a dry run, never delete wholesale").
Both `run_id` (the superseded generation's own run) and `superseded_by_run_id`
(the run that overwrote it) are indexed so a sweep can select a generation
without a table scan.
THE ONLY READER: `manage.py local_calculate_verdicts --generation-diff <path>`,
one JSONL line per vote this run superseded. Per the owner's ruling,
generation-diffing is an opt-in DEBUG FLAG, never a default write path - the
archive WRITE is unconditional (a paper trail that only exists when somebody
remembered to ask for it is not a paper trail); the READ is what is opt-in.
ORDER IS A CORRECTNESS CONSTRAINT, NOT A PERFORMANCE ONE
`fallback`, `illustration` and `slow-path` select POSITIVELY from join-key's
output. So "purge everything, then run the calculators in parallel" gives THREE
OF THE FOUR an empty eligible set - a silent near-no-op that reports success.
Required order stays join-key -> fallback -> illustration -> slow-path, which is
what both dispatchers already do.
This is also why the upstream populations are deliberately NOT run-scoped, and
that asymmetry is the whole correctness argument rather than an oversight: a
converged join-key pass writes NOTHING under a fresh run_id (an identical
recomputed verdict is skipped, and the stored row keeps its original run), so
"cards join-key voted no-match IN THIS RUN" is empty on every re-run. Slow-path's
fallback-voted exclusion must stay unscoped for the same reason and in a worse
direction: a run-scoped version would route a card fallback SOLVED in an earlier
run to a human reviewer.
CONSEQUENCE WORTH KNOWING: THE RETRACTION RUNBOOKS ARE PARTLY OBSOLETED
`reparse_collector_evidence` and `rejudge_fallback_channel`'s two-step runbook
existed partly because a stale scan-log row permanently locked a card out of its
calculator. It no longer does. Their retraction step is still needed to remove
the stale RECORD (and, for votes, to stop a stale row being counted by
consensus), but it is no longer what unlocks eligibility. Those tests now assert
the exclusion under the recorded row's OWN run_id and say why, rather than
quietly pinning the weaker claim.
A RESUMPTION GAP THAT IS ACCEPTED, NOT OVERLOOKED
A card whose verdict a run recomputes as UNCHANGED writes no row, so it carries
no marker for that run and a restarted run recomputes it. Resumption skips
completed WRITES, not completed recomputations. Closing it would mean stamping
the current run_id onto an existing row, destroying the provenance migration
0097's frozen cohort depends on being able to state. Not worth it.
VERIFICATION
- `cardpicker/tests/test_run_scoped_eligibility.py`: 29 new tests across
prior-run/current-run eligibility, the compiled-SQL trap, illustration's own
duplicated copy of the exclusion, changed-verdict overwrite + archiving,
archive-is-not-a-live-vote, superseding-run stamping, dependency ordering
(including the out-of-order empty-set failure, constructed on purpose), and
two- and three-pass convergence.
- 26 MUTATIONS applied one at a time, each run red, then restored: every
exclusion (vote and scan-log, in all four eligibility functions), every
calculator's forwarding of its run_id, the split's value comparison, the
split's skip-if-identical branch, the archive copy, `related_name="+"`,
`created_at` fidelity, `original_id`, `superseded_by_run_id`, and each of the
three upstream populations that must NOT be run-scoped. Three mutants came
back GREEN on the first pass and each was a genuine coverage gap rather than
a false alarm - the illustration calculator's run_id forwarding, and both
call sites of the evidence-transfer stamp - so tests were added until every
one went red.
- A pre-existing latent flake fixed on the way past:
`test_local_illustration.TestPrintingsForIllustration::
test_the_scope_reaches_the_compiled_sql` asserted `str(pk) not in sql`, which
is only true while the pk is a digit string appearing nowhere else - and the
query embeds `illustration_id` UUIDs verbatim, so a single-digit pk is a
substring of almost any of them. It passed or failed on where the sequence
happened to be, i.e. on which other test files ran first. Now asserted on the
predicate it actually means.
- Full `pytest cardpicker`: 3192 passed, 8 skipped. black, ruff, isort, mypy
clean; docs-lint clean; `makemigrations --check`: no changes, single leaf.
MIGRATION NUMBERING - 0100, AND A DEPENDENCY THAT DOES NOT EXIST YET
`0100_superseded_card_printing_tag_archive`, depending on
`0099_rename_printings_count_catalogued` (PR #601). THAT MIGRATION DOES NOT EXIST ANYWHERE YET -
not on master, and not yet on #601's own branch, which still carries the file under its original
name `0098_rename_printings_count_catalogued`. The name assumed here is #601's file renumbered
0098 -> 0099 with its slug unchanged, exactly the transformation #576 performed
(`0096_freeze_...` -> `0097_freeze_...`, slug preserved). IF #601 LANDS UNDER ANY OTHER NAME THIS
STRING MUST BE CORRECTED BEFORE MERGE, or `migrate` fails with NodeNotFoundError and no test
database can be built on any branch. THIS PR THEREFORE CANNOT MERGE BEFORE #601.
The chain 0098 (#573, merged) -> 0099 (#601) -> 0100 (this) is coordinator-assigned. There is no
substantive ordering constraint between the three - #601 renames a column on
`cardpicker_canonicalcard`, #573 added columns to `cardpicker_cardillustrationvote`, this creates a
new table - the chain exists solely to keep `cardpicker` at a SINGLE LEAF NODE.
HOW THIS WAS ORIGINALLY GOT WRONG, recorded so the reasoning is not repeated. It was first numbered
0098-on-0097 on the then-correct reasoning that #573 was still open and that depending on a
migration absent from master makes a branch unmigratable today, with certainty, to avoid a
collision that might never happen. #573 then MERGED, inverting the trade-off: the collision stopped
being hypothetical and became a fact on master - and one invisible to every normal signal, since
different filenames mean no textual conflict, GitHub still reported the PR mergeable, and CI stayed
green because it had run against the pre-#573 tree. Only `makemigrations --check` and the migration
loader's leaf count catch it.
VERIFIED AFTER THE RENUMBER: `makemigrations --check --dry-run` reports "No changes detected";
`MigrationLoader.graph.leaf_nodes()` returns exactly one cardpicker leaf,
`0100_superseded_card_printing_tag_archive`, with the forward plan ending
0097 -> 0098 -> 0099 -> 0100. Verified against a LOCAL STUB standing in for #601's migration (no
operations, so it cannot alter model state); the stub is not committed.
DOCS: TWO RUNBOOK CLAIMS THIS INVALIDATES, CORRECTED IN PLACE
- `docs/troubleshooting.md`'s "A reparse_collector_evidence/Stage D retraction pass silently
never routes its own newly-touched cards to slow-path review" is RESOLVED by this change, and
NOT by the fix it had spec'd. Run-scoping means a stale `stage-d-slow-path-v1` marker no longer
excludes anything from a new run. The spec'd fix - teach `reparse_and_retract` to also delete
the slow-path row - was deliberately NOT built: it would make every retraction command
responsible for knowing which downstream calculators had left markers, which is the coupling
that produced the symptom.
- `docs/features/stage-e-operations.md`'s `rejudge_fallback_channel` runbook described retraction
as "making those cards eligible for a fresh local_calculate_verdicts pass". That was the
mechanism and no longer is. Revised to say what retraction still buys: removing a stale RECORD,
which for a VOTE is load-bearing (an un-retracted stale vote keeps its consensus weight until
something overwrites it), while eligibility is now unlocked by every new run regardless.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…master Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5f34ad5 to
5d9cd51
Compare
Rebased onto
|
| Mutant | Result |
|---|---|
Delete run_id narrowing on the own-CardScanLog exclude |
red — killed by test_a_PRIOR_run_s_scan_log_row_does_not_suppress_a_new_run |
Delete run_id from the own-vote CardPrintingTag narrowing |
GREEN — survived the entire suite (3381 passed) |
The function run-scopes two self-suppressing excludes and only the abstention half was asserted anywhere. The vote half — the one the owner directive is actually about — was covered by no test at all. Fixed by adding TestRunLandsIdentify::test_this_run_s_own_VOTE_removes_the_card_from_the_land_pool and ::test_a_PRIOR_run_s_vote_does_not_suppress_a_new_run; the mutant now dies on the second.
Full pytest cardpicker/tests/: 3383 passed, 11 skipped. Merged with #615 on a scratch tree: clean merge, single leaf, 3350 passed.
🤖 Generated with Claude Code
…der and no rows (#615) * Retire PrintingTagVote: the vote channel that had no resolver, no reader and no rows Owner ruling, 2026-07-29: "i am willing to not need the printing tag. i am happy to reduce things to the minimum that gives us our expected results." PR #599's investigation measured the case against production and it is not close. `PrintingTagVote` held 0 rows, 0 of them human, 0 ever resolved. It had no consensus resolver anywhere in the repo's history - `printing_tag_consensus.py`, which two docstrings forward-referenced, never existed on any branch - no reader outside the Django admin, and no frontend caller of its submit endpoint. Its only machine writer, `manage.py import_external_ip_tags`, never ran once. Underneath that sits the reason it was unsalvageable rather than merely unused: an imported Scryfall fact is not a disputable claim, and `resolve_weighted_consensus`'s `has_human_backed` gate is absolute, so a machine-only channel returns None at any volume. It was built to carry an indisputable fact through a mechanism designed for disputable ones. Removed: the model and its table (migration 0101), `POST 2/submitPrintingTagVote/` and its route, the admin registration, the importer and its tests, and the `PrintingTagVote` arm of `purge_machine_votes` (its report now enumerates three vote tables, not four - the always-zero counter was dropped rather than pinned, since a purge report naming a table that no longer exists is a claim about work it did not do). NOT touched, despite adjacent names: `CardPrintingTag` (167,229 rows, the whole Stage D printing channel) and `CardTagVote` (223,999 rows, resolves into `Card.tags`); nor `PRINTING_TAG_MIN_VOTES` / `PRINTING_TAG_IMPLICIT_CAP` / `PRINTING_TAG_MACHINE_WEIGHT`, which are the app-wide consensus weights; nor `_split_new_printing_tag_votes`, a `CardPrintingTag` collision guard. Knowledge preserved rather than evaporated. `docs/features/printing-tags.md`'s External-IP entry is rewritten as a retirement record carrying the deleted importer's algorithm (the art_tags BFS-to-fixpoint over `child_ids`, the two-pass tag/tagging split, the `illustration_id -> default_cards -> CanonicalCard` join), the measured numbers on both sides (`promo_types` 10,407 vs `art:external-ip` ~13,166, an uncharacterised ~2,759 delta), what a rebuild must not carry, and the commit to `git show` the deleted file from. `EXTERNAL_IP_TAG_NAME` moved to `reason_tags.py` so the one-Tag.name convergence contract outlives the code that used to honour it. `vote_write`'s `target_field` parameter lost its only production caller, so its two tests are re-pointed at `CardPrintingTag.printing_id` rather than deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * Repoint 0101_delete_printingtagvote at 0100; merge order is #604 then #615 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Retire scryfall-tagger-v1 from the fidelity-gate roster and the tether test Master's #588 added a roster entry and a real-repo tether assertion for scryfall-tagger-v1, the one calculator identity declared under management/commands/. This PR deletes that command, so both went stale on rebase: a broken path link in docs/pipeline-fidelity-gate.md and a tether assertion for an identity that no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… production Every change of the last three days was verified in isolation; none was verified in composition. This enumerates every vote channel, calculator identity, attribute-chip channel and Stage C extractor from code, measures each against production, and traces each to the engine that can reach it. Four findings, ranked: 1. Frame-style (Old/Modern Border) and bleed-edge (appropriate-bleed) chips are at ZERO machine rows with no substitute channel AND no wiring into either engine. 142,633 + 2,786 votes are re-derivable from stored ImageEvidence with no image fetch. Border chips survived the same purge only because they were being computed twice. 2. image_evidence.extract_card_evidence has zero production callers - only tests. Both engines call compute_card_evidence directly. It is the only non-pilot caller of cast_border_attribute_vote. Same defect shape as the artist recovery PR #581 fixed. 3. _slow_path_eligible_cards_queryset has no exclusion for the illustration calculator's identity; the code comment says the wiring "would need" it. A card illustration resolves is still routed to a human. PR #604 does not close this. 4. Four Stage D readers gate on one extractor key and then read six extractors' fields ungated. Two degrade in the strict direction, one into a permanently non-rescannable frame-mismatch skip. Also: local-name-frequency-v1 and scryfall-tagger-v1 have zero PilotRunLedger rows ever - never run, not merely dormant. docs/reference/skip-reasons.md was checked value by value against production and holds unchanged. READ-ONLY: no write, no management command, no migration, no deploy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
… production Every change of the last three days was verified in isolation; none was verified in composition. This enumerates every vote channel, calculator identity, attribute-chip channel and Stage C extractor from code, measures each against production, and traces each to the engine that can reach it. Four findings, ranked: 1. Frame-style (Old/Modern Border) and bleed-edge (appropriate-bleed) chips are at ZERO machine rows with no substitute channel AND no wiring into either engine. 142,633 + 2,786 votes are re-derivable from stored ImageEvidence with no image fetch. Border chips survived the same purge only because they were being computed twice. 2. image_evidence.extract_card_evidence has zero production callers - only tests. Both engines call compute_card_evidence directly. It is the only non-pilot caller of cast_border_attribute_vote. Same defect shape as the artist recovery PR #581 fixed. 3. _slow_path_eligible_cards_queryset has no exclusion for the illustration calculator's identity; the code comment says the wiring "would need" it. A card illustration resolves is still routed to a human. PR #604 does not close this. 4. Four Stage D readers gate on one extractor key and then read six extractors' fields ungated. Two degrade in the strict direction, one into a permanently non-rescannable frame-mismatch skip. Also: local-name-frequency-v1 and scryfall-tagger-v1 have zero PilotRunLedger rows ever - never run, not merely dormant. docs/reference/skip-reasons.md was checked value by value against production and holds unchanged. READ-ONLY: no write, no management command, no migration, no deploy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
… production (#616) Every change of the last three days was verified in isolation; none was verified in composition. This enumerates every vote channel, calculator identity, attribute-chip channel and Stage C extractor from code, measures each against production, and traces each to the engine that can reach it. Four findings, ranked: 1. Frame-style (Old/Modern Border) and bleed-edge (appropriate-bleed) chips are at ZERO machine rows with no substitute channel AND no wiring into either engine. 142,633 + 2,786 votes are re-derivable from stored ImageEvidence with no image fetch. Border chips survived the same purge only because they were being computed twice. 2. image_evidence.extract_card_evidence has zero production callers - only tests. Both engines call compute_card_evidence directly. It is the only non-pilot caller of cast_border_attribute_vote. Same defect shape as the artist recovery PR #581 fixed. 3. _slow_path_eligible_cards_queryset has no exclusion for the illustration calculator's identity; the code comment says the wiring "would need" it. A card illustration resolves is still routed to a human. PR #604 does not close this. 4. Four Stage D readers gate on one extractor key and then read six extractors' fields ungated. Two degrade in the strict direction, one into a permanently non-rescannable frame-mismatch skip. Also: local-name-frequency-v1 and scryfall-tagger-v1 have zero PilotRunLedger rows ever - never run, not merely dormant. docs/reference/skip-reasons.md was checked value by value against production and holds unchanged. READ-ONLY: no write, no management command, no migration, no deploy. Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e name-only face reading `_slow_path_eligible_cards_queryset` excluded `already_routed` and `fallback_voted` and had NOTHING for the illustration calculator. `management/commands/local_calculate_verdicts.py`'s own sequencing comment admitted it - "the slow-path queryset would need an additional exclusion for this identity's votes". PR #604 did not close it. Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences join-key -> fallback -> illustration -> slow-path, so a card the illustration calculator resolves is routed to a reviewer moments later in the SAME invocation, asking a human to identify a card the pipeline just identified. Bounded so far only because `stage-d-illustration-v2` has never run; the read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing votes, so it fires on the first `-v2` run. This is a pre-fire fix. One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote is the calculator CONCLUDING it cannot identify the card - precisely a card a reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT run-scoped, because "illustration has a confident vote for this card" is a statement about the catalogue rather than about a run. The identity is a duplicated literal per this module's established "no hard import-time dependency between sibling engines" convention - with a test asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future `-v3` bump fails there instead of silently reopening the defect. ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be populated in production. 1,594 of 113,224 printings get a non-empty list, and 60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) - non-empty lists carrying no usable illustration, which satisfy the partial index `cpm_face_illustrations_present`. Any consumer reading list truthiness as "has back-face art" is wrong for exactly those 60 the moment the importer runs. Audited every consumer. BOTH are already correct: `IllustrationIndex._build` tests `illustration_id is None`, and `printings_for_illustration`'s JSONB containment asks for a real uuid a `None` cannot satisfy. The version stamp's `.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a name-only row is the correct behaviour. Nothing to fix - so the readings are pinned with a name-only fixture instead, proven by mutation to fail against the truthiness reading in both consumers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…e name-only face reading `_slow_path_eligible_cards_queryset` excluded `already_routed` and `fallback_voted` and had NOTHING for the illustration calculator. `management/commands/local_calculate_verdicts.py`'s own sequencing comment admitted it - "the slow-path queryset would need an additional exclusion for this identity's votes". PR #604 did not close it. Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences join-key -> fallback -> illustration -> slow-path, so a card the illustration calculator resolves is routed to a reviewer moments later in the SAME invocation, asking a human to identify a card the pipeline just identified. Bounded so far only because `stage-d-illustration-v2` has never run; the read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing votes, so it fires on the first `-v2` run. This is a pre-fire fix. One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote is the calculator CONCLUDING it cannot identify the card - precisely a card a reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT run-scoped, because "illustration has a confident vote for this card" is a statement about the catalogue rather than about a run. The identity is a duplicated literal per this module's established "no hard import-time dependency between sibling engines" convention - with a test asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future `-v3` bump fails there instead of silently reopening the defect. ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be populated in production. 1,594 of 113,224 printings get a non-empty list, and 60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) - non-empty lists carrying no usable illustration, which satisfy the partial index `cpm_face_illustrations_present`. Any consumer reading list truthiness as "has back-face art" is wrong for exactly those 60 the moment the importer runs. Audited every consumer. BOTH are already correct: `IllustrationIndex._build` tests `illustration_id is None`, and `printings_for_illustration`'s JSONB containment asks for a real uuid a `None` cannot satisfy. The version stamp's `.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a name-only row is the correct behaviour. Nothing to fix - so the readings are pinned with a name-only fixture instead, proven by mutation to fail against the truthiness reading in both consumers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…cratch `run_image_evidence_cohort`'s resume filter asked an IDENTITY-scoped question - "has this card ever been extracted at these manifest versions" - so a run could never redo work, only ever see a shrinking subset of the previous run's pool. PR #604 established the owner's ruling for Stage D ("a bulk run redoes everything from scratch; flags tell it to narrow"; "prior runs cannot pollute, but the current run can be resumed") and never reached Stage C. This applies the same predicate one layer up, via `ImageEvidence.run_id` (already stamped unconditionally by `persist_evidence` and `transfer_evidence`, already indexed). The second consequence this closes: a field added WITHOUT a version bump was permanently unreachable. `bleed_diff_mm` is NULL on 215,921/220,579 rows (97.9%), 213,131 of them on rows whose `bleed_class` is a confident `bleed`, so the extractor's abstain path does not explain it - every row carries the one `geometry-bleed-v1` version, so a v1 manifest written before the field existed reads as current and is skipped forever. Within-run resume is preserved and tested: `--run-id <the killed run's id>` skips exactly what that run finished. The command prints its own resume line at startup, and `--max-rss-mb`'s help no longer claims a bare re-invocation resumes. `--only-never-extracted` narrows back to the old identity-scoped predicate as an opt-in flag, per "default the default things, disable them with flags". `--card-ids-file` is GENERALISED rather than duplicated: forcing re-extraction is now what the default does, so the flag's hardcoded resume-filter bypass is gone and it reduces to the pure scope narrowing its name promises. Strict gain - a killed targeted re-extraction now resumes, which under the bypass it could not. Its existing test claimed to prove that bypass while seeding `{key: "v1"}`, which fails the version-aware filter regardless; it now seeds the CURRENT version map and proves the run-scoped mechanism that replaced the bypass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…e name-only face reading (#655) `_slow_path_eligible_cards_queryset` excluded `already_routed` and `fallback_voted` and had NOTHING for the illustration calculator. `management/commands/local_calculate_verdicts.py`'s own sequencing comment admitted it - "the slow-path queryset would need an additional exclusion for this identity's votes". PR #604 did not close it. Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences join-key -> fallback -> illustration -> slow-path, so a card the illustration calculator resolves is routed to a reviewer moments later in the SAME invocation, asking a human to identify a card the pipeline just identified. Bounded so far only because `stage-d-illustration-v2` has never run; the read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing votes, so it fires on the first `-v2` run. This is a pre-fire fix. One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote is the calculator CONCLUDING it cannot identify the card - precisely a card a reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT run-scoped, because "illustration has a confident vote for this card" is a statement about the catalogue rather than about a run. The identity is a duplicated literal per this module's established "no hard import-time dependency between sibling engines" convention - with a test asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future `-v3` bump fails there instead of silently reopening the defect. ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be populated in production. 1,594 of 113,224 printings get a non-empty list, and 60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) - non-empty lists carrying no usable illustration, which satisfy the partial index `cpm_face_illustrations_present`. Any consumer reading list truthiness as "has back-face art" is wrong for exactly those 60 the moment the importer runs. Audited every consumer. BOTH are already correct: `IllustrationIndex._build` tests `illustration_id is None`, and `printings_for_illustration`'s JSONB containment asks for a real uuid a `None` cannot satisfy. The version stamp's `.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a name-only row is the correct behaviour. Nothing to fix - so the readings are pinned with a name-only fixture instead, proven by mutation to fail against the truthiness reading in both consumers. Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…cratch (#645) `run_image_evidence_cohort`'s resume filter asked an IDENTITY-scoped question - "has this card ever been extracted at these manifest versions" - so a run could never redo work, only ever see a shrinking subset of the previous run's pool. PR #604 established the owner's ruling for Stage D ("a bulk run redoes everything from scratch; flags tell it to narrow"; "prior runs cannot pollute, but the current run can be resumed") and never reached Stage C. This applies the same predicate one layer up, via `ImageEvidence.run_id` (already stamped unconditionally by `persist_evidence` and `transfer_evidence`, already indexed). The second consequence this closes: a field added WITHOUT a version bump was permanently unreachable. `bleed_diff_mm` is NULL on 215,921/220,579 rows (97.9%), 213,131 of them on rows whose `bleed_class` is a confident `bleed`, so the extractor's abstain path does not explain it - every row carries the one `geometry-bleed-v1` version, so a v1 manifest written before the field existed reads as current and is skipped forever. Within-run resume is preserved and tested: `--run-id <the killed run's id>` skips exactly what that run finished. The command prints its own resume line at startup, and `--max-rss-mb`'s help no longer claims a bare re-invocation resumes. `--only-never-extracted` narrows back to the old identity-scoped predicate as an opt-in flag, per "default the default things, disable them with flags". `--card-ids-file` is GENERALISED rather than duplicated: forcing re-extraction is now what the default does, so the flag's hardcoded resume-filter bypass is gone and it reduces to the pure scope narrowing its name promises. Strict gain - a killed targeted re-extraction now resumes, which under the bypass it could not. Its existing test claimed to prove that bypass while seeding `{key: "v1"}`, which fails the version-aware filter regardless; it now seeds the CURRENT version map and proves the run-scoped mechanism that replaced the bypass. Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…mode (#650) * Record why channel_report is not auto-gating yet (#628) Owner ruling 2026-07-30: it stays an operator step for now. The decision belongs in the published doc, not a PR comment. The command is built to gate, and the roster is KNOWINGLY dirty - ZERO_DECLARATIONS is empty by design and the coverage audit found three genuinely silent channels. Auto-gating today means an unrelated command fails because a channel nobody was touching has been silent for a week. Sequence recorded: run deliberately -> first real reading -> rule on each zero -> then wire it in, at which point a new zero genuinely means something regressed. Also states that the first production reading is EXPECTED to exit 1, so nobody reads a red first run as a broken tool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * docs: record the push-loses-the-race-with-squash-merge failure mode Second occurrence: PR #646's final commit landed 76 seconds after the squash-merge fired, so it never reached master. PR #604's 82360a9 was the first. Nothing in GitHub or CI reports this - the PR reads MERGED and CI reads green. Extends the existing squash-merge orphan entry rather than adding a sibling: same mechanism, narrower window. Adds the mergedAt vs branch-tip sweep so it can be detected mechanically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * docs: correct recovery PR number to #650 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Stacked on #603 (the
run_idaudit). Review that first; this PR's base is that branch.Why these three are one PR and not three
They are not independently reviewable, and shipping any one alone is worse than shipping none:
purge_and_write_votesscopes its purge to the rows being written, a dropped row purges nothing. The stale vote survives verbatim, with no error, no counter moving, and a recomputation's worth of work discarded. Two independent suppression layers, and the second silently defeats a fix to the first.Layer 1 — run-scoped eligibility
Every Stage D printing-channel calculator asked "have I EVER voted on / abstained on this card?". That predicate grows monotonically, so each pass could only ever see a subset of the previous pass's pool, and a repaired engine could never re-examine anything the broken one had answered.
Not hypothetical:
stage-d-illustrationhad to be version-bumped v1 → v2 purely to escape its own scan-log rows after itslayout_classgate turned out to be reading a border colour — 3,409 wrongly-skipped cards were otherwise unreachable to a repaired v1. Under run-scoping the repair alone would have sufficed.Both self-suppressing excludes now additionally match the current
run_id, in_eligible_cards_queryset(join-key + fallback),_slow_path_eligible_cards_queryset,_eligible_illustration_cards_queryset, and_eligible_base_queryset(opt-in there; only lands passes arun_idtoday —run_pilot/run_name_frequency_eliminationare a different workload with their own fetch budgets and resume semantics, and flipping them is a separate decision with a separate blast radius).run_id=Nonekeeps the pre-change behaviour byte-identically, deliberately and not vestigially:stream_backstop_sweep.verify_chunkasks "is there ANY Stage D backlog", a question about the catalogue rather than about a run, and answering it run-scoped would report the whole catalogue as backlog on every freshrun_id.A bug this almost shipped with, found by compiling the SQL rather than reasoning about it
The obvious spelling is
.exclude(printing_tags__anonymous_id=X, printing_tags__run_id=Y). Django does not combine those into one subquery the same related row must satisfy — it emitstwo independent clauses. A card carrying this identity's vote from an old run plus some other identity's vote from this run satisfies both halves and is wrongly excluded — re-creating the exact cross-run suppression this work removes, in the hardest direction to notice: fewer cards processed, no error, no counter. It is the same negated-multi-valued-lookup trap
_eligible_base_queryset's own docstring already documented for the scan-log exclusion. The scoped path uses an explicitpk__insubquery instead;TestTheCompiledSqlTrappins both the SQL shape and the behaviour.Layer 2 — the split compares the VALUE, not just the key
_split_new_printing_tag_votescompared(card_id, anonymous_id)alone. It now compares the whole set of(printing_id, is_no_match)a batch proposes for a group against what is stored — the shapelocal_illustration._split_new_illustration_voteshas always had, whose own docstring calls it "THE ONE DIFFERENCE, AND IT IS LOAD-BEARING".already_voted— re-running over a converged catalogue stays a no-opCompared per group, all-or-nothing, because one identity can legally hold several rows for a card (
cardprintingtag_unique_printing_voteconstrains the triple, not the pair —run_illustration_calculator's loop oververdict.printing_pksis a live caller shaped that way) and the purge is family-keyed oncard_id. Keeping only part of a group would delete the rest and never re-insert them.The "skip-and-count, not retract-and-recast" reasoning is untouched: it was always conditioned on both racing invocations computing the same verdict, which is exactly the case still skipped. What no longer gets swallowed is the case that reasoning already named as not covered — a genuinely changed conclusion.
local_lands_identify._split_new_votesneeded no change (it already compares the full triple). A test now pins that rather than leaving it a coincidence.Layer 3 —
ArchivedCardPrintingTagpurge_stale_machine_votescopies every row it is about to delete into the archive first, stamped with the run that overwrote it. That function is the choke point for "a machine vote is superseded by a later machine vote", so no caller can supersede without archiving and no new caller has to remember to.vote_write.purge_and_write_votessuppliessuperseded_by_run_id; its existingtransaction.atomic()now covers three statements instead of two — same cancel-safety property, one statement wider. A batch that cannot name a singlerun_idrecordsNULLrather than a guess: a wrong stamp is worse than a missing one, since the diff report and #575's janitor both select on it.Why a separate table and not retained generations in the live one — measured, not preferred
Of the 13 modules that read
CardPrintingTag, 9 bypassresolve_vote_weightentirely:views.py,catalog_stats.py,local_calculate_verdicts.py,models.py,local_identify_printing_tags.py,soak_gate.py,harvest_probe.py,illustration_vote.py,local_lands_identify.py. A zero-weight-by-run_idrule (migration 0097's pattern) protects only the four that route through weight resolution. A retained generation left in the live table would still be displayed by views, counted by catalog-stats, and — fatally — would make eligibility treat the card as already voted, re-creating the very suppression this work removes.Keeping the live table single-generation means no consumer can be wrong about it: no unique-constraint change, no audit of thirteen modules, no new rule any future reader has to know.
Rows are unreachable from
Card/CanonicalCard(related_name="+"on both FKs), are not anAbstractWeightedVotesubclass, and copycreated_atverbatim rather than re-stamping it (archived_atis the separate honest answer to "when did this stop being live"). Append-only, no unique constraints — the same shapeCardScanLogalready has. Human votes never reach it:calculator_familyreturnsNonefor the UUIDs humans use and the purge returns before touching anything.Retention is #575's janitor's ("keep the N most recent runs per calculator, sweep the oldest, operator-authorised with a dry run, never delete wholesale"). Both
run_idandsuperseded_by_run_idare indexed so a sweep can select a generation without a table scan.The only reader is
manage.py local_calculate_verdicts --generation-diff <path>. Per the owner's ruling, generation-diffing is an opt-in debug flag, never a default write path — the archive write is unconditional (a paper trail that only exists when somebody remembered to ask for it is not a paper trail); the read is what is opt-in.Order is a CORRECTNESS constraint, not a performance one
fallback,illustrationandslow-pathall select positively from join-key's output. So "purge everything, then run the calculators in parallel" gives three of the four an empty eligible set — a silent near-no-op that reports success. Required order stays join-key → fallback → illustration → slow-path, which is what both dispatchers already do.This is also why the upstream populations are deliberately not run-scoped, and that asymmetry is the whole correctness argument rather than an oversight: a converged join-key pass writes nothing under a fresh
run_id, so "cards join-key voted no-match in this run" is empty on every re-run. Slow-path's fallback-voted exclusion must stay unscoped for the same reason and in a worse direction: a run-scoped version would route a card fallback solved in an earlier run to a human reviewer.TestDependencyOrderingconstructs the out-of-order failure on purpose — a suite that only ever runs them in the right order is not evidence that the order matters.Consequence worth knowing: the retraction runbooks are partly obsoleted
reparse_collector_evidenceandrejudge_fallback_channel's two-step runbook existed partly because a stale scan-log row permanently locked a card out of its calculator. It no longer does. Their retraction step is still needed to remove the stale record (and, for votes, to stop a stale row being counted by consensus), but it is no longer what unlocks eligibility. Those tests now assert the exclusion under the recorded row's ownrun_idand say why, rather than quietly pinning the weaker claim.A resumption gap that is accepted, not overlooked
A card whose verdict a run recomputes as unchanged writes no row, so it carries no marker for that run and a restarted run recomputes it. Resumption skips completed writes, not completed recomputations. Closing it would mean stamping the current
run_idonto an existing row — destroying the provenance migration 0097's frozen cohort depends on being able to state. Not worth it.Verification
cardpicker/tests/test_run_scoped_eligibility.py: 29 new tests — prior-run/current-run eligibility, the compiled-SQL trap, illustration's own duplicated copy of the exclusion, changed-verdict overwrite + archiving, archive-is-not-a-live-vote, superseding-run stamping, dependency ordering (including the empty-set failure), and two- and three-pass convergence.run_id, the split's value comparison, the split's skip-if-identical branch, the archive copy,related_name="+",created_atfidelity,original_id,superseded_by_run_id, and each of the three upstream populations that must not be run-scoped.Three mutants came back green on the first pass and each was a genuine coverage gap rather than a false alarm — the illustration calculator's
run_idforwarding, and both call sites of the evidence-transfer stamp — so tests were added until every one went red.test_local_illustration.TestPrintingsForIllustration::test_the_scope_reaches_the_compiled_sqlassertedstr(pk) not in sql, which is only true while the pk is a digit string appearing nowhere else — and the query embedsillustration_idUUIDs verbatim, so a single-digit pk is a substring of almost any of them. It passed or failed on where the sequence happened to be, i.e. on which other test files ran first. Now asserted on the predicate it actually means.pytest cardpicker: 3192 passed, 8 skipped. black, ruff, isort, mypy clean; docs-lint clean;makemigrations --check: no changes, single leaf.0100, on a dependency that does not exist yetRebased onto current
origin/master(post-#573, #579, #581, #582, #584, #586).0100_superseded_card_printing_tag_archive, depending on0099_rename_printings_count_catalogued(PR #601).That migration does not exist anywhere yet — not on
master, and not yet on #601's own branch, which still carries the file under its original name0098_rename_printings_count_catalogued. The name assumed here is #601's file renumbered0098 → 0099with its slug unchanged, exactly the transformation #576 performed (0096_freeze_… → 0097_freeze_…, slug preserved). If #601 lands under any other name this dependency string must be corrected before merge, ormigratefails withNodeNotFoundErrorand no test database can be built on any branch.This PR therefore cannot merge before #601.
The chain
0098(#573, merged) →0099(#601) →0100(this) is coordinator-assigned. There is no substantive ordering constraint between the three — #601 renames a column oncardpicker_canonicalcard, #573 added columns tocardpicker_cardillustrationvote, this creates a new table. The chain exists solely to keepcardpickerat a single leaf node; two leaves makemanage.py migraterefuse to run at all, which breaks test-database setup on every branch in the repo, not just the offender's.How this was originally got wrong
Recorded so the reasoning is not repeated. It was first numbered
0098-on-0097, on the then-correct reasoning that #573 was still open and that depending on a migration absent frommastermakes a branch unmigratable today, with certainty, to avoid a collision that might never happen. #573 then merged, inverting the trade-off: the collision stopped being hypothetical and became a fact onmaster— and one invisible to every normal signal, since different filenames mean no textual conflict, GitHub still reported this PR mergeable, and CI stayed green because it had run against the pre-#573 tree. Onlymakemigrations --checkand the migration loader's leaf count catch it.Verified after the renumber
makemigrations --check --dry-run→No changes detected, exit 0.MigrationLoader.graph.leaf_nodes()→ exactly onecardpickerleaf,0100_superseded_card_printing_tag_archive; forward plan ends0097 → 0098 → 0099 → 0100.Rebase note: this PR and #579 touch the same querysets
#579 (
push card_ids into every Stage D dependency subquery) landed while this was open and refactored the join-key no-hit population into a shared_join_key_no_hit_subqueries(card_ids)helper. Two conflicts, both resolved by composing rather than choosing:_fallback_eligible_cards_querysetand_slow_path_eligible_cards_querysetkeep #579's helper andcard_idspushdown and gain this PR'srun_idnarrowing (which applies to slow-path's ownalready_routedsub-population only, never to the upstream join-key/fallback ones). The mutants that target those two functions were respelled against the new code shape and re-run — both still red.Docs: two runbook claims this invalidates, corrected in place
docs/troubleshooting.md's "Areparse_collector_evidence/Stage D retraction pass silently never routes its own newly-touched cards to slow-path review" is resolved by this change — and not by the fix it had spec'd. Run-scoping means a stalestage-d-slow-path-v1marker no longer excludes anything from a new run. The spec'd fix (teachreparse_and_retractto also delete the slow-path row) was deliberately not built: it would make every retraction command responsible for knowing which downstream calculators had left markers, which is the coupling that produced the symptom in the first place.docs/features/stage-e-operations.md'srejudge_fallback_channelrunbook described retraction as "making those cards eligible for a freshlocal_calculate_verdictspass". That was the mechanism and no longer is. Revised to say what retraction still buys: removing a stale record, which for a vote is load-bearing (an un-retracted stale vote keeps its consensus weight until something overwrites it) — while eligibility is now unlocked by every new run regardless.Post-rebase verification
Full
pytest cardpicker: 3304 passed, 8 skipped. black, ruff, isort, mypy clean; docs-lint clean. Seven mutations re-run against the rebased tree (including the two respelled for #579's refactor) — all red.🤖 Generated with Claude Code
https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN