Skip to content

Declare local_phash's skip reasons at source (authorised protected-core exception) - #574

Closed
WilfordGrimley wants to merge 1 commit into
masterfrom
fix/protected-core-skip-reason-constants
Closed

Declare local_phash's skip reasons at source (authorised protected-core exception)#574
WilfordGrimley wants to merge 1 commit into
masterfrom
fix/protected-core-skip-reason-constants

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Stacked on #567. Base is feat/skip-reason-declaration-convention, not master — see "Why this is stacked" below. Review/merge after #567.

The gap

local_phash.find_best_match returned two skip reasons as bare inline string literals: "no-hashable-candidates" and "no-clear-winner". #567's roster tether derives the roster by scanning for module-level *_SKIP_REASON = "<literal>" declarations, and it cannot enumerate literals it cannot see — so a NEW literal added inside find_best_match would reach CardScanLog.skip_reason (~2.7M rows, no choices list, no FK) with no lint failure anywhere.

#567 could not close that, because MPCAutofill/cardpicker/local_phash.py is PROTECTED CORE (docs/upstreaming/license-provenance.md §2). It mirrored the constants in the consuming module and documented the residual gap instead.

Owner ruling 2026-07-29: exception granted, with the added requirement that it "hold up to audit".

What this does

  1. Declares at source. PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON / PHASH_NO_CLEAR_WINNER_SKIP_REASON are declared in local_phash.py, exported in __all__, and returned by name.
  2. Removes the mirror. The two declarations in local_identify_printing_tags.py are gone; that module imports the one constant it uses. One declaration per value, nothing that can drift. Verified nothing else mirrored them.
  3. Tether covers it automatically_declared_skip_reasons() globs cardpicker/*.py, so it picks the new site up with no linter change. Both values were already documented; the doc entries that named the mirror's location are corrected to the real one.
  4. Records the exception where the policy lives — new §2.1 Authorised exceptions — the log in license-provenance.md, with the date, the change, the reasoning, the authorisation, the proof of zero effect, and an explicit scope statement: it permits declaring skip-reason constants in this one file. It is not a licence to edit protected core, not a licence for the other listed files, and not a standing "lint-driven refactors are exempt" rule. The next change of any shape needs its own ruling and its own entry. local_phash.py stays on the list in §2 and in PROTECTED_CORE_FILES.
  5. Guards the hole shut. The tether is structurally blind to the two regressions that would undo this, so test_skip_reason_roster.py gains two tests: one fails if any other cardpicker module re-declares either value (mirror returns), one fails if find_best_match returns a bare literal again. Both mutation-checked.

Verification

Byte-identical, proved not asserted. Three independent static proofs:

  • A — AST equality. Taking the after-source, deleting the two new declarations and their __all__ entries, and inlining every load of those names back to its string literal produces a parse tree identical to the before-source. (Docstrings stripped from both: the docstring text deliberately changed to name the constants; it is prose, not executable data.)
  • B — return-value enumeration. The sequence of strings find_best_match can return, resolved statically through the module's constant table, is unchanged: ['no-hashable-candidates', 'no-clear-winner', 'no-clear-winner', ''] before and after.
  • C — one declaration. Each value: declared in the consumer before, absent from the consumer after, declared in local_phash with the same string.

Proof A also holds against origin/master directly — local_phash.py is byte-identical on master and on #567's branch, so the pre-change baseline is the same file either way.

The constant NAMES were kept identical too, not just the values. That is why test_skip_reason_roster.py's hand-written EXPECTED_SKIP_REASONS pin and the doc's Constant column needed no edit at all — nothing about the roster moved except the declaration site.

Roster tether — passes, and proved it can still fail. With the doc entry for each value stripped in turn, the tether errors and names the new site:

::error file=docs/reference/skip-reasons.md::skip-reason roster drift: reason
`no-hashable-candidates` is declared in code (MPCAutofill/cardpicker/local_phash.py:93
(PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON)) but has no entry in docs/reference/skip-reasons.md...

::error file=docs/reference/skip-reasons.md::skip-reason roster drift: reason
`no-clear-winner` is declared in code (MPCAutofill/cardpicker/local_phash.py:94
(PHASH_NO_CLEAR_WINNER_SKIP_REASON)) but has no entry in docs/reference/skip-reasons.md...

Restored → clean. Full docs_lint.py: docs-lint: clean.

check_protected_core_license.py: protected-core-license: clean (9 files checked).

Tests: cardpicker/tests/2984 passed, 9 skipped. test_catalog_stats.py is excluded from that count: 5 of its tests fail with ValueError: Missing staticfiles manifest entry for 'cardpicker/favicon.ico', identically on the unmodified baseline (verified by stashing) — a local collectstatic gap, not this change.

Also clean: ruff, isort, black, mypy (all pre-commit hooks passed on commit).

Item 5 — same defect elsewhere in protected core? Reported only, not fixed

Each of these would need its own ruling; that is the point of the policy.

File Roster-relevant literals the tether cannot see
vote_consensus.py None. Its one identity literal is bound to DEDUCTIVE_BACKFILL_ANONYMOUS_ID.
printing_consensus.py None.
tag_consensus.py None.
artist_consensus.py None.
federation-hash-tool/hash_my_cards.py None.
local_fallback.py Three. run_fallback_for_card returns FallbackOutcome(skip_reason=...) with bare literals "no-evidence" (L687), "eliminated" (L690), "ambiguous" (L694).

On local_fallback.py: none of the three reaches CardScanLog today. Its only non-test caller is local_residual_classify.recover_frame_mismatch_printing_via_fallback_refetch, which reads outcome.printing_pk and discards skip_reason; the module's own write branch was retired on 2026-07-29 and the local-fallback-v1 rows in production are historical. So this is a latent gap, not a live one — but it is the same shape as the one this PR closes, and it would become live the moment anything persists that outcome. Recommend a separate ruling rather than folding it in here. (Its FALLBACK_ANONYMOUS_ID is properly declared; the calculator-identity side of protected core is clean across all six files.)

Incidental, out of scope, not protected core: local_residual_classify.py:135 filters CardScanLog.objects.filter(skip_reason="frame-mismatch") on a bare literal — a read-side coupling to a value declared elsewhere, not a roster-tether gap.

Why this is stacked on #567 rather than based on master

The instruction said to base on master if #567 had not landed. It has not (open as of writing), and basing on master would have made the work unverifiable and items 2–3 vacuous: the mirror to remove, the tether to satisfy, and the doc entries to correct all originate in #567 and none exist on master. Basing there would have produced a PR that declares constants with nothing to check them against. Stacking keeps the deliverable reviewable as one coherent change; the diff against #567 is exactly the five files listed. If #567 is reworked, this rebases onto it.

🤖 Generated with Claude Code

https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

@WilfordGrimley
WilfordGrimley force-pushed the feat/skip-reason-declaration-convention branch from 724c513 to 3df1322 Compare July 29, 2026 15:26
@WilfordGrimley
WilfordGrimley force-pushed the fix/protected-core-skip-reason-constants branch from 459d18a to 08da4ad Compare July 29, 2026 15:35
@WilfordGrimley

Copy link
Copy Markdown
Author

Rebased onto the rebased #567

Rebased from the old #567 head (724c513f) onto the new one (3df13228). No conflicts. Base deliberately left as feat/skip-reason-declaration-convention — the mirror this PR removes and the tether it satisfies both originate in #567, so the stacking is kept, not retargeted to master.

Checked that #567's rebase edits and this PR's edits to the same two files still cohere:

Verification

`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
@WilfordGrimley
WilfordGrimley force-pushed the fix/protected-core-skip-reason-constants branch from 08da4ad to 25a24f5 Compare July 29, 2026 17:29
@WilfordGrimley
WilfordGrimley changed the base branch from feat/skip-reason-declaration-convention to master July 29, 2026 17:30
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>
@WilfordGrimley

Copy link
Copy Markdown
Author

Closing as fully subsumed by #584.

#584 carried both commits and merged alone, so this branch's content reached master through #584's squash. Verified against origin/master (d74b84e), comparing this branch's head 25a24f5 line-by-line:

  • local_phash.py — byte-identical between this branch and master (same blob).
  • local_identify_printing_tags.py, tests/test_skip_reason_roster.py, docs/upstreaming/license-provenance.md — every line this branch adds over its merge base is present on master verbatim; master carries further changes on top (the 2026-07-29 printings_count correction, the LOCAL_FALLBACK_* roster entries, the local_fallback.py exception log entry).
  • docs/reference/skip-reasons.md — this branch's version is a strict subset: 322 lines here vs 397 on master, all substance present. The only three added lines that do not match verbatim are the same statements re-wrapped and re-worded by Declare local_fallback's skip reasons at source (second authorised protected-core exception) #584 (master lines 150 and 338-339).

Merging would at best be a no-op and at worst reintroduce the smaller skip-reasons.md. GitHub already reports this branch as CONFLICTING for that reason. Branch left in place.

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