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

@ -35,8 +35,10 @@ from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import subprocess
import sys
import time
from collections import Counter
@ -86,6 +88,55 @@ def load_labels(path: str) -> Dict[str, Any]:
return payload
def file_hash(path: str) -> Optional[str]:
"""SHA-256 of a file's bytes, or None when it cannot be read."""
try:
with open(path, "rb") as handle:
return hashlib.sha256(handle.read()).hexdigest()
except OSError:
return None
def catalog_hash(stock_dir: str) -> Optional[str]:
"""SHA-256 over the catalog's CSV bytes and its image names.
Image bytes are not hashed: a re-encoded view of the same product is the
same catalog, and hashing every packshot would make the number move for
reasons that do not change a single decision. A changed row or a different
set of views does change it.
"""
digest = hashlib.sha256()
csv_path = os.path.join(stock_dir, "stock.csv")
try:
with open(csv_path, "rb") as handle:
digest.update(handle.read())
except OSError:
digest.update(b"missing-csv")
try:
names = sorted(
name for name in os.listdir(stock_dir)
if os.path.splitext(name)[1].lower() in (".jpg", ".jpeg", ".png")
)
except OSError:
names = []
digest.update(b"\n")
digest.update("\n".join(names).encode("utf-8"))
return digest.hexdigest()
def code_revision() -> Optional[str]:
"""The checked-out revision, or None outside a git worktree."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=os.path.dirname(os.path.abspath(__file__)),
capture_output=True, text=True, timeout=10,
)
except (OSError, subprocess.SubprocessError):
return None
return result.stdout.strip() or None if result.returncode == 0 else None
def crop_of(image: Any, box: Sequence[int]) -> Any:
x1, y1, x2, y2 = [int(v) for v in box]
return image[max(0, y1):y2, max(0, x1):x2]
@ -539,6 +590,15 @@ def main() -> int:
"image": image_path,
"stock_dir": args.stock_dir,
"labels": args.labels,
# The enrollment protocol's versioning rule: every report records the
# catalog hash, the label-file hash and the code revision, so a number
# can be traced to the exact inputs that produced it.
"provenance": {
"catalog_hash": catalog_hash(args.stock_dir),
"labels_hash": file_hash(args.labels),
"image_hash": file_hash(image_path),
"code_revision": code_revision(),
},
"facings": len(facings),
"leakage": {"excluded": len(refused), "labels": refused},
"hub_share_limit": HUB_SHARE,