Skip to content

Retire local-fallback-v1 printing votes as redundant with stage-d-fallback-v1 - #560

Merged
WilfordGrimley merged 1 commit into
masterfrom
retire-local-fallback-printing-votes-v2
Jul 29, 2026
Merged

Retire local-fallback-v1 printing votes as redundant with stage-d-fallback-v1#560
WilfordGrimley merged 1 commit into
masterfrom
retire-local-fallback-printing-votes-v2

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Retires local-fallback-v1 as a printing-vote identity, keeping stage-d-fallback-v1. Owner ruling 2026-07-29, redundancy doctrine — anything made redundant is retired, judged on the evidence source, not the vote cast. Two calculators reading one underlying signal are one witness counted twice, inflating apparent corroboration in vote_consensus.resolve_weighted_consensus' weighted quorum (machine weight 0.5, PRINTING_TAG_MIN_VOTES 2.0) without adding information. The same ruling already retired local-ocr-v1 against stage-d-join-key-v1.

1. The premise was verified in code before anything was changed

The measurement (11,825/11,825 overlapping cards, 100.0%, zero conflicts, zero one-sided abstentions, over all 179,176 CardPrintingTag rows) is consistent with two hypotheses: same evidence source, or artefact (one calling the other, or both cast by a shared command). It is the former.

Same decision model. local_calculate_verdicts.calculate_fallback_verdict is a faithful port of local_fallback.run_fallback_for_card: the same three sub-checks composed by the same intersection loop in the same order, abstaining on the same trichotomy (no sub-check produced a reading / zero survivors / more than one survivor), at the same FALLBACK_CONFIDENCE_SINGLE_EVIDENCE/MULTI_EVIDENCE constants — imported from local_fallback, not redeclared. It calls filter_by_border_color and match_artist directly, and duplicates find_symbol_matches' arithmetic against the same PROTECTED CORE SYMBOL_DISTANCE_THRESHOLD/SYMBOL_MARGIN.

Same underlying signal. Stage D reads persisted ImageEvidence rather than a fresh fetch, but every field it reads is Stage C's output of the same extractor:

sub-check pilot (local-fallback-v1) Stage D (stage-d-fallback-v1)
border classify_border_color(live image) evidence.layout_class — literally classify_border_color's stored output
artist detect_illus_anchorextract_artist_name evidence.artist_ocr_nameextract_artist_name's stored output
symbol find_symbol_matches scans SYMBOL_STRIP_BOX evidence.symbol_phash — a hash of that same SYMBOL_STRIP_BOX region

Not an artefact. Neither function calls the other, and they are cast by two different management commands (local_identify_printing_tags vs local_calculate_verdicts). The agreement is the signature of one witness counted twice, exactly as the doctrine describes. Premise holds.

2. The entanglement trap — checked BEFORE removing anything

local-ocr-v1 could not simply be switched off because it shared a management command with local-phash-v1. The equivalent check here found the entanglement is worse in three independent ways, which is why this PR is a scalpel on one call site rather than a switch-off:

  1. No --engine value exists for the fallback. The pilot command's choices are ocr/phash/both; the fallback rode along on whatever pass 1 selected. "Stop running the command" would have silently dropped both local-ocr-v1 and local-phash-v1.
  2. local-fallback-v1 is also an attribute-chip identity. cast_border_attribute_vote, cast_frame_style_vote and cast_bleed_edge_vote cast CardTagVote rows under that same id. Stage D has no analogue for any of the three and none was part of the printing-vote measurement (which counted CardPrintingTag only). Retiring the identity would have dropped them; only its printing channel is retired here.
  3. local_fallback.py has a caller outside the pilot. local_residual_classify.recover_frame_mismatch_printing_via_fallback_refetch still calls run_fallback_for_card for historical frame-mismatch recovery, and image_evidence.py still calls cast_border_attribute_vote. local_fallback.py therefore stays live and functionally unmodified (docstring only).

3. What changed

  • run_pilot no longer calls run_fallback_for_card. The pass-2 write branch — positive vote, cluster propagation, the "eliminated" is_no_match vote, and the CardScanLog abstention rows — is removed, not gated off, along with the now-dead already_fallback_covered idempotence set, the fallback_* fields on CardOutcome, and the permanently-zero results["fallback"] entry (which would otherwise misreport a retired calculator as one that ran and found nothing).
  • Retirement lock in flush() — the single point every printing vote this module casts passes through. It refuses any CardPrintingTag row whose calculator family is retired, so a future local-fallback-v2 cannot silently un-retire a ratified ruling. Keyed on family, never the literal string, and the family set is derived via models.calculator_family(FALLBACK_ANONYMOUS_ID) rather than written out a second time, so the two cannot drift. It deliberately does not screen tag_votes_batch, per trap Promote PringlePrints to a full ordering tab #2.
  • Docstrings in local_fallback.py, the pilot module and the management command record the ruling and, explicitly, what survives it.

