feat(enroll): report views with no recorded source

Six enrolled views predate ATTRIBUTION.json and their source URLs were never
recorded. They are not wrong - each is independent of the evaluation photo
(highest template match 0.166 against add/image6.jpg) and each is above the
enrollment floor - but the provenance is gone, and provenance is what lets a
later audit tell a re-sourced packshot from a leaked shelf crop.

rebuild_catalog now names them instead of leaving the gap to be rediscovered,
and ATTRIBUTION.json records both the l06 replacement and the gap itself, with
what is verifiable about those six.

Reported, not refused: refusing would drop six working references to punish a
missing field.
This commit is contained in:
kiyo9w 2026-09-29 03:35:41 +09:00
parent 20fb459ee7
commit 8db280d220
2 changed files with 34 additions and 0 deletions

View file

@ -345,6 +345,25 @@ def audit(
return kept, refused
def unattributed(source: str) -> List[str]:
"""SKUs whose views have no entry in the source's ATTRIBUTION.json.
Reported, not refused. A view with no recorded source is still a usable
reference - the six that predate the attribution file are independent of
the evaluation photo and their OCR matches their catalog names - but the
provenance is gone, and provenance is what lets a later audit tell a
re-sourced packshot from a leaked shelf crop. Naming them keeps the gap
from growing quietly.
"""
path = os.path.join(source, "ATTRIBUTION.json")
try:
with open(path) as handle:
recorded = json.load(handle)
except (OSError, ValueError):
recorded = {}
return sorted(sku for sku in collect(source) if sku not in recorded)
def text_discriminated_siblings(
kept: Dict[str, List[str]], source: str, names: Optional[Dict[str, str]] = None
) -> List[Dict[str, Any]]:
@ -564,6 +583,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
text_only = sorted(set(names) - set(kept))
siblings = text_discriminated_siblings(kept, args.source, names)
small = below_pyramid_floor(kept, args.source)
no_source = unattributed(args.source)
verdict = live_ready_verdict(kept, names, shelf_skus)
report = {
"source": args.source,
@ -577,6 +597,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
"text_only_skus": text_only,
"text_discriminated_siblings": siblings,
"below_pyramid_floor": small,
"unattributed_skus": no_source,
"completeness": verdict,
"skus": {sku: views for sku, views in sorted(kept.items())},
"refused": refused,
@ -600,6 +621,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
for row in small:
print(f" {row['sku']:<10} {row['view']:<20} {row['height']}px tall - "
"scores are not comparable with the rest; re-source this view")
if no_source:
print(f"\nno recorded source ({len(no_source)}): {', '.join(no_source)} - "
"usable, but provenance is missing; re-sourcing would close it")
if verdict["live_ready"] is None:
print(f"\ncompleteness: unknown - {verdict['reason']}")
elif verdict["live_ready"]: