Skip to content

Illustration consensus: md5 identity-group pooling and sibling propagation, built in from the first line - #573

Merged
WilfordGrimley merged 1 commit into
masterfrom
feat/illustration-consensus
Jul 29, 2026
Merged

Illustration consensus: md5 identity-group pooling and sibling propagation, built in from the first line#573
WilfordGrimley merged 1 commit into
masterfrom
feat/illustration-consensus

Conversation

@WilfordGrimley

Copy link
Copy Markdown

CardIllustrationVote (issue #524) has been WRITTEN since it landed and read by nothing but the admin and the human write path. printing_consensus.py / artist_consensus.py / tag_consensus.py each reconcile their own vote model; there was no illustration equivalent, so PR #565's projected ~10,277 machine rows would have been recorded rather than reasoned over — and two ratified rulings (the art hash resolves ARTWORK; illustration identity rules premium-vs-base conflicts) depend on them.

1. cardpicker/illustration_consensus.py

Modelled on its two siblings, built on the shared vote_consensus core. No weighting, quorum, or human-backed gate is re-derived here.

is_unknown is an ordinary outcome key, the UNKNOWN sentinel — exactly as artist_consensus treats CardArtistVote.is_unknown and printing_consensus treats is_no_match. The XOR constraint makes the outcome space {uuid} ∪ {UNKNOWN}, and the abstention on this model is the absence of a row (the calculator's skip paths write a CardScanLog and no vote; a human who doesn't answer leaves nothing). A present is_unknown=True row is a positive, falsifiable claim — "someone looked and there is no Scryfall artwork behind this" — so it must be able both to resolve on its own and to contest a uuid. Treating it as an abstention would make an UNKNOWN-vs-uuid disagreement read as an uncontested uuid, and would leave a card everyone agrees is unidentifiable sitting UNRESOLVED forever, indistinguishable from one nobody has looked at.

2. md5 pooling built in from the start

Every read path is group-scoped. resolve_illustration tallies card's whole md5 identity group, pooled per agent by vote_consensus.pool_group_votes, keyed on agent_dedupe_key — the versionless calculator family, not the raw anonymous_id. That is live, not hypothetical, for this calculator specifically: #565 bumped it stage-d-illustration-v1-v2, and a version bump re-votes incrementally, so an md5 group straddling the migration holds rows under both strings at once. A raw-id key would read that as two independent agents and let one calculator buy a whole quorum on a routine redeploy.

The group primitives (md5_group_card_ids, md5_group_cards, _require_full_md5_group, agent_dedupe_key) are imported from printing_consensus, not reimplemented — one definition of a group, one completeness guard, one agent-identity rule.

Doing this now rather than retrofitting is the point of the change. The printing side acquired pooling late and the defect that mattered lived in the seam between the un-pooled original and the retrofit (its own docstrings record both halves: human votes initially left unkeyed, and _require_full_md5_group's "a subset yields a DIFFERENT tally, not a weaker one"). A resolver that is group-scoped from its first line has no such seam — no un-pooled call path left behind, no caller that predates the contract.

md5 is strictly stronger than the perceptual art hash here. Byte-identical files are necessarily the same artwork: no threshold, no tolerance, no distance metric, because identical bytes decode to identical pixels. A phash match is a different claim — two cards can share one and be genuinely different artworks at any radius. Since pooling deliberately suppresses evidence and propagation pushes one member's answer onto another, both are sound only on byte identity. Nothing in the module reads content_phash, structurally: membership has exactly one source.

3. Propagation — a consequence of group-scoped resolution, not a separate step

The calculator needs BOTH evidence AND a candidate-name match. Evidence transfers across an md5 group (evidence_transfer); the decorated NAME does not — so a member whose name fails candidate resolution abstains with no-candidate-match (367 of 2,350 considered cards, ~15.6% in #565's 30,000-card replay) even though a byte-identical sibling resolved cleanly.

Closed by the tally being defined over the group: the abstainer's tally is its sibling's tally, and resolve_and_persist_illustration writes the outcome to every member. It ends up with a resolved inferred_illustration_id with no code path aware it abstained.

The rejected alternative — an explicit step writing a copied CardIllustrationVote row onto the abstainer — is worse on three counts, in order of weight:

  1. It would buy nothing. The row carries the calculator's own fixed anonymous_id, so it pools under the same dedupe_key and collapses to the same event: exactly zero added weight. The only thing it could change is per-card display, which group-scoped persistence already provides. Tested, not merely argued (TestPropagatedVoteRowsWouldBeWeightNeutral).
  2. It manufactures evidence. A vote row records that an agent made a claim. No agent made this one. The pipeline's posture throughout is withhold-never-manufacture.
  3. It collides by construction. CardIllustrationVote's unique constraint on (card, anonymous_id) is unconditional (stage-d-illustration-v1 at N>1 casts N full-weight votes for mutually exclusive printings; the /N confidence spread never reaches the tally #525). A propagated row occupies the exact slot the calculator wants when that member later becomes resolvable, and _purge_and_write_illustration_votes compares the stored value — it could not tell a propagated row from the calculator's own stale answer.

Honest limit, stated as a test (test_machine_only_evidence_does_not_propagate): nothing propagates until the group actually RESOLVES, and the human-backed gate means a machine-only group never does. The ~15.6% are cards that become resolvable once a human weighs in on any member, not cards resolved from machine votes alone.

Persistence, thresholds, reference data

Card.inferred_illustration_id (plain UUIDField, not an FK — no CanonicalIllustration table, mirroring the vote model and CanonicalPrintingMetadata.illustration_id) plus Card.illustration_vote_status (IllustrationVoteStatus, the four members ArtistVoteStatus has), migration 0096. Written for every group member, in pk order so concurrent votes on two members queue rather than deadlock.

ILLUSTRATION_MIN_VOTES / ILLUSTRATION_MIN_SHARE default to PRINTING_TAG_MIN_VOTES / _MIN_SHARE, so this ships changing nothing and the illustration bar can later move without moving the printing bar. Whether it should differ is genuinely open — an illustration claim is strictly coarser (1:N, ~2.2 printings per illustration) so easier to get right, but does less work once resolved — and with 3 votes in production there is no data to settle it, so the defaults settle it by changing nothing. No ILLUSTRATION_MACHINE_WEIGHT: a vote's weight is a property of who cast it and by what method, never of what is being voted on (same argument resolve_vote_weight makes against confidence-scaled weight); per-vote-type weights would also count one agent's CardIllustrationVote and its derived CardPrintingTag differently for one judgement.

Reference data (owner ruling 2026-07-29). This module reads neither CanonicalCard nor CanonicalPrintingMetadata — it tallies uuids off vote rows and stores the winner verbatim, asserted directly by TestReferenceDataIndependence (consensus resolves with zero canonical rows in existence, and for a uuid no metadata row references). What a stale snapshot does, precisely: upstream it changes which uuids exist to be voted for, so the pipeline under-resolves and never mis-resolves from that cause; downstream it changes what a resolved uuid maps to, a consumer-side narrowing done by a live join each consumer performs itself. What it cannot do is change a tally, flip a winner, or move a propagation. If reference data is later found wrong, the votes stand and re-running reproduces the same outcome — the correction belongs at the calculator, which is where it was consulted.

Tests — 50 new, every one proven able to fail

Production holds 3 illustration votes, so this cannot be validated by observation. 24 mutations were applied to the implementation and each of the 50 tests was verified RED under at least one; the full mutation table is in the thread below. Highlights:

Mutation Tests turned red
is_unknown rows dropped (abstention reading) 5
pool_group_votes bypassed 8
agent_dedupe_key → raw anonymous_id 2 (the v1/v2 pair)
only machine votes keyed for pooling 6
tally card-scoped instead of group-scoped 11
persist writes only the caller's card 4
grouping switched from md5 to content_phash 3
is_human_backed=True for every vote 3
every card in one giant group 6
consensus gated on CanonicalPrintingMetadata 15
write path no longer recomputes 2

The phash tests carry a positive control (test_the_same_pair_with_a_matching_md5_does_propagate) that changes only the md5 — without it both negatives would stay green against an implementation that propagates to nothing at all. test_lowering_the_illustration_quorum_does_not_lower_the_printing_one is stated behaviourally (one human vote resolves the illustration, the same voter's printing vote does not resolve the printing) rather than as a comparison of two settings values, which could not fail.

Harness fix — not incidental

test_shared_cache.py::TestSharedCacheTableMigration carries @pytest.mark.django_db(transaction=True), so its migration round-trip really commits — and its finally restored only as far as 0092, leaving every later migration unapplied for the remainder of the pytest session, in a database later tests go on using.

Latent and free until now purely by alphabetical luck: 0093/0094 are data-only RunPython, and 0095 (face_illustrations) is read only by modules sorting before test_shared_cache.py. A migration adding a Card column is the first thing to step on it — test_sources.py and test_stage_e_dispatch.py sort later and use transactional_db, so they write real Card rows through a connection no rollback can save, and failed with column "inferred_illustration_id" ... does not exist: a message pointing squarely at this branch for a defect entirely in that finally. Now restores to graph.leaf_nodes("cardpicker") — derived, so it stays correct for every future migration without anyone remembering this file exists.

Verification

  • Full cardpicker/tests/ suite: 3,090 passed, 9 skipped, 0 failed, against a 3,040-passed baseline on the same commit of master (85d88bf).
  • black / isort / ruff / mypy clean; docs_lint.py clean (no new *_ANONYMOUS_ID, so no roster entry is required — docs/theory.md §4 item 3 and §10a's neighbourhood-lookup list are updated instead, the latter from four live instances to five).
  • Rebased onto master after Per-face illustration ids; delete the border-colour "multi-faced" gate; stage-d-illustration-v2 #565 merged; migration renumbered 0095 → 0096 behind 0095_canonicalprintingmetadata_face_illustrations, makemigrations --check reports no pending changes.

Not in this PR

consensus_recompute is not wired to the illustration domain — the human write path (cast_illustration_vote) recomputes inline, which is the path that actually produces resolutions, but there is no batch recompute over the ~10,277 machine rows yet. Deliberate: a machine-only group cannot resolve, so a batch pass over them today would write nothing, and the wiring touches a file under concurrent edit. Flagged rather than silently omitted.

🤖 Generated with Claude Code

https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

@WilfordGrimley

Copy link
Copy Markdown
Author

Mutation proofs — all 24, and the per-test coverage map

Method: apply one mutation to the implementation (never to the tests), run pytest cardpicker/tests/test_illustration_consensus.py -p no:randomly, record the reds, git checkout -- to restore, confirm green again. Baseline is 50 passed every time.

# Mutation Result Tests turned RED
M1 build_group_illustration_vote_tuples skips is_unknown rows (the abstention reading) 5 failed / 45 passed agreeing_unknown_votes_resolve_to_unknown, unknown_can_outvote_a_named_illustration, resolved_unknown_stores_no_illustration_id, a_contradiction_between_named_and_unknown_is_also_withheld, an_unknown_answer_through_the_write_path_persists_unknown
M2 pool_group_votes never called (return vote_tuples) 8 / 42 one_agent_agreeing_across_two_members_counts_once, pooling_keeps_the_agents_highest_weight, a_self_contradicting_agent_contributes_to_neither_side, withholding_is_order_independent, a_contradiction_between_named_and_unknown_is_also_withheld, a_version_bump_does_not_create_a_second_agent, a_corrective_re_vote_across_a_version_bump_is_withheld, a_copied_sibling_row_changes_no_tally
M3 dedupe_key = vote.anonymous_id instead of agent_dedupe_key(...) 2 / 48 a_version_bump_does_not_create_a_second_agent, a_corrective_re_vote_across_a_version_bump_is_withheld
M4 only machine votes keyed for pooling (humans left unkeyed) 6 / 44 one_agent_agreeing_across_two_members_counts_once, pooling_keeps_the_agents_highest_weight, a_self_contradicting_agent_contributes_to_neither_side, withholding_is_order_independent, a_contradiction_between_named_and_unknown_is_also_withheld, human_uuids_key_on_themselves
M5 group_illustration_votes always card-scoped (card.illustration_votes.all()) 11 / 39 distinct_agents_across_two_members_still_sum, pooling_keeps_the_agents_highest_weight, a_self_contradicting_agent_contributes_to_neither_side, a_consistent_agent_in_the_same_shape_does_resolve, withholding_is_order_independent, a_contradiction_between_named_and_unknown_is_also_withheld, a_corrective_re_vote_across_a_version_bump_is_withheld, human_uuids_key_on_themselves, the_voteless_sibling_resolves_from_its_own_entry_point_too, a_de_resolution_propagates_too, two_internally_consistent_members_disagreeing_reads_as_contested
M6 persist writes only the caller's card, not the group 4 / 46 a_voteless_sibling_inherits_the_groups_resolution, a_de_resolution_propagates_too, the_same_pair_with_a_matching_md5_does_propagate, two_internally_consistent_members_disagreeing_reads_as_contested
M7 md5_group_card_ids groups on content_phash when present 3 / 47 an_identical_phash_does_not_propagate, an_identical_phash_with_no_md5_does_not_propagate, test_module_never_reads_the_perceptual_hash
M8 is_human_backed=True for every vote 3 / 47 machine_votes_alone_never_resolve_however_many_agents, machine_votes_alone_never_resolve_across_an_md5_group, machine_pile_never_overrides_a_human_quorum
M9 threshold captured at import instead of read at call time 1 / 49 the_quorum_setting_is_read_at_call_time
M10 unresolved always UNRESOLVED, never CONTESTED 3 / 47 unknown_contests_a_named_illustration, a_de_resolution_propagates_too, two_internally_consistent_members_disagreeing_reads_as_contested
M11 _require_full_md5_group + the members identity check removed 3 / 47 a_partial_group_raises_rather_than_resolving_differently, a_duplicated_member_raises, persist_rejects_a_substituted_card_instance
M12 cast_illustration_vote no longer recomputes consensus 2 / 48 two_human_votes_through_the_write_path_resolve_and_persist, an_unknown_answer_through_the_write_path_persists_unknown
M13 unresolved always CONTESTED (the inverse of M10) 3 / 47 the_absence_of_a_row_is_the_abstention, machine_only_evidence_does_not_propagate, a_single_vote_is_unresolved_not_contested
M14 machine votes dropped entirely 2 / 48 one_human_vote_promotes_agreeing_machine_weight, a_version_bump_does_not_create_a_second_agent
M15 singleton branch removed — a group of one takes the group query and is pooled 3 / 47 a_checksum_less_card_is_its_own_group_and_is_not_pooled, a_unique_checksum_card_is_its_own_group, the_singleton_read_honours_a_prefetch
M16 resolve_vote_weight bypassed — every vote at machine weight 20 / 30 (broad) incl. two_agents_on_a_singleton_resolve_exactly_as_before, both TestReferenceDataIndependence tests, both write-path tests
M17 every card in one giant group 6 / 44 a_unique_checksum_card_is_its_own_group, the_singleton_read_honours_a_prefetch, a_non_sibling_does_not_inherit, an_identical_phash_does_not_propagate, an_identical_phash_with_no_md5_does_not_propagate, an_empty_string_md5_is_not_an_identity
M18 ILLUSTRATION_MIN_VOTES default diverges from the printing one (3.0) 17 / 33 (broad) incl. defaults_match_the_printing_thresholds
M19 printing_consensus.resolve_printing wired to illustration_min_votes() 1 / 49 lowering_the_illustration_quorum_does_not_lower_the_printing_one
M20 display tally becomes group-scoped and drops is_unknown rows 2 / 48 the_tally_is_card_scoped_not_group_scoped, the_tally_counts_unknown_as_its_own_outcome
M21 contested query loses its is_unknown sentinel 1 / 49 contested_card_ids_flags_a_named_illustration_against_unknown
M22 contested query's outcome_fieldcard_id (distinct outcomes always 1) 1 / 49 contested_card_ids_flags_two_illustrations
M23 resolution gated on a CanonicalPrintingMetadata row carrying the winning uuid 15 / 35 (broad) incl. both TestReferenceDataIndependence tests

Coverage: 50 of 50 tests fail under at least one mutation. Two were strengthened after the first batch showed them passing for the wrong reason, and are re-proven above:

  • machine_only_evidence_does_not_propagate originally used 2 machine agents (1.0 weight) and stayed green under M8 — it was falling short of quorum, not being stopped by the human-backed gate. Now uses 6 agents (3.0 weight, 100% share) so the gate is the only thing in the way.
  • lowering_the_illustration_quorum_does_not_lower_the_printing_one originally compared two settings values inside an override_settings block, which could not fail. Restated behaviourally against resolve_printing; M19 is the plausible wiring mistake it now catches.

@WilfordGrimley
WilfordGrimley force-pushed the feat/illustration-consensus branch from 431fd73 to a61ebe5 Compare July 29, 2026 15:55
@WilfordGrimley

Copy link
Copy Markdown
Author

Rebased onto master; migration renumbered 00960098

Migration

0096_card_illustration_consensus_fields0098_card_illustration_consensus_fields, dependency repointed from 0095 to 0097_freeze_deductive_backfill_zero_weight_cohort.

Two migrations named 0096 are already on master (#568's 0096_card_scan_log_anon_skip_idx and #570's freeze migration). #576 linearises them as 0096 → 0097_freeze, making 0098 the next free number.

Verified: graph.leaf_nodes("cardpicker") returns exactly [('cardpicker', '0098_card_illustration_consensus_fields')], makemigrations --check --dry-run reports No changes detected, and showmigrations --plan is a single chain 0095 → 0096 → 0097 → 0098.

⚠️ Merge-order requirement

This PR must not merge before #576. Its dependency names 0097_freeze_…, which only exists once #576 lands. Until then CI here will fail at the migrate step — that is expected, not a defect in this branch. (Happy to retarget this PR's base onto fix/migration-graph-single-leaf instead if you'd prefer green CI now; left on master because it was not asked for.)

Semantic conflict fixed

The rebase brought in #570, which re-scoped the deductive-backfill zero-weight rule from the calculator FAMILY to one RUN and so changed the signature to resolve_vote_weight(source, anonymous_id, run_id). illustration_consensus.py is a new file, so git had no conflict to report — but its call still passed two arguments:

TypeError: resolve_vote_weight() missing 1 required positional argument: 'run_id'

50 tests failed on the raw rebase (38 in test_illustration_consensus.py, 12 in test_illustration_vote.py), all from this single cause.

Fixed by passing vote.run_id straight off the row — identical to the sibling call in printing_consensus.py, which #570 updated the same way. CardIllustrationVote inherits run_id from AbstractWeightedVote, and this file's own docstring already states the ruling has exactly one enforcement site and should be honoured "without this file being touched" — withholding the discriminator would have quietly broken that.

Verification

…ation from the first line

`CardIllustrationVote` (issue #524) has been WRITTEN since it landed and read by
nothing but the admin and the human write path. `printing_consensus.py`,
`artist_consensus.py` and `tag_consensus.py` each reconcile their own vote model;
there was no illustration equivalent, so PR #565's projected ~10,277 machine
rows would have been recorded rather than reasoned over.

1. `cardpicker/illustration_consensus.py`, modelled on its two siblings and built
   on the shared `vote_consensus` core - no weighting, quorum or human-backed gate
   is re-derived. `is_unknown` is tallied as an ORDINARY OUTCOME KEY (the `UNKNOWN`
   sentinel, exactly as `artist_consensus` treats `CardArtistVote.is_unknown`): the
   abstention on this model is the ABSENCE OF A ROW, so an `is_unknown=True` row is
   a positive claim that must be able to resolve on its own AND to contest a uuid.

2. MD5 POOLING IS BUILT IN, NOT RETROFITTED. Every read path is group-scoped:
   `resolve_illustration` tallies `card`'s whole md5 identity group, pooled per
   agent by `vote_consensus.pool_group_votes`, keyed on `agent_dedupe_key` (the
   versionless calculator family - #565 bumping this calculator v1->v2 is exactly
   the case a raw-id key would misread as two agents). The group primitives are
   IMPORTED from `printing_consensus`, so there is one definition of a group, one
   completeness guard and one agent-identity rule. md5 is strictly stronger than
   the perceptual art hash here: byte-identical files are necessarily the same
   artwork, with no threshold and no tolerance.

3. PROPAGATION IS A CONSEQUENCE OF GROUP-SCOPED RESOLUTION, NOT A SEPARATE STEP.
   A member whose decorated name fails candidate resolution abstains with
   `no-candidate-match` (367 of 2,350 considered cards in #565's replay) even
   though a byte-identical sibling resolved. Its tally IS the group's tally, and
   `resolve_and_persist_illustration` writes the outcome to every member, so it
   receives the resolved artwork with no path aware it abstained. The rejected
   alternative - writing a copied `CardIllustrationVote` row - would contribute
   exactly ZERO weight (it pools under the same agent key; pinned by a test),
   would manufacture a claim no agent made, and would collide with the model's
   unconditional (card, anonymous_id) constraint.

`Card.inferred_illustration_id` (plain UUIDField, not an FK - no
`CanonicalIllustration` table, and no reference-data join to go stale) plus
`Card.illustration_vote_status` (migration 0096), written for every group member.
`ILLUSTRATION_MIN_VOTES`/`ILLUSTRATION_MIN_SHARE` default to the printing values,
changing nothing; there is deliberately no illustration machine weight, since a
vote's weight is a property of who cast it, never of what is being voted on.

REFERENCE DATA (owner ruling 2026-07-29): this module reads neither `CanonicalCard`
nor `CanonicalPrintingMetadata` - it tallies uuids off vote rows and stores the
winner verbatim, asserted by `TestReferenceDataIndependence`. A stale snapshot can
under-supply the uuids agents have to vote for (upstream) and narrow what a resolved
uuid maps to (downstream, a live join every consumer performs itself); it cannot
change a tally, flip a winner, or move a propagation.

TESTS. 50 new tests, and every one was verified RED against a deliberately mutated
implementation (24 mutations; each of the 50 fails under at least one). Coverage
includes md5 pooling (agreeing siblings collapse; a self-contradicting agent is
withheld order-independently; distinct agents still sum; a group of one is a
byte-for-byte no-op including query shape), propagation (a voteless sibling
inherits; it does NOT inherit across an identical `content_phash`, with a
same-phash-plus-matching-md5 positive control proving the negative is not vacuous),
and the human-backed gate for this vote type.

HARNESS FIX, and it is not incidental. `test_shared_cache.py::TestSharedCacheTable
Migration` carries `@pytest.mark.django_db(transaction=True)`, so its migration
round-trip REALLY COMMITS - and its `finally` restored only as far as `0092`,
leaving every later migration unapplied for the rest of the session. Latent and
free until now purely by alphabetical luck (0093/0094 are data-only; 0095 is read
only by modules sorting earlier). A migration adding a `Card` column is the first
thing to step on it: `test_sources.py` and `test_stage_e_dispatch.py` sort later
and use `transactional_db`, and failed with `column "inferred_illustration_id" ...
does not exist` - a message pointing at this branch for a defect entirely in that
`finally`. Now restores to `graph.leaf_nodes("cardpicker")`, derived rather than
hardcoded.

VERIFIED: full `cardpicker/tests/` suite green (3,090 passed, 9 skipped) against a
3,040-passed baseline on the same commit of master. black/isort/ruff/mypy and
docs-lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
@WilfordGrimley
WilfordGrimley force-pushed the feat/illustration-consensus branch from a61ebe5 to b984cc3 Compare July 29, 2026 17:29
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…e "cross-verified against Scryfall" claim

`CanonicalPrintingMetadata.printings_count` sat on a model whose docstring
said "Scryfall printing-level fields", and the docs read it that way. It is
not Scryfall data. `import_scryfall_printing_metadata` builds a Counter over
`CanonicalCard.canonical_id` — our own table — and stores each row's oracle
group size. Rows with a NULL canonical_id are stored as 1 by fiat.

That difference is load-bearing. A column derived from our catalogue cannot
detect that our catalogue is incomplete, which is exactly what deductive
backfill's first tier advertised it as doing ("cross-verified against
Scryfall's own printings_count (not just 'our table happens to have one
row')" — it was precisely the latter).

Measured against the live catalogue, 2026-07-29:

  - 14,893 normalised names have exactly one CanonicalCard row. All 14,893
    carry a count of 1. Zero carry >1. Zero are NULL. The tier's second
    condition is entailed by the name-uniqueness test one line above it.
  - 137 cards reach that condition out of an eligible pool of 104,969;
    137 pass. The gate excludes nothing and never could.
  - Counting the Scryfall bulk file directly finds 2 oracle ids where we
    hold one row and Scryfall publishes more — the exact case the gate
    claimed to catch, invisible to it by construction.

The condition is left in the code, labelled as entailed rather than deleted,
so the gap stays visible; issue #592 tracks what a real external check is.
Per the "do not make false assertions" directive the claim is deleted, not
softened: the tier's documented claim is now the one it can support — the
name matches exactly one row in our catalogue.

Also corrects the derived claim in local_identify_printing_tags, which said
its selection "revisits single-candidate names deductive backfill's Scryfall
printings_count cross-check rejected". That cohort is empty and always was.

Migration 0098 is a pure RenameField (ALTER TABLE ... RENAME COLUMN — no
table rewrite, no data touched). NOTE: PR #573 also adds an 0098; whichever
merges second must renumber to 0099.
@WilfordGrimley
WilfordGrimley merged commit 42fe97e into master Jul 29, 2026
9 checks passed
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…e "cross-verified against Scryfall" claim

`CanonicalPrintingMetadata.printings_count` sat on a model whose docstring
said "Scryfall printing-level fields", and the docs read it that way. It is
not Scryfall data. `import_scryfall_printing_metadata` builds a Counter over
`CanonicalCard.canonical_id` — our own table — and stores each row's oracle
group size. Rows with a NULL canonical_id are stored as 1 by fiat.

That difference is load-bearing. A column derived from our catalogue cannot
detect that our catalogue is incomplete, which is exactly what deductive
backfill's first tier advertised it as doing ("cross-verified against
Scryfall's own printings_count (not just 'our table happens to have one
row')" — it was precisely the latter).

Measured against the live catalogue, 2026-07-29:

  - 14,893 normalised names have exactly one CanonicalCard row. All 14,893
    carry a count of 1. Zero carry >1. Zero are NULL. The tier's second
    condition is entailed by the name-uniqueness test one line above it.
  - 137 cards reach that condition out of an eligible pool of 104,969;
    137 pass. The gate excludes nothing and never could.
  - Counting the Scryfall bulk file directly finds 2 oracle ids where we
    hold one row and Scryfall publishes more — the exact case the gate
    claimed to catch, invisible to it by construction.

The condition is left in the code, labelled as entailed rather than deleted,
so the gap stays visible; issue #592 tracks what a real external check is.
Per the "do not make false assertions" directive the claim is deleted, not
softened: the tier's documented claim is now the one it can support — the
name matches exactly one row in our catalogue.

Also corrects the derived claim in local_identify_printing_tags, which said
its selection "revisits single-candidate names deductive backfill's Scryfall
printings_count cross-check rejected". That cohort is empty and always was.

Migration 0098 is a pure RenameField (ALTER TABLE ... RENAME COLUMN — no
table rewrite, no data touched). NOTE: PR #573 also adds an 0098; whichever
merges second must renumber to 0099.
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…0098

#573 merged its own `0098_card_illustration_consensus_fields` first, also
depending on `0097_freeze_deductive_backfill_zero_weight_cohort`. This
branch's `0098_rename_printings_count_catalogued` depended on the same
0097, so the merge of the two would have given `cardpicker` TWO leaf
nodes - and pytest-django builds its test database by running `migrate`,
so the fork errors at test-database SETUP on every branch in the repo,
not just this one. That is the outage #576 had to repair at 0096.

Nothing normally trusted showed it. The filenames differ so there was no
textual conflict; GitHub reported this PR MERGEABLE/CLEAN; its checks
were 10/10 green because they had run against master BEFORE #573 landed,
and GitHub does not re-run a PR's checks when its base moves.

- rebase onto master (d044223)
- `git mv` the migration to `0099_rename_printings_count_catalogued.py`
  and repoint `dependencies` at `0098_card_illustration_consensus_fields`
- rewrite the migration's own MIGRATION-GRAPH NOTE, which stated the old
  number and predicted this collision, to record what actually happened
- update the three other places that state the number in prose:
  `models.py`'s field comment, `deductive_backfill.py`'s module
  docstring, `docs/features/printing-tags.md`

The operation is unchanged: still a single `RenameField` on
`CanonicalPrintingMetadata.printings_count`, which PostgreSQL executes as
`ALTER TABLE ... RENAME COLUMN` - catalogue metadata only, no table
rewrite, no row read or written, fully reversible. Only the number and
the dependency moved.

Verified: `cardpicker` has exactly one leaf
(`0099_rename_printings_count_catalogued`); `makemigrations --check
--dry-run` reports no changes detected; `cardpicker/tests/` 3263 passed,
8 skipped; `docs_lint.py --strict` clean; pre-commit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
… 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
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…0098

#573 merged its own `0098_card_illustration_consensus_fields` first, also
depending on `0097_freeze_deductive_backfill_zero_weight_cohort`. This
branch's `0098_rename_printings_count_catalogued` depended on the same
0097, so the merge of the two would have given `cardpicker` TWO leaf
nodes - and pytest-django builds its test database by running `migrate`,
so the fork errors at test-database SETUP on every branch in the repo,
not just this one. That is the outage #576 had to repair at 0096.

Nothing normally trusted showed it. The filenames differ so there was no
textual conflict; GitHub reported this PR MERGEABLE/CLEAN; its checks
were 10/10 green because they had run against master BEFORE #573 landed,
and GitHub does not re-run a PR's checks when its base moves.

- rebase onto master (d044223)
- `git mv` the migration to `0099_rename_printings_count_catalogued.py`
  and repoint `dependencies` at `0098_card_illustration_consensus_fields`
- rewrite the migration's own MIGRATION-GRAPH NOTE, which stated the old
  number and predicted this collision, to record what actually happened
- update the three other places that state the number in prose:
  `models.py`'s field comment, `deductive_backfill.py`'s module
  docstring, `docs/features/printing-tags.md`

The operation is unchanged: still a single `RenameField` on
`CanonicalPrintingMetadata.printings_count`, which PostgreSQL executes as
`ALTER TABLE ... RENAME COLUMN` - catalogue metadata only, no table
rewrite, no row read or written, fully reversible. Only the number and
the dependency moved.

Verified: `cardpicker` has exactly one leaf
(`0099_rename_printings_count_catalogued`); `makemigrations --check
--dry-run` reports no changes detected; `cardpicker/tests/` 3263 passed,
8 skipped; `docs_lint.py --strict` clean; pre-commit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
… 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
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…-verified against Scryfall" claim (#601)

* Rename printings_count -> catalogued_printings_count; delete the false "cross-verified against Scryfall" claim

`CanonicalPrintingMetadata.printings_count` sat on a model whose docstring
said "Scryfall printing-level fields", and the docs read it that way. It is
not Scryfall data. `import_scryfall_printing_metadata` builds a Counter over
`CanonicalCard.canonical_id` — our own table — and stores each row's oracle
group size. Rows with a NULL canonical_id are stored as 1 by fiat.

That difference is load-bearing. A column derived from our catalogue cannot
detect that our catalogue is incomplete, which is exactly what deductive
backfill's first tier advertised it as doing ("cross-verified against
Scryfall's own printings_count (not just 'our table happens to have one
row')" — it was precisely the latter).

Measured against the live catalogue, 2026-07-29:

  - 14,893 normalised names have exactly one CanonicalCard row. All 14,893
    carry a count of 1. Zero carry >1. Zero are NULL. The tier's second
    condition is entailed by the name-uniqueness test one line above it.
  - 137 cards reach that condition out of an eligible pool of 104,969;
    137 pass. The gate excludes nothing and never could.
  - Counting the Scryfall bulk file directly finds 2 oracle ids where we
    hold one row and Scryfall publishes more — the exact case the gate
    claimed to catch, invisible to it by construction.

The condition is left in the code, labelled as entailed rather than deleted,
so the gap stays visible; issue #592 tracks what a real external check is.
Per the "do not make false assertions" directive the claim is deleted, not
softened: the tier's documented claim is now the one it can support — the
name matches exactly one row in our catalogue.

Also corrects the derived claim in local_identify_printing_tags, which said
its selection "revisits single-candidate names deductive backfill's Scryfall
printings_count cross-check rejected". That cohort is empty and always was.

Migration 0098 is a pure RenameField (ALTER TABLE ... RENAME COLUMN — no
table rewrite, no data touched). NOTE: PR #573 also adds an 0098; whichever
merges second must renumber to 0099.

* Renumber 0098_rename_printings_count_catalogued -> 0099, onto #573's 0098

#573 merged its own `0098_card_illustration_consensus_fields` first, also
depending on `0097_freeze_deductive_backfill_zero_weight_cohort`. This
branch's `0098_rename_printings_count_catalogued` depended on the same
0097, so the merge of the two would have given `cardpicker` TWO leaf
nodes - and pytest-django builds its test database by running `migrate`,
so the fork errors at test-database SETUP on every branch in the repo,
not just this one. That is the outage #576 had to repair at 0096.

Nothing normally trusted showed it. The filenames differ so there was no
textual conflict; GitHub reported this PR MERGEABLE/CLEAN; its checks
were 10/10 green because they had run against master BEFORE #573 landed,
and GitHub does not re-run a PR's checks when its base moves.

- rebase onto master (d044223)
- `git mv` the migration to `0099_rename_printings_count_catalogued.py`
  and repoint `dependencies` at `0098_card_illustration_consensus_fields`
- rewrite the migration's own MIGRATION-GRAPH NOTE, which stated the old
  number and predicted this collision, to record what actually happened
- update the three other places that state the number in prose:
  `models.py`'s field comment, `deductive_backfill.py`'s module
  docstring, `docs/features/printing-tags.md`

The operation is unchanged: still a single `RenameField` on
`CanonicalPrintingMetadata.printings_count`, which PostgreSQL executes as
`ALTER TABLE ... RENAME COLUMN` - catalogue metadata only, no table
rewrite, no row read or written, fully reversible. Only the number and
the dependency moved.

Verified: `cardpicker` has exactly one leaf
(`0099_rename_printings_count_catalogued`); `makemigrations --check
--dry-run` reports no changes detected; `cardpicker/tests/` 3263 passed,
8 skipped; `docs_lint.py --strict` clean; pre-commit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…branch (#611)

Two branches can each add `0098_<something>.py` depending on `0097`. The
filenames differ, so there is no textual conflict, GitHub reports the
second PR MERGEABLE/CLEAN, and both branches are individually valid. The
moment the second merges, `cardpicker` has two leaf nodes - and
pytest-django builds its test database by running `migrate`, so the fork
fails at test-database SETUP on EVERY branch in the repo, not just the
one that introduced it.

This has now happened twice: at 0096 (#568 vs #570) and at 0098 (#573 vs
#601). #576 repaired the first fork but prevented nothing, which is why
the second arrived within days. Nothing in CI failed either time.

WHY THE MERGE RESULT IS THE WHOLE POINT

#601's checks were 10/10 green with the collision already live on master
- they had run against master BEFORE #573 landed, and GitHub does not
re-run a PR's checks when its base moves. A check reading only the PR
branch's files sees one leaf and passes; the fork exists only in the
merge. So `check_migration_leaves.py --base origin/<base_ref>` unions the
worktree's migrations with the base branch's CURRENT tip, resolved at run
time, honouring anything the PR deletes (`--no-renames` is load-bearing:
a renumber is a delete+add of near-identical content and git otherwise
reports it as a rename, which would resurrect the old number and fail a
PR that had already fixed itself).

HOW IT DECIDES

Static `ast` read of every `migrations/` package: filenames are nodes,
each file's `dependencies` gives same-app edges, `run_before` gives
reversed ones, and a squash's `replaces` removes the nodes it stands in
for. Migration modules are never imported or executed, so this needs no
settings module, no installed apps, no postgres and no
`requirements.txt` - it runs on a bare `actions/setup-python` in about a
second. Non-literal dependency entries
(`migrations.swappable_dependency(settings.AUTH_USER_MODEL)`, in seven of
this repo's migrations) are cross-app by construction and are skipped,
not guessed at. Exit code is the finding count, matching docs_lint.py's
and check_protected_core_license.py's convention.

Findings: more than one leaf per app (the failure), a duplicate NNNN
number prefix within an app (the same defect one step earlier, and the
actionable instruction), and a dependency naming a migration that does
not exist.

WHAT IT CANNOT DO, STATED PLAINLY

A check run that PASSED before the base moved stays green in GitHub's UI.
No CI job can fix that from the inside; branch protection's "Require
branches to be up to date before merging" is the setting that closes it,
and this makes the forced re-run meaningful. `merge_group` is wired up so
a merge queue would close it too.

The workflow is its own file rather than another entry in docs-lint.yml,
which four open PRs are already editing. Every path glob uses `**`: a
single `*` does not match a slash, so `MPCAutofill/cardpicker/*.py` would
miss `migrations/` entirely (#588 hit exactly that).

Demonstrated red-then-green against the real collision, and kept as
permanent regression coverage in
`.github/scripts/tests/test_check_migration_leaves.py` (15 tests) rather
than as a one-off local run: a scratch repo where master has
`0098_card_illustration_consensus_fields` and a feature branch has
`0098_rename_printings_count_catalogued`, both on 0097, asserts clean on
the branch alone, two leaves against the merge, and clean again once
renumbered to 0099 - plus no-finding cases for a normal single-migration
PR, a PR touching no migrations, cross-app dependencies, swappable
dependencies, squashes and this repo's own tree.


Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
… 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
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
… superseded votes (#604)

* Run-scoped eligibility, the value-comparing split, and an archive for 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

* Migration 0100: rewrite the dependency note now that 0099 is real on master

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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