4. Vote rows are NOT purged

Existing local-fallback-v1 rows are left in place — history is kept, only future casting stops. Removing them is a separate, owner-authorized purge_machine_votes step and is deliberately not part of this PR. No mutating management command was run against prod as part of this change.

5. Verification

check result
test_local_identify_printing_tags.py 226 passed
Full cardpicker/tests/ suite 2954 passed, 6 skipped (incl. 130 snapshots)
ruff / black / isort / mypy (pre-commit) all passed
mypy on the changed module 2 errors, identical to the master baseline — no new errors
Mutation check on the retirement lock disabling the raise makes test_the_flush_refuses_a_retired_family_row_by_family_not_by_literal fail — the test is not vacuous

Test classes whose subject no longer has a code path were replaced rather than deleted: TestFallbackPrintingVoteRetired asserts the retirement (including that a pass-1 miss never computes the verdict, so the run doesn't pay for an extra artist-crop OCR pass and symbol scan per missed card), and TestPass2Wiring now pins the two things that must survive it — the attribute-chip channel and the frame-mismatch consistency check.

Open items

  1. Vote purge — whether and when to purge the 11,947 historical local-fallback-v1 printing rows. Owner-authorized ops step, not done here.
  2. Cluster propagation of pre-existing rows — historical local-fallback-v1 votes propagated to absorbed cluster members remain; unaffected by this change, in scope for the purge decision above.

Prior salvaged WIP (wip/retire-local-fallback-salvage, 03ec8cc4) was adopted as the starting point rather than discarded — its analysis was independently re-derived and confirmed correct here. Two defects were fixed on top: the retirement-lock test never actually exercised the guard raising (it only asserted set membership, and would have passed with the guard deleted), and a stale fallback's "eliminated" comment remained in the management command's report path.

🤖 Generated with Claude Code

https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

…lback-v1

Owner ruling 2026-07-29, redundancy doctrine: anything made redundant is
retired, judged on the EVIDENCE SOURCE rather than the vote cast. Two
calculators reading one underlying signal are one witness counted twice,
inflating apparent corroboration in vote_consensus.resolve_weighted_consensus'
weighted quorum without adding information. The same ruling already retired
local-ocr-v1 against stage-d-join-key-v1.

PREMISE VERIFIED IN CODE, not taken on the measurement alone.
local_calculate_verdicts.calculate_fallback_verdict ("stage-d-fallback-v1") is
a faithful port of local_fallback.run_fallback_for_card's decision model: the
same three sub-checks composed by the same intersection loop in the same order,
abstaining on the same "no sub-check produced a reading"/"zero survivors"/"more
than one survivor" trichotomy, at the same FALLBACK_CONFIDENCE_SINGLE_EVIDENCE/
MULTI_EVIDENCE constants imported from local_fallback rather than redeclared.
It calls filter_by_border_color and match_artist directly, and duplicates
find_symbol_matches' arithmetic against the same PROTECTED CORE
SYMBOL_DISTANCE_THRESHOLD/SYMBOL_MARGIN. Its inputs are Stage C's stored
ImageEvidence readings of the SAME image signal, produced by the SAME
extractors: layout_class is local_fallback.classify_border_color's own output,
artist_ocr_name is local_fallback.extract_artist_name's (the same extraction
detect_illus_anchor performs), symbol_phash is a hash of
local_fallback.SYMBOL_STRIP_BOX (the same strip find_symbol_matches scans). The
100.0% agreement over 11,825 overlapping cards is therefore NOT an artefact:
neither calls the other, and they run from two different management commands
(local_identify_printing_tags vs local_calculate_verdicts). Same evidence
source, one witness counted twice.

