Retire local-fallback-v1 printing votes as redundant with stage-d-fallback-v1 - #560
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Retires
local-fallback-v1as a printing-vote identity, keepingstage-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 invote_consensus.resolve_weighted_consensus' weighted quorum (machine weight 0.5,PRINTING_TAG_MIN_VOTES2.0) without adding information. The same ruling already retiredlocal-ocr-v1againststage-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
CardPrintingTagrows) 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_verdictis a faithful port oflocal_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 sameFALLBACK_CONFIDENCE_SINGLE_EVIDENCE/MULTI_EVIDENCEconstants — imported fromlocal_fallback, not redeclared. It callsfilter_by_border_colorandmatch_artistdirectly, and duplicatesfind_symbol_matches' arithmetic against the same PROTECTED CORESYMBOL_DISTANCE_THRESHOLD/SYMBOL_MARGIN.Same underlying signal. Stage D reads persisted
ImageEvidencerather than a fresh fetch, but every field it reads is Stage C's output of the same extractor:local-fallback-v1)stage-d-fallback-v1)classify_border_color(live image)evidence.layout_class— literallyclassify_border_color's stored outputdetect_illus_anchor→extract_artist_nameevidence.artist_ocr_name—extract_artist_name's stored outputfind_symbol_matchesscansSYMBOL_STRIP_BOXevidence.symbol_phash— a hash of that sameSYMBOL_STRIP_BOXregionNot an artefact. Neither function calls the other, and they are cast by two different management commands (
local_identify_printing_tagsvslocal_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-v1could not simply be switched off because it shared a management command withlocal-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:--enginevalue exists for the fallback. The pilot command's choices areocr/phash/both; the fallback rode along on whatever pass 1 selected. "Stop running the command" would have silently dropped bothlocal-ocr-v1andlocal-phash-v1.local-fallback-v1is also an attribute-chip identity.cast_border_attribute_vote,cast_frame_style_voteandcast_bleed_edge_votecastCardTagVoterows under that same id. Stage D has no analogue for any of the three and none was part of the printing-vote measurement (which countedCardPrintingTagonly). Retiring the identity would have dropped them; only its printing channel is retired here.local_fallback.pyhas a caller outside the pilot.local_residual_classify.recover_frame_mismatch_printing_via_fallback_refetchstill callsrun_fallback_for_cardfor historical frame-mismatch recovery, andimage_evidence.pystill callscast_border_attribute_vote.local_fallback.pytherefore stays live and functionally unmodified (docstring only).3. What changed
run_pilotno longer callsrun_fallback_for_card. The pass-2 write branch — positive vote, cluster propagation, the"eliminated"is_no_matchvote, and theCardScanLogabstention rows — is removed, not gated off, along with the now-deadalready_fallback_coveredidempotence set, thefallback_*fields onCardOutcome, and the permanently-zeroresults["fallback"]entry (which would otherwise misreport a retired calculator as one that ran and found nothing).flush()— the single point every printing vote this module casts passes through. It refuses anyCardPrintingTagrow whose calculator family is retired, so a futurelocal-fallback-v2cannot silently un-retire a ratified ruling. Keyed on family, never the literal string, and the family set is derived viamodels.calculator_family(FALLBACK_ANONYMOUS_ID)rather than written out a second time, so the two cannot drift. It deliberately does not screentag_votes_batch, per trap Promote PringlePrints to a full ordering tab #2.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-v1rows are left in place — history is kept, only future casting stops. Removing them is a separate, owner-authorizedpurge_machine_votesstep and is deliberately not part of this PR. No mutating management command was run against prod as part of this change.5. Verification
test_local_identify_printing_tags.pycardpicker/tests/suiteruff/black/isort/mypy(pre-commit)masterbaseline — no new errorsraisemakestest_the_flush_refuses_a_retired_family_row_by_family_not_by_literalfail — the test is not vacuousTest classes whose subject no longer has a code path were replaced rather than deleted:
TestFallbackPrintingVoteRetiredasserts 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), andTestPass2Wiringnow pins the two things that must survive it — the attribute-chip channel and the frame-mismatch consistency check.Open items
local-fallback-v1printing rows. Owner-authorized ops step, not done here.local-fallback-v1votes 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 stalefallback's "eliminated"comment remained in the management command's report path.🤖 Generated with Claude Code
https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN