feat: finish the enrollment protocol's reporting checks

Three items from DECISION §8's enrollment protocol were unimplemented and none
of them needed Tier-2 data:

- Eval reports now record provenance: catalog hash, label-file hash, image hash
  and the code revision, so a number can be traced to the inputs that produced
  it. The catalog hash covers the CSV bytes and the view names, not image bytes:
  a re-encoded view of the same product is the same catalog.
- rebuild_catalog flags cross-SKU near-duplicates as text-discriminated
  siblings. These are the near-unknown case no threshold rejects - the geometry
  is real because the sibling shares the brand's artwork - so they are reported
  rather than refused.
- --shelf-tags turns the completeness rule into a live-ready verdict. Without
  the shelf's SKU list the verdict is 'unknown', not a guess.

Adds test_rebuild_catalog.py (11 tests; the tool had no test file). Suite 261
-> 272. Also corrects score_crop_lanes' docstring, which still described the
live path as averaging local scores when the local scale decides on the newest
crop.
This commit is contained in:
tngo 2026-09-28 22:44:07 +09:00
parent 7fff0cf724
commit e53c1ef59b
4 changed files with 301 additions and 3 deletions

View file

@ -527,9 +527,12 @@ class RecognitionEngine:
"""Both lanes' evidence for one crop: local scores and text ranking.
The live path accumulates evidence across looks, so it needs the lanes
separately rather than one fused decision: it averages the local scores
over the track's window and keeps the newest text reading, then decides
once. The still path calls `decide_crop`, which fuses a single crop.
separately rather than one fused decision. How it combines them depends
on the scale: the local lane decides on the newest crop (its value is a
margin over the runner-up, and averaging in an empty observation halves
it below the accept line), while the cosine and inlier scales average
the scores over the track's window. The text reading is always the
newest. The still path calls `decide_crop`, which fuses a single crop.
"""
scores = self.score_crop(crop, method)
text_lane = self.identity_text_lane()