ENTANGLEMENT CHECKED FIRST (the local-ocr-v1 trap, where "stop running the
command" would have silently dropped local-phash-v1). Here it is worse in three
ways, so this is a scalpel on ONE call site, not a switch-off:
  1. The pilot command has no --engine value for the fallback (choices are
     ocr/phash/both) - it rode along on whatever pass 1 selected. Not running
     the command would drop local-ocr-v1 AND local-phash-v1.
  2. local-fallback-v1 is ALSO the identity of three CardTagVote attribute-chip
     channels - cast_border_attribute_vote, cast_frame_style_vote and
     cast_bleed_edge_vote. Stage D has no analogue for any of them and none was
     part of the printing-vote measurement. Retiring the IDENTITY would drop
     them; only its CardPrintingTag channel is retired.
  3. run_fallback_for_card is still called by local_residual_classify's
     recover_frame_mismatch_printing_via_fallback_refetch, and image_evidence.py
     still calls cast_border_attribute_vote. local_fallback.py stays live and
     unmodified apart from its docstring.

What changed: run_pilot no longer calls run_fallback_for_card, and its pass-2
write branch (positive vote, cluster propagation, the "eliminated" is_no_match
vote, and the CardScanLog abstention rows) is removed rather than gated off,
along with the now-dead already_fallback_covered idempotence set and the
permanently-zero results["fallback"] entry. A family-keyed RETIREMENT LOCK in
flush() refuses any CardPrintingTag row from a retired calculator family, so a
future "local-fallback-v2" cannot silently un-retire the ruling; it deliberately
does not screen tag_votes_batch, where that identity still legitimately votes.
The family set is DERIVED via models.calculator_family, never a second literal.

Existing local-fallback-v1 vote rows are LEFT IN PLACE - history is kept, only
future casting stops. Removing them is a separate, owner-authorized
purge_machine_votes step and is NOT part of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
@WilfordGrimley
WilfordGrimley merged commit 5716a9d into master Jul 29, 2026
10 checks passed
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…e exception)

`local_fallback.run_fallback_for_card` set `FallbackOutcome.skip_reason`
from three bare inline literals - "no-evidence", "eliminated", "ambiguous".
The roster tether added in #567 derives the skip-reason roster from
module-level `*_SKIP_REASON = "<literal>"` declarations, and it cannot
enumerate literals it cannot see, so a FOURTH literal added beside them
would have joined three invisible siblings on the way to
`CardScanLog.skip_reason` (~2.7M rows, no `choices` list, no FK).

Latent, not live: this module's own write branch was retired by #560 and its
one non-test caller
(`local_residual_classify.recover_frame_mismatch_printing_via_fallback_refetch`)
reads `outcome.printing_pk` and discards `skip_reason`. Nothing persists
these today. That is an argument for closing the hole now, while the change
is provably inert, not for leaving it: the invisibility is a property of the
literals, and it goes live the moment anything persists the outcome, with no
lint failure to mark the moment.

`local_fallback.py` is PROTECTED CORE
(docs/upstreaming/license-provenance.md section 2). The owner granted a
SECOND narrow exception on 2026-07-29 - separate from #574's, which names
this file explicitly as one it does NOT cover.

- declare LOCAL_FALLBACK_NO_EVIDENCE_SKIP_REASON /
  LOCAL_FALLBACK_ELIMINATED_SKIP_REASON / LOCAL_FALLBACK_AMBIGUOUS_SKIP_REASON
  in `local_fallback.py`, export them, and pass them by name. The
  `LOCAL_FALLBACK_` prefix (not `FALLBACK_`) keeps them distinct from the
  SEPARATE `stage-d-fallback-v1` calculator's `FALLBACK_*` family in
  `local_calculate_verdicts.py`
- no mirror was deleted, because none existed: unlike the phash pair these
  values were never re-declared for this engine anywhere, since its one
  caller never reads them. The Stage D family was checked and left alone -
  a parallel calculator's own vocabulary, not a mirror of these
- completeness verified by AST scan of the whole file (every `skip_reason=`
  kwarg, every `.skip_reason` assignment, every lowercase/hyphenated string
  constant): three is the complete set, not just the three I was handed
- record the exception, its reasoning, its proof of nil effect and - the
  part that matters - its LIMITS as the second entry in
  license-provenance.md section 2.1. `local_fallback.py` stays on the
  protected list in section 2, now annotated
- document the three in docs/reference/skip-reasons.md under a new
  Local-fallback pilot engine section, marked Latent; correct that doc's
  reference to a function named `compute_fallback_outcome`, which does not
  exist (it is `run_fallback_for_card`)
- three guards in test_skip_reason_roster.py, all mutation-checked. The
  mirror guard is deliberately narrower than #574's: `no-evidence`,
  `eliminated` and `ambiguous` are shared vocabulary several calculators
  legitimately declare under their own prefixes, so a flat value ban would
  forbid the roster's own design

Naming-only. The string VALUES are untouched: the after-source with the
constants inlined back to literals parses to an AST identical to the
before-source (docstrings normalised - they are the only other difference),
and the sequence run_fallback_for_card can produce is
['no-evidence', 'eliminated', 'ambiguous'] before and after. A `CardScanLog`
row written after this is byte-identical to one written before.

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
…otected-core exception) (#584)

* Declare local_phash's skip reasons at source (protected-core exception)

`local_phash.find_best_match` returned "no-hashable-candidates" and
"no-clear-winner" as bare inline literals. The roster tether added in
#567 derives the skip-reason roster from module-level
`*_SKIP_REASON = "<literal>"` declarations, and it cannot enumerate
literals it cannot see - so a NEW literal added inside `find_best_match`
would have reached `CardScanLog.skip_reason` (~2.7M rows, no `choices`
list, no FK) with nothing to catch it.

#567 could not close that: `local_phash.py` is PROTECTED CORE
(docs/upstreaming/license-provenance.md §2). It mirrored the two
constants in the consuming module instead and documented the residual
gap. The owner granted a narrow exception on 2026-07-29 to close it.

- declare PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON and
  PHASH_NO_CLEAR_WINNER_SKIP_REASON in `local_phash.py`, export them,
  and return them by name
- delete the mirror in `local_identify_printing_tags.py`, which now
  imports the one constant it uses; one declaration per value
- record the exception, its reasoning and - the part that matters - its
  LIMITS in license-provenance.md §2.1, a new exception log. It permits
  declaring skip-reason constants in this one file. It is not a licence
  to edit protected core; the next such change needs its own ruling.
  `local_phash.py` stays on the protected list.
- two guards in test_skip_reason_roster.py for the regressions the
  tether is structurally blind to: the mirror coming back, and a bare
  literal returning at the origin

Naming-only. The string VALUES are untouched - and so are the constant
NAMES, so the roster's pinning test and the doc's Constant column needed
no edit at all. A `CardScanLog` row written after this is byte-identical
to one written before.

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

* Declare local_fallback's skip reasons at source (second protected-core exception)

`local_fallback.run_fallback_for_card` set `FallbackOutcome.skip_reason`
from three bare inline literals - "no-evidence", "eliminated", "ambiguous".
The roster tether added in #567 derives the skip-reason roster from
module-level `*_SKIP_REASON = "<literal>"` declarations, and it cannot
enumerate literals it cannot see, so a FOURTH literal added beside them
would have joined three invisible siblings on the way to
`CardScanLog.skip_reason` (~2.7M rows, no `choices` list, no FK).

Latent, not live: this module's own write branch was retired by #560 and its
one non-test caller
(`local_residual_classify.recover_frame_mismatch_printing_via_fallback_refetch`)
reads `outcome.printing_pk` and discards `skip_reason`. Nothing persists
these today. That is an argument for closing the hole now, while the change
is provably inert, not for leaving it: the invisibility is a property of the
literals, and it goes live the moment anything persists the outcome, with no
lint failure to mark the moment.

`local_fallback.py` is PROTECTED CORE
(docs/upstreaming/license-provenance.md section 2). The owner granted a
SECOND narrow exception on 2026-07-29 - separate from #574's, which names
this file explicitly as one it does NOT cover.

- declare LOCAL_FALLBACK_NO_EVIDENCE_SKIP_REASON /
  LOCAL_FALLBACK_ELIMINATED_SKIP_REASON / LOCAL_FALLBACK_AMBIGUOUS_SKIP_REASON
  in `local_fallback.py`, export them, and pass them by name. The
  `LOCAL_FALLBACK_` prefix (not `FALLBACK_`) keeps them distinct from the
  SEPARATE `stage-d-fallback-v1` calculator's `FALLBACK_*` family in
  `local_calculate_verdicts.py`
- no mirror was deleted, because none existed: unlike the phash pair these
  values were never re-declared for this engine anywhere, since its one
  caller never reads them. The Stage D family was checked and left alone -
  a parallel calculator's own vocabulary, not a mirror of these
- completeness verified by AST scan of the whole file (every `skip_reason=`
  kwarg, every `.skip_reason` assignment, every lowercase/hyphenated string
  constant): three is the complete set, not just the three I was handed
- record the exception, its reasoning, its proof of nil effect and - the
  part that matters - its LIMITS as the second entry in
  license-provenance.md section 2.1. `local_fallback.py` stays on the
  protected list in section 2, now annotated
- document the three in docs/reference/skip-reasons.md under a new
  Local-fallback pilot engine section, marked Latent; correct that doc's
  reference to a function named `compute_fallback_outcome`, which does not
  exist (it is `run_fallback_for_card`)
- three guards in test_skip_reason_roster.py, all mutation-checked. The
  mirror guard is deliberately narrower than #574's: `no-evidence`,
  `eliminated` and `ambiguous` are shared vocabulary several calculators
  legitimately declare under their own prefixes, so a flat value ban would
  forbid the roster's own design

Naming-only. The string VALUES are untouched: the after-source with the
constants inlined back to literals parses to an AST identical to the
before-source (docstrings normalised - they are the only other difference),
and the sequence run_fallback_for_card can produce is
['no-evidence', 'eliminated', 'ambiguous'] before and after. A `CardScanLog`
row written after this is byte-identical to one written before.

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>
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