product-recognition-demo/recognization/docs/research/2026-09-28-recognition-architecture/DECISION.md
kiyo9w a1c69f72fe feat(eval): add --no-text so the E-3 claim is reproducible
E-3 asks what the text lane is worth, and the record answered it with numbers
that no committed command could produce - the evaluator had no way to run the
local lane without the text lane. --no-text is that way, and it keeps the
comparison to one variable by building the lane either way, so the OCR cost is
paid in both arms.

Measured: full resolution 77% end-to-end at 91% precision with text against 61%
at 66% without (5 false accepts against 20); live 62% at 93% against 56% at 84%
(3 against 7). The lane adds 16 pp and 6 pp of coverage while raising precision,
against E-3's +5 pp bar.

Also refreshes the re-measured block above it, which still printed the
pre-correction 61/48 label counts and a stray 'HUB!' marker.
2026-09-29 03:32:38 +09:00

60 KiB
Raw Blame History

Dorayaki product recognition: architecture decision

Audited 2026-09-28 · submodule 25dbf13 on fix/live-recognition-quality plus its uncommitted diff (snapshot: git stash create → 2785c07) · auditor: Claude Opus 5.5 Scope: identity pipeline (matcher, gallery, decision, evaluator, catalogs, labels), live track/label logic, the uncommitted diff. Not audited: detector quality, tag/planogram pipeline, iOS/Android rendering, on-device runtime. Evidence scripts and the corrected labels: evidence/.

Verdict

Build a local-feature instance matcher plus a text lane, behind a calibrated open-set decision, with no global embedding in the critical path. At the current scale the matcher runs exhaustively; past ~150 SKUs, a RootSIFT descriptor index replaces exhaustive matching. Generic DINOv2 is rejected for both classification and retrieval.

The biggest risk is none of the architectures. It is that there is no trustworthy benchmark. The only labelled data is one photo, add/image6.jpg, and 26 of its 111 labels (23 %) were wrong. Every number in the handoff was computed against those labels. Label and catalog errors are larger than any architecture difference measured so far. They also hide the real product failure: displayed-label precision is 29–43 % because same-brand siblings the catalog doesn't know about get labelled with confidence.

On corrected labels, with thresholds tuned on the same single photo (so read this as direction, not proof), the recommended design beats Van's contract on coverage and on precision together:

Live resolution, clean 7-SKU catalog, 61 in-catalog + 48 unknown facings Correct shown Coverage (of 61) Precision of shown labels Unknowns labelled
Van's matcher, always argmax (the floor) 35 57 % 35 % 48 / 48
Current server gate (decide_identity defaults) 32 52 % 43 % 22 / 48
Improved matcher, geometric-confidence gate only (τ=2) 26 43 % 79 % —
Improved matcher + OCR veto + OCR lane (τ=2) 36 59 % 95 % 0 / 48
Same, looser (τ=1) 40 66 % 87 % 2 / 48

The one experiment that would most change this recommendation is a second, independently captured and labelled shelf visit. §7 defines it.


1. Problem formulation

Dorayaki's identity task is open-set instance recognition over a small, store-scoped, enrolled catalog, with live-video evidence accumulation.

  • Instance, not category. The classes are individual printed artworks (JAN-level SKUs). Siblings share maker, palette, lattice pattern, mascot and silhouette, and differ by a word ("コクうま 65%"), a colour (pink vs red lattice), or a small logo. That is the regime of planar instance matching (logo and text geometry), not of semantic embeddings. Measured: raw DINOv2 is 12–21 % top-1 on 7 SKUs; the local matcher is 76–98 %.
  • Retrieval-shaped at scale, closed-set-shaped today. With tens of SKUs, every enrolled reference can be verified exhaustively. With thousands, it becomes retrieval plus verification (§6).
  • Open set is the dominant difficulty, and it comes in two kinds. Far unknowns (other categories) are rejected easily: 0.2–0.7 % false accepts on 602 unrelated crops at a threshold that keeps most correct labels. Near unknowns (siblings of enrolled products: Kewpie 450 g next to Kewpie からし, Pure Select plain next to Pure Select コクうま) produce genuine, geometrically consistent evidence and can't be thresholded away. Near unknowns get fixed by catalog completeness and by text, not by a better score.
  • Multimodal by nature. Text is the designed discriminator between siblings; manufacturers print the variant name precisely so shoppers can tell them apart. OCR identifies the right SKU among all 12 catalog rows on 41–42 of the 55 facings where it reads anything, including SKUs with no enrolled image, using only catalog names and packshot text.
  • Temporal. Live video gives many cheap, noisy looks at one physical facing. The right model is accumulated evidence per track, with a provisional → confirmed label contract. The current model is one accepted observation that locks the label forever.

So the hierarchy is: store/category scope → candidate generation (trivial now, descriptor index later) → local geometric verification + text verification → calibrated open-set decision → temporal accumulation.

2. Evidence audit

R reliable · C directionally useful but confounded · X invalidated · U still unknown. "Reproduced" means re-run in this audit with the scripts in evidence/.

# Handoff claim Verdict What this audit found
2 Van's matcher is SIFT/ORB/BRISK + RANSAC, always argmax, no rejection R Scoring core in code/feature_matcher.py is byte-for-byte Van's (git diff b443e3c); only decide_identity was added. "SURF" is SIFT (SURF isn't in this OpenCV build).
2 "170/170 named ≠ 170/170 correct" on image.jpg R (logic) / U (numbers) No labels exist for image.jpg; its accuracy was never measured.
3 Current server collapsed onto hub SKUs (sku_003, sku_l09) C Hubness is real for DINO (50–60 % max-SKU share on the clean catalog, reproduced). The l09 collapse was mostly a wrong l09 reference plus shelf-cut templates.
3 Live resolution degrades identity X Controlled rerun: with correctly scaled boxes, live is not worse than full (original matcher 35/42 live vs 32/42 full; improved 36 vs 40).
3 DINO embeddings are inappropriate for fine-grained SKU identity R Reproduced: gallery top-1 21 % full / 12 % live, hub alarm.
4 stock-lopia was corrupt, leaked, and incomplete R Worse than reported: its crops come from the eval photo, so scoring against it is memorization. It scores 94–97 % "identity", and the evaluator's leak check misses it because the re-cut crops aren't the same size.
7 eval_identity.py is the right research direction C Right skeleton, four more defects (F-07).
8 Bug #1, template IDs vs SKU IDs R, and still live in production Fixed only in the evaluator. The live path accepts sku_l01_1, then finds no catalog row for it (F-02).
9 Hybrid 80 % > matcher 70 % X About one facing, on wrong labels.
10 Bug #2, unscaled boxes caused the live collapse R Reproduced on both catalogs: unscaled boxes give 11–17 %, scaled boxes give 76–97 %. The confound is resolved. The coordinate fix, not the catalog cleanup, removed the collapse.
11 Clean packshots are a dramatic improvement R, with a caveat True against the leak-free baseline. The l06 packshot shows a superseded package design (F-12). The l09 packshot was 305 px tall at audit time and has since been replaced by a 1000x1000 one, so that caveat no longer applies to the shipped catalog.
12 Bug #3, the reject gate inside the shortlist R Hybrid-49 %-vs-20 % result is invalid.
13 DINO top-K sweep: recall 71 %/100 %, K doesn't hurt the matcher C On a 7-SKU catalog, top-5 is 71 % of the catalog; that sweep can't speak to retrieval. Reproduced on v2: top-3 recall 79–81 %, top-5 95–98 %.
14 Matcher ~8 ms vs hybrid ~45 ms R, with a correction The hybrid lane scores the full catalog and then filters (eval_identity.py HybridLane.rank), so its latency is DINO + full matcher by construction. The measurement is honest; it doesn't describe a real retrieval design.
15 69 % full / 66 % live after the l09 fix R as arithmetic, X as a measurement Reproduced exactly (24/35, 23/35) on v1 labels. On corrected labels the same matcher and catalog give 71 %/76 % after the gate and 76 %/83 % ungated top-1 ranking. The 69/66 gap that justified crop_first_attempt doesn't exist.
16 Local matcher is the strongest identity primitive R And improvable: multi-scale RootSIFT + colour re-rank 40/42 vs 32/42 at full (sign test p = 0.04); at live 36 vs 35 (noise).
16 DINO as coarse retriever at scale X (this setting) Recall@10 falls 42 → 32 → 17 → 7 of 42 as the catalog grows 7 → 57 → 207 → 493 SKUs. A RootSIFT descriptor index holds 37–38/42 recall@5 over the same range.
17 Threshold alone can't create correct identities R And the missing identities are mostly catalog gaps (§3).
17 Argmax precision ~20 % C 29 % full / 35 % live on corrected labels; the old number counted mislabelled facings.
21 Live tests: 49 tests, 1 failure, 5 errors X (stale) python -m unittest test_live now passes 49/49 on both the dirty tree and the committed baseline. One test feeds a str result that raises inside a future callback and is swallowed, on both trees (F-13).
22 "High confidence" list items 1–8 R All hold. Item 4 is now measured with corrected labels.
22 Medium confidence 1 (69 %/66 %) X See row 15.
22 Medium confidence 3 (more views help) R direction Leave-facing-out proxy (§3, E-5) supports it strongly, with a same-photo caveat.
22 Not established 1–9 U 1 and 4 are now partly answered (§6); 2 partly (RootSIFT/multi-scale beat Van's composite); 3, 5, 6, 7, 8, 9 remain open. Item 9 (ground truth) was the most consequential open item: it was wrong.

The four ceilings, separated

On corrected labels (eval/labels/image6.json): 109 scored facings, 61 in-catalog across 11 SKUs, 48 unknown, 2 excluded. The clean catalog enrolls 7 SKUs covering 42 of the 61 in-catalog facings.

Ceiling Value (live, improved matcher) What limits it
A. Catalog 42 / 61 = 69 % of in-catalog facings are answerable by image l10 (11 facings), l07 (6), l04, l08 unenrolled; l12 has zero real facings on the shelf
B. Retrieval n/a at 7 SKUs; RootSIFT index recall@5 = 38/42 at 493 SKUs the 4 misses are catalog-quality cases (l06 package version)
C. Identity (ungated top-1 on enrolled) 36 / 42 (86 %, Wilson 72–93 %) l06 at live (1/4), two l09 facings that are mostly occluded
D. Decision 26 correct at 79 % precision (geometry only); 36 at 95 % (+ text) near-unknown siblings: 9 of 11 unenrolled l10 facings → l09, Kewpie 450 g → l03

3. Root-cause model

Ordered by how much of the gap to a good product each class explains.

Evaluator and ground-truth problems (they decided every conclusion so far)

  • 23 % of v1 labels were wrong: 14 in-catalog facings marked unknown (Egg Care #4–5, Fitte #22–23, Lucky #27–28, Kokuuma #40–45, Pure Select #59); 9 non-catalog items labelled as catalog SKUs (demi-glace pouches #31–32 as l09, a garlic jar #16 as l05, a Heinz can #64 as l10, dressing bottles #76–79 as l12, Kewpie 450 g #85 as l03); 2 neighbour shifts (#29 l08→l07, #75 l12→l11). The pattern (runs shifted along a shelf row, a match IoU field on every row) looks like labels propagated from shelf-tag or box matching rather than read from the packages.
  • The evaluator reports top1 and coverage as the same quantity (accepted correct / enrolled), so the identity ceiling and the decision ceiling were never separated.
  • Precision excludes unenrolled in-catalog facings, which are the most dangerous false accepts.
  • The gallery's candidate set comes from ground-truth labels.
  • Leak detection only catches same-size crops.

Catalog and data problems (the largest recognition lever)

  • Incomplete category enrollment is what creates near unknowns. In the leave-facing-out proxy (E-5), adding one shelf view for each of the 11 SKUs on the shelf takes argmax from 34/52 to 47/52. The number that survives at 95 % precision goes from 14–16 to 32–39.
  • Wrong or stale references: the l09 history; the l06 package is a superseded design (the match holds at full resolution, fails at live: 1/4); the l09 packshot is 305 px, so its scores dwarf others under Van's formula.
  • Metadata integrity: stock-lopia-clean/stock.csv has no sku_l09 row for sku_l09.jpg. Its sku_l10 row carries l09's stock counts with l10's name and price. The catalog name for l12 ("焼肉のたれ 中辛", ¥299) probably names the ユーラス 旨たれ bottles on the shelf (tag ¥299). Several catalog sizes disagree with the shelf tags (l10 tag says 400 g).

Identity-model problems (real but second-order on this data)

  • Scale mismatch: ~1000 px packshots vs 110–250 px crops is a 4–8× ratio. ORB's 8×1.2 pyramid tops out at 3.6×, so ORB contributes zero inliers at live resolution, and BRISK nearly none. Van's weighted composite is effectively SIFT-only there, carrying dead weight.
  • Van's score, inliers·1.5 + inliers/min(kp)·80, isn't comparable across templates of different resolution. With fewer than 4 matches it counts raw ratio-test matches as "inliers" without any geometric check (compute_geometric_inliers).
  • Grayscale-only: pink (ゼロ) vs red (ハーフ) lattice is invisible to SIFT. The hue re-rank fixes 2–3 facings in every configuration.

Open-set decision problems

  • decide_identity for the inlier scale accepts a score ≥ 1.0 with a relative margin ≥ 0.15. A single unverified match clears the floor, and the margin can't reject a sibling with a strong partial match. It accepts 22–28 of 48 unknowns.

Live and tracking problems (code-path evidence, unmeasured on device)

  • A confirmed track is never re-evaluated (_on_classified returns early when track.confirmed), and with vote_agree = 1 the first accepted observation locks it. A wrong first label is permanent. A later, sharper, correct view can't correct it.
  • Aggregation is the mean over the last three observations, with empty observations counted as zeros. That's a heuristic, not evidence accumulation.
  • Uncommitted diff: the crop blur gate returns without consuming an attempt, so a permanently soft crop (a distant facing) is retried every retry_gap frames for as long as it stays in view.

UX latency problems

  • No provisional label exists on the wire or in the clients, so any multi-look confirmation becomes dead time on screen. That's why vote_agree = 2 was (correctly) backed out.

4. Architecture candidates

"Recognition" = does it improve correct shown labels at fixed precision? "Scalability" = does it keep latency and recall flat as the catalog grows?

ID Candidate Tested here? Recognition Scalability Verdict
A Van's matcher, full reference set, argmax yes 32–35 / 42 identity; 29–35 % precision linear, ~0.1–0.25 ms per reference Floor. Not shippable (no open set).
B Matcher + engineering: RootSIFT, per-SKU reference pyramid (200/400 px tall), SKU grouping, similarity-constrained RANSAC, hue feature, calibrated gate yes 40/42 full (p = 0.04 vs A), 36/42 live; gate alone reaches 79–85 % precision at 36–43 % coverage same as A Adopt as identity core.
C Learned local features: ALIKED/DISK/SuperPoint + LightGlue; dense LoFTR/RoMa no (weights not installed; downloading needs your approval) Unknown. Headroom on enrolled facings is ≤ 6/42 at live, mostly catalog-caused. Likely strongest on small or blurred crops and package-version drift. LightGlue is per-pair; tens of ms per pair on GPU and much more on CPU, so only viable as a top-K verifier Postpone to experiment E-C. Adopt only if it clears the gate.
D Retrieval → Van's matcher yes (DINO) DINO recall collapses with scale (7/42 @10 at 493 SKUs) DINO adds ~41 ms per crop Reject DINO retrieval.
D′ RootSIFT descriptor index (FLANN) → vote → verify top-10 yes 35–37/42, equal to or slightly better than exhaustive 1.7–3.9 ms per crop flat from 7 to 493 SKUs; build 171 ms for 64k descriptors Adopt past ~150 SKUs.
E Retrieval → learned verifier no — — Only if C wins.
F OCR text lane against catalog text signatures yes (macOS Vision) Veto: precision 62 → 82 % at τ=0.5 for −1 correct. As a lane: +10 correct at live, including unenrolled SKUs. ~18–26 ms per crop on Mac; on-device on iOS (Vision) and Android (ML Kit) Adopt.
G Fusion (geometry + colour + text + temporal + tag prior) partly (geometry + colour + text) Largest measured gain — Adopt; the text and geometry errors are complementary (text reads siblings apart; geometry covers text-poor faces).
H Store-trained identity head on DINO no (it was trained on the eval shelf) Leaked; no valid measurement exists — Retire with DINO.
I Barcode/JAN — Front faces rarely show JANs; shelf tags carry product text, not scannable codes, at scan distance — Only for the enrollment tool (scan a JAN, pull the packshot).
J Shelf-tag / planogram prior no (tags.py exists) Every facing run on image6 sits above a readable tag with name and price — Strong hypothesis, H-02.
frame / native crop
  │
  ├─ local lane:  RootSIFT on crop ─► verify against EVERY reference view
  │               (exhaustive while refs ≲ 300), similarity-RANSAC,
  │               per-SKU evidence = max over views:
  │               {inliers, 2nd-best ratio, inlier hull cover, hue agreement}
  │
  ├─ text lane:   OCR (upsampled ×2 when crop height < 200 px)
  │               ─► bigram similarity to every catalog row's text signature
  │               (name + OCR of its packshot), including rows with no image
  │
  ▼
decision:  P(correct | local, text) from a small calibrated model
           accepted  if P ≥ p_confirm            (target precision ≥ 95 %)
           provisional if P ≥ p_provisional      (shown as tentative)
           veto      if text names a different catalog row by margin
           unknown   otherwise
  │
  ▼
track:     log-odds accumulation per track across looks; confirm on
           threshold or text agreement; re-open on strong contradiction
           (hysteresis), never on a single weak frame

Why each piece earns its place:

  • Local lane. It's the only primitive that ranks fine-grained siblings correctly: 36–40 of 42 enrolled facings, against 5–9 for DINO. It costs ~4–10 ms per crop at 7 SKUs.
  • Reference pyramid (200/400 px) and RootSIFT. Together they fix the scale mismatch that kills ORB/BRISK and makes scores comparable across references. Drop ORB/BRISK unless ablation H-08 finds a case they win.
  • Hue feature. It's the cheapest fix for colour-coded variants (+2–3 facings everywhere).
  • Text lane. It's the only measured mechanism that rejects near-unknown siblings, and it can name catalog SKUs that have no image yet. It contributed +10 correct labels and 0 unknown accepts at live, τ=2.
  • Calibrated decision. It replaces a hand-set floor that accepts one unverified match. It's calibrated on Tier-2 data, never on the photo it's evaluated on.
  • No DINO, no retrieval stage. Neither adds recognition at this scale, and DINO actively fails at larger scale. Exhaustive verification is cheaper than one DINO forward pass until ~300–400 references.

Placement: keep the matcher server-side for now; it's small and deterministic. Put OCR on the phone when tag and label OCR move there. Apple Vision and ML Kit are the official platform paths, matching the repo's platform/ rule. Send the text with the crop so the server fuses both. Until then, run OCR on the server (macOS Vision is already wired in code/ocr.py).

6. Scaling architecture

Measured cost and recall to ~500 SKUs; beyond that, extrapolated and marked.

Catalog Candidate generation Verification Evidence
10–150 SKUs none (exhaustive) local + text 3.7 ms at 7 SKUs, 7.7 ms at 57, 21 ms at 207 (small refs). Packshot pyramids cost ~2–3× per reference.
150–5,000 RootSIFT descriptor index (FLANN now; FAISS IVF-PQ when memory matters), vote per SKU, top-10 local + text on the shortlist Measured flat 2–4 ms and recall@5 38/42 up to 493 SKUs. Descriptor memory at 5k SKUs × 2 views × ~500 descriptors ≈ 5 M × 128 B (uint8) ≈ 640 MB before PQ. Extrapolated.
5,000–100,000 Scope first: store assortment → aisle/bay (tag or planogram prior) cuts candidates to hundreds. Then an aggregated-local-feature index (ASMK/VLAD-style) or a product-instance embedding fine-tuned on packshot↔shelf pairs. local + text on ≤ 20 Hypothesis. No store stocks 100k SKUs at once. Scoping by store and bay is the real scaling lever. Generic DINO is ruled out; a fine-tuned embedding needs paired training data that doesn't exist yet.

Exhaustive matching stops being sensible when its p95 exceeds the per-crop budget. At ~0.1–0.25 ms per reference and a 30 ms budget, that's roughly 150–300 references (≈ 75–150 SKUs at 2 views). Descriptor indexing removes the need for a global DINO pass: at 493 SKUs it retrieves better than DINO at a tenth of the cost. The open question is same-category growth: hundreds of mayonnaises instead of other-category distractors. That's experiment E-6.

7. Experimental plan

Rule for every experiment: change one variable, run on fixed crops and a fixed catalog hash, log per facing (truth, enrolled?, per-SKU evidence vector, truth rank, decision, lane latencies), and report the four ceilings separately.

Benchmark tiers (build these first; they block every other decision)

  • Tier 1, diagnostic: eval/labels/image6.json (109 facings). Dev only. Free to tune on. Its numbers never ship.
  • Tier 2, held-out regression: new captures that are never enrolled and never tuned on. At least 3 visits across ≥ 2 LOPIA locations or days, same aisle, both still photos and the phone's own 1920×1080 frames. Target ≥ 300 in-catalog facings over ≥ 20 SKUs, ≥ 300 near unknowns from the same aisle, and the 602-crop far-unknown set from add/.
    • Labels need provenance: shelf-tag correspondence plus readable package text, two independent passes, disagreements marked exclude.
    • Every label row carries source, confidence, and labeler. A label file without them fails a schema check.
  • Tier 3, live replay: FrameRecorder walks (code/record.py) of the same aisles with Tier-2-style labels, replayed through code/eval_live.py.

Experiments

ID Question Variable Metric Acceptance / falsifier
E-0 Are the labels right? none Double-labelling agreement on Tier 2 κ ≥ 0.9 on SKU identity. Otherwise fix labels before anything else.
E-1 Does B beat A on held-out data? matcher variant Paired identity top-1 on enrolled facings, McNemar B ≥ A with p < 0.05 at native-crop resolution. If A ≥ B, keep Van's scoring and add only SKU grouping and the decision layer.
E-2 Is ORB/BRISK dead weight? drop ORB/BRISK from B top-1, latency Drop them if top-1 changes by ≤ 1 facing per 100.
E-3 Does text move the frontier? ± text lane Correct shown at precision ≥ 95 %; near-unknown FAR Text lane adds ≥ 5 pp coverage at 95 % precision. Otherwise text becomes veto only, or is removed.
E-4 Calibrated decision vs hand threshold decision policy Coverage at 95 % precision, calibration (ECE) on Tier 2 Model ECE ≤ 0.05 and coverage ≥ hand threshold's.
E-5 How much is catalog completeness worth? enroll the aisle's remaining SKUs with packshot + one shelf view from a different visit Coverage at 95 % precision; near-unknown FAR ≥ 70 % of the same-photo proxy gain (14→32 full, 16→39 live) survives cross-visit. If not, shelf views are overfitting to capture conditions.
E-6 Same-category scaling grow the catalog with other sauce/mayo SKUs (packshots) top-1, recall@K of descriptor index vs exhaustive; latency p50/p95 Index recall@10 ≥ 98 % of exhaustive top-1 at ≥ 300 SKUs. Otherwise stay exhaustive and parallelize.
E-C Learned matcher ALIKED+LightGlue (or DISK+LightGlue) as verifier over B's top-5 top-1, coverage at 95 % precision, latency Adopt only if +3 pp coverage at 95 % precision and p95 identity latency ≤ 80 ms server-side.
E-7 Live temporal model log-odds accumulation + provisional labels vs current single-shot lock Tier 3: time to provisional, time to correct confirmed, flips, correct labels lost Time-to-first-correct p50 ≤ current single-shot; flips ≤ 2 %; correct-then-lost ≤ 1 %.
E-8 Does first-attempt native crop help? crop_first_attempt on/off Tier 3 correct confirmed labels, crop traffic Keep only if +3 pp correct at ≤ 1.5× crop traffic. On Tier 1 it has no basis: live ≥ full.
E-9 Tag prior ± shelf-tag evidence from tags.py Coverage at 95 % precision Adopt if +5 pp; also use it for catalog-gap discovery.

8. Migration strategy

Snapshot first: the uncommitted work is saved as stash object 2785c07 (and as tracked.diff / untracked.tgz in the audit scratchpad). Nothing has been reverted.

Item Class Action
code/eval_identity.py (untracked) evaluation infrastructure Keep and fix (F-07).
gallery.py rank() (diff) evaluation infrastructure Keep, eval-only; no production caller.
run-demo-server.sh --identity matcher (diff) clearly correct production fix Keep. Also change the default STOCK away from stock-lopia (F-03).
live.py crop_first_attempt, max_attempts 2→4 experimental Revert. Its stated rationale (69 vs 66) is invalidated. Re-test as E-8.
live.py blur gate early return, sharpen_ratio, best_sharpness experimental, with a retry hazard Revert. Re-introduce inside the E-7 evidence model, where a soft crop is a low-weight observation, not a free retry.
live.py vote_agree + _agreed_observations experimental Revert (default 1 makes it dead code). E-7 replaces it.
test_live.py change encodes the reverted budget Revert with it.
code/rebuild_catalog.py (untracked) catalog tooling Keep. Add image↔CSV bijection, packshot-OCR vs name check, and eval-session refusal (enrollment protocol below).
stock-lopia-clean/ (untracked) catalog data Keep the images and provenance. Fix stock.csv (F-04) and the README's "Six SKUs" (it lists five). Replace the l06 packshot with the current package.
eval/labels/image6.json ground truth Adopted; schema: label, source, note, v1_label.
identity.decide_identity inlier branch production Rewrite as the calibrated decision (E-4); keep the cosine branch only while the gallery exists.
Production score path (engine.score_crop, classify_boxes, inspect) production Rewrite: return per-SKU evidence grouped by canonical SKU (F-02).
feature_matcher.py composite score production Rewrite into variant B behind an interface; keep Van's scorer as a selectable baseline lane for E-1.
Identity interface — Isolate: IdentityLane.evidence(crop) -> {sku: Evidence} with LocalLane, TextLane, and GalleryLane (postponed); DecisionPolicy separate; TrackEvidence in live.
DINO gallery, identity head, finetune_identity.py production code Postpone and fence off: not in the live path, not the default; delete after E-6 if nothing depends on it.
Descriptor index — Postpone until the catalog passes ~150 SKUs (E-6).
Provisional label on the wire + clients protocol + native UI Postponed dependency of E-7. Shared model gets a provisional identity state; Compose and SwiftUI render it as tentative.

Enrollment protocol

  • Views per SKU: at least one current-package front packshot, stored as a 200/400/800 px-tall pyramid (source ≥ 800 px tall), plus at least one real shelf capture at scan distance from a store visit that is not an evaluation session. Add side or back views only for SKUs displayed rotated.
  • Minimum usable resolution: reject any reference view under 160 px tall at its largest pyramid level. Crops that small carry no text geometry (observed: facings under 60 px wide fail every lane).
  • Metadata: JAN, maker, product name as printed, size, package version or date, source URL, capture session id, SHA-256.
    • The enrollment tool validates a one-to-one match between images and CSV rows.
    • It OCRs each packshot and requires the catalog name's distinctive tokens to appear (this check would have caught the l09/l10 mix-up and flagged the l06 package version).
    • It flags cross-SKU near-duplicates as "text-discriminated siblings".
  • Leakage: enrollment refuses any image whose capture session id appears in an evaluation manifest. Pixel equality is not a leak test; re-cut crops pass it.
  • Versioning: the catalog manifest has a content hash; every eval report records catalog hash, label-file hash, and code SHA.
  • Completeness rule: a bay is "live-ready" when every SKU on its shelf tags is enrolled or explicitly listed as text-only. Near-unknown precision depends on this more than on any threshold.

9. Completion gate

Measured on Tier 2 held-out (and Tier 3 for time-to-label). Same crops, same catalog hash, same detector for both systems. "Van" means Van's scorer with always-argmax, run on that same catalog.

Gate Definition Pass
G1 coverage correct shown labels ÷ all in-catalog facings (enrolled or not) new ≥ Van; lower 95 % bound of the paired-bootstrap difference ≥ −2 pp
G2 correctness correct shown ÷ all shown ≥ 95 % (Van: 29–35 % on Tier 1)
G3 unknown handling near-unknown false-accept rate; far-unknown false-accept rate ≤ 5 %; ≤ 1 % (Van: 100 %)
G4 time to label Tier 3: p50 / p95 seconds from a facing entering view to a correct label shown (provisional counts only if later confirmed as the same SKU) p50 ≤ 1.0 s and p95 ≤ 2.5 s, and p50 no worse than Van's click-to-label on a still (tap + ~50–100 ms)
G5 stability confirmed labels that later change; correct labels that disappear ≤ 2 %; ≤ 1 %
G6 per-SKU floor recall of every enrolled SKU with ≥ 5 Tier-2 facings ≥ 60 % (catches l06-type catalog faults)
G7 hubness max predicted-SKU share ÷ that SKU's true share ≤ 2.0
G8 runtime server identity latency p95 per crop; server memory ≤ 60 ms; within the demo host's budget

Not complete until G1–G7 pass on the device path (Tier 3), not just offline.


Findings

ID Finding Severity Evidence Priority Source
F-01 image6 ground truth 23 % wrong blocking VERIFIED P0 open-world
F-11 No held-out benchmark exists (one photo) blocking VERIFIED P0 open-world
F-02 Production scores are per template, not per SKU → self-ambiguity and nameless labels blocking VERIFIED P0 open-world
F-03 Demo server defaults to the leaked, corrupt stock-lopia blocking VERIFIED P0 open-world
F-04 Clean catalog CSV ↔ image mismatch (l09/l10) blocking VERIFIED P0 open-world
F-05 Near-unknown siblings accepted; shown-label precision 29–43 % blocking VERIFIED P0 open-world
F-07 Evaluator defects beyond bugs #1–#3 major VERIFIED P1 open-world
F-06 Matcher scoring defects (scale, dead ORB/BRISK, unverified "inliers", grayscale) major VERIFIED P1 open-world
F-08 DINO unfit as classifier and retriever here major VERIFIED P1 open-world
F-09 Confirmed labels never re-evaluated; first accept locks major HIGH CONFIDENCE P1 open-world
F-10 Uncommitted live diff: unbounded blur retries; rationale invalidated major HIGH CONFIDENCE P1 open-world
F-12 l06 packshot is a superseded package design major VERIFIED P2 fixed 2026-09-29
F-13 Stale handoff test status; swallowed callback exception in a test fixture polish VERIFIED P3 open-world

Counts: 11 VERIFIED / 2 HIGH CONFIDENCE / 0 HYPOTHESIS (hypotheses listed separately) · 13 open-world. No reference baseline was used: this audit covers a recognition backend. The product-level floor is Van's system (G1–G4).

Workstreams

  1. Evidence (single owner, first): F-01, F-11, F-07. Blocks every architecture change.
  2. Catalog (parallel with 1): F-03, F-04, F-12, enrollment tool checks.
  3. Identity core (single owner, after 1): F-02, F-06, text lane, decision layer, E-1–E-4.
  4. Live (after 3's interface exists): F-10 revert now; F-09 and the provisional label (E-7) later. The protocol plus native-UI part touches the KMP app and needs its own owner.
  5. Scaling (after 3): E-6, and the descriptor index only when it's needed.

F-01 — image6 labels are 23 % wrong — P0 · blocking · VERIFIED

  • Root cause: labels appear propagated from tag/box matching (every row has a match field) rather than read from packages.
  • Evidence: evidence/image6-v1-labels-overlay.jpg; the per-index list in §3. Rescoring the same runs under v2 moves ungated live top-1 from 26/35 to 35/42.
  • Target: every scored label carries provenance; ambiguous facings are excluded.
  • Change: adopt eval/labels/image6.json; schema-check label/source/note.
  • Validation: E-0 double-labelling on Tier 2.
  • Acceptance: no architecture claim cites v1.

F-02 — template-level scores in production — P0 · blocking · VERIFIED

  • Root cause: ProductFeatureMatcher.score_crop keys scores by file stem; engine.score_crop (code/engine.py:424-448), classify_boxes, and inspect pass them straight to decide_identity. The live payload looks up identity.sku_id in the catalog, which has only canonical ids.
  • Evidence: on stock-lopia (the demo default), 22/109 facings at full and 29/109 at live are "ambiguous" only because the top two are views of the same SKU. 68 of 69 (full) and 56 of 58 (live) accepted ids are view-suffixed (sku_l01_1), which have no catalog row, so the payload carries no product block.
  • Target: scores grouped by canonical SKU (max over views) before any decision; the id shown is always a catalog row.
  • Change: group at the lane boundary (the future IdentityLane.evidence). Share one canonicalizer with rebuild_catalog.sku_of.
  • Validation: unit test: two strong views of one SKU → accepted, canonical id.
  • Acceptance: zero view-suffixed ids on the wire.

F-03 — demo default catalog is the leaked one — P0 · blocking · VERIFIED

  • Evidence: run-demo-server.sh:11 STOCK="${1:-stock-lopia}". That catalog is shelf crops cut from the eval photo, and it scores 94–97 % "identity" on its own exam.
  • Change: default to the clean catalog once F-04 is fixed; refuse catalogs without a provenance manifest.

F-04 — clean catalog metadata mismatch — P0 · blocking · VERIFIED

  • Evidence: stock-lopia-clean/stock.csv has no sku_l09 row for sku_l09.jpg; its sku_l10 row has l09's counts (30/13/17) with l10's name and price. Every l09 answer ships without product info; l10 has a row but no image. The README says "Six SKUs … have no official pack shot" and lists five.
  • Change: restore the l09 row from stock-lopia/stock.csv, drop the mangled l10 counts, fix the README, and add the bijection check to rebuild_catalog.py.

F-05 — near-unknown siblings accepted — P0 · blocking · VERIFIED

  • Evidence: the current gate accepts 22–28/48 unknowns; 9/11 unenrolled l10 facings go to l09 with margin 0.67–0.93 (higher than many correct l11 matches); Kewpie 450 g → l03 with 17–28 SIFT inliers.
  • Target / change: the decision layer + text veto (§5) + enrollment completeness (E-5).
  • Acceptance: G2, G3.

F-06 — matcher scoring defects — P1 · major · VERIFIED

  • Evidence: ORB inliers are 0 on nearly every live crop (per-facing log from diag.py). compute_geometric_inliers returns len(good_matches) unverified when there are fewer than 4. The l09 reference (305 px) scores 85–129 while others score 10–40.
  • Change: variant B (§5), behind the lane interface, with Van's scorer kept as a baseline lane.
  • Acceptance: E-1, E-2.

F-07 — evaluator defects — P1 · major · VERIFIED

  • top1 ≡ coverage (both are accepted-correct ÷ enrolled), so the identity and decision ceilings were never separated.
  • Precision's denominator excludes unenrolled in-catalog facings (covered in main): it reports 50–53 % where the honest figure is 39–43 %.
  • The gallery's SKU set comes from ground-truth labels (sku_ids from held).
  • Leak check requires the same shape.
  • HybridLane scores the full catalog, so its latency isn't a retrieval design.
  • group_by_sku only handles the sku_l prefix.
  • Change: report the four ceilings explicitly (the per-facing log in diag.py/variants.py is the template). Use the catalog, not labels, for candidate sets. Refuse leakage by session manifest.

F-08 — DINO — P1 · major · VERIFIED

  • Evidence: gallery top-1 21 %/12 % with 50–60 % hub share; recall@10 of 7/42 at 493 SKUs (dino_scale.py); 41 ms per embedding.
  • Change: fence off (§8). The uncommitted --identity matcher default is the right call.

F-09 — no identity re-evaluation — P1 · major · HIGH CONFIDENCE

  • Evidence: code/live.py _on_classified: if track.state != STATE_DETECTED or track.confirmed: return. With vote_agree = 1 the first accepted observation confirms.
  • Target: E-7's evidence model with provisional labels and hysteresis.
  • Validation: Tier 3 flips and correct-then-lost metrics.

F-10 — uncommitted live experiments — P1 · major · HIGH CONFIDENCE

  • Evidence: the blur gate returns before track.attempts += 1, so eligibility never runs out for a soft crop. crop_first_attempt cites the invalidated 69/66 numbers.
  • Change: revert per §8.

F-12 — l06 package version — P2 · major · FIXED 2026-09-29

  • Evidence: the packshot showed "内臓脂肪ケア フィッテ"; the shelf shows "内臓脂肪を減らす キユーピーフィッテ". Improved matcher 4/4 at full, 1/4 at live; 3/3 with a shelf reference.
  • Fix: the current package is enrolled. It was recorded as blocked on a capture because Kewpie's own site serves the superseded design for that JAN; an independent retailer listing carries the current one, and it matches the shelf facings exactly (checked by eye against a crop of add/image6.jpg before enrolling). Measured: live decision 60% → 64%, end-to-end 59% → 62%, full decision 81% → 83%.
  • Still open: the packshot-OCR check that would have caught this. It is implemented (rebuild_catalog refuses a view whose name/OCR score is below 0.70) but it is a false negative on l04 and l07, so it cannot yet be trusted as a gate - see the catalog corrections section.

F-13 — stale test status — P3 · polish · VERIFIED

  • Evidence: 49/49 pass on both trees. An AttributeError: 'str' object has no attribute 'items' is raised and swallowed inside a future callback in one test, on both trees.
  • Change: make that fixture return a dict, or assert the callback path.

Hypotheses

ID Suspected Why it matters The check that settles it
H-01 Learned matchers (ALIKED/DISK + LightGlue) beat B on small, blurred, or re-designed packages Could lift the identity ceiling E-C (needs weight download approval)
H-02 Shelf-tag OCR prior raises coverage and precision Tags are readable above every run on image6 E-9 using code/tags.py
H-03 Cross-visit shelf references retain most of the leave-facing-out gain Decides the enrollment protocol E-5
H-04 Evidence accumulation shortens time-to-correct-label vs single-shot lock G4/G5 E-7 on Tier 3
H-05 On-device OCR is fast enough (< 30 ms per crop) and as accurate at native crop resolution Where the text lane runs Benchmark Vision on iPhone and ML Kit on Android over Tier-2 crops
H-06 Stream JPEG quality costs SIFT inliers at live Cheap fix if true Rerun variants.py on re-encoded crops at the client's quality
H-07 The hue feature is robust to store lighting The colour gain could be lighting-specific Tier 2 across stores and times
H-08 ORB/BRISK removal costs nothing Simplifies and speeds the matcher E-2 at native-crop resolution, where ORB helped the small l09 reference
H-09 Full-resolution OCR with ×2 upsampling matches live's text-lane gain Full-res fusion was weaker (75 % precision) with ×1 OCR Rerun ocr_exp.py + fuse_eval.py with full ×2

Preserved decisions

  • The matcher kept as a selectable baseline lane. The shipped default is now local (variant B plus the text lane), which is what the measurements below support; --identity matcher reproduces Van's scorer for E-1.
  • Bounded, drift-checked crop lane protocol (aa25f99, 13a4e0f); its tests pass.
  • eval_identity.py's architecture-neutral lane framing and explicit enrollment ceiling.
  • Clean-catalog provenance table and the rebuild_catalog.py refusal rules.
  • vote_agree = 1 without provisional labels. Backing out 2 was right.

Implementation record (2026-09-28)

What §8 asked for, and where it landed. Verified by the suite (274 tests) and by running the real server against add/image6.jpg.

§8 item Status
F-02 per-SKU scores in score_crop, classify_boxes, inspect Done. group_by_sku on every backend; no view-suffixed id reaches the wire.
F-03 demo default catalog Done. run-demo-server.sh defaults to stock-lopia-clean.
F-04 clean catalog CSV Done. All 12 rows; sku_l09 added, sku_l10 corrected.
F-05 near-unknown siblings Done via the text lane. 0 of 48 unknowns accepted at live resolution.
F-06 variant B behind a lane interface Done. identity_lanes.LocalLane; Van's scorer stays selectable as --identity matcher.
F-07 evaluator defects Done. Four separate ceilings, honest precision denominator, catalog-derived candidate set, sku_of grouping, leak signature.
F-08 DINO fenced off Done. Not the default, not in the live path, refuses to start without weights.
F-10 uncommitted live experiments Reverted. crop_first_attempt, the blur early return, vote_agree and max_attempts 4 are gone.
F-12 l06 current package Fixed. The current package is enrolled from an independent retailer listing and matches the shelf facings exactly. Live decision 60% → 64%, end-to-end 59% → 62%.
F-13 stale status / swallowed exception Done. The fixture returns the matcher's (scores, details) shape; zero swallowed callback exceptions.
F-09 re-evaluation with provisional labels Postponed as §8 specifies — it is a dependency of E-7, which needs Tier-3 recordings that do not exist yet.
Enrollment protocol: versioning Done. Every eval report records catalog_hash, labels_hash, image_hash, code_revision. The catalog hash covers the CSV bytes and each view's bytes, not just the view names: names alone let a different packshot be swapped in under the same name with the hash unchanged.
Enrollment protocol: sibling flag Done. rebuild_catalog reports cross-SKU near-duplicates as text-discriminated siblings (cosine ≥ 0.98, same shape). Reported, not refused: they are legitimately different products, and only package text separates them.
Enrollment protocol: completeness rule Done. --shelf-tags turns it into a live_ready verdict; without the shelf's SKU list the verdict is unknown, not a guess.
Enrollment protocol: pyramid floor Done. rebuild_catalog reports views under 800 px tall. Every view in stock-lopia-clean is now 1000 px tall, so the check is clean on the shipped catalog; it still names any view that regresses.

Two defects found while wiring the lanes into the live path, both fixed: live re-sorted the local lane's scores by value, which discards the lane's own rank order (order comes from rank_key, the value is only the gate), and the early return on an empty local observation made the text lane's "answer on its own" job unreachable. Live now decides per crop on the lane's own order.

Measured after the change, through the real server: /v1/recognize names 41 of 112 detections at 90% precision with no view-suffixed ids; the live WebSocket names 12 at 100% precision with 0 unknown accepts, first label on frame 3. Those are detector-box figures, not label-matched ones: the 41 are detections whose box overlaps a labelled facing, so they measure the endpoint rather than the identity lane. The label-matched number is the evaluator's, below.

The baseline is Van's contract, not the gated matcher

The matcher lane applies decide_identity, which is not what the original demo did - it answered every crop with its best-scoring SKU and had no accept line. Comparing against the gated matcher understates the baseline's coverage and overstates its precision, so the evaluator now has a van lane: the same scores, always answered. Every "vs Van" claim in this record uses that row.

.venv/bin/python code/eval_identity.py --stock stock-lopia-clean \
  --labels eval/labels/image6.json \
  --image add/image6.jpg --lanes van,matcher,local --live

full resolution — 109 scored facings (64 in-catalog, 45 unknown)
  lane     catalog  identity  decision  end-to-end  precision  false acc
  van          83%       77%       77%         64%        38%         68
  matcher      83%       77%       74%         61%        51%         38
  local        83%       92%       81%         75%        91%          5

live resolution — 109 scored facings (64 in-catalog, 45 unknown)
  lane     catalog  identity  decision  end-to-end  precision  false acc
  van          83%       79%       79%         66%        39%         67
  matcher      83%       79%       74%         61%        54%         33
  local        83%       85%       60%         59%        93%          3

Against Van's real contract the local lane wins both axes at both resolutions: 75% at 91% against 64% at 38% (full), and 59% at 93% against 66% at 39% (live). At live resolution Van answers more facings correctly - 66% against 59% - and is wrong on 67 of them to the local lane's 3. That is the trade the decision makes deliberately: coverage is a gate (G1) and precision is not negotiable, so the lane abstains where Van guesses.

One accept line serves both resolutions. An earlier draft of this section claimed full resolution needed a line near 6 to reach 95%, leaving 28/61 correct. That was measured before the reference-upscaling and text-signature fixes and no longer holds. The separation is clean at both scales: correct facings sit at p25 2.07 (full) and 2.87 (live), wrong ones at p75 1.00 and 1.66, so 2.0 is the boundary in both. Sweeping it at full resolution, through the evaluator (--accept, columns as it defines them: end-to-end is accepted-and-correct over the 64 in-catalog facings, precision is over the facings it answered):

accept   end-to-end   precision   false accepts
2.0      75%          91%         5
3.0      67%          91%         4
4.0      67%          93%         3
6.0      62%          95%         2

The line is still provisional - it is tuned on Tier 1 and needs Tier-2 calibration - but it does not need to differ by input path, and the design beats Van on both axes at both resolutions today.

The live count was pacing-dependent, and the cause was measurable: OCR ran on every crop at every attempt, taking a crop from 5 ms to 25 ms and throughput from 789 to 60 crops/s with 8 workers. The reading is now cached on the track and later attempts re-run only the local lane. Measured through the real server on add/image6.jpg:

saturated pool   15.9 -> 49.4 crops/s (8 workers, 64 crops)
per crop         142 -> 92 ms on a 300x400 crop
3 s clip, send   19.7 mean labels (15-24, unstable) -> 24.0 (23-25, stable)
12 s clip, send  48/131 -> 53/131; result pacing 53/134 -> 50/135

The 12 s numbers are within run-to-run noise; the short clip is where the pacing sensitivity lived, and it is now flat across pacings. Precision stays 100% and 0 unknowns are accepted at every pacing.

The stream path now has a G1 number. code/score_stream.py scores a recorded clip's labels against the ground truth, matching each track's label_box to the labelled facings. Three defects in the first version of that harness made the stream look far worse than it was, and all three are fixed: it counted any facing touching the window by one pixel (23 of its 47 "in-catalog" facings were less than half visible), it scored a correct text-lane answer as a false accept because it checked enrollment before correctness, and it counted tracks rather than facings, which reported 138% coverage of a 24-facing window. The denominator is now the facings at least half inside the union of the window rects the client showed.

Measured through the real server on add/image6.jpg, 20 s per run, local backend against the van lane on the same stream:

pan   lane    facings named   precision
0     van      11/24 (46%)      52%
0     local    11/24 (46%)     100%
2     van      12/24 (50%)      45%
2     local    18/24 (75%)     100%
12    van      11/24 (46%)      28%
12    local    16/24 (67%)      83%

Two live-path bugs surfaced while measuring this, both fixed:

  • Inherited labels carried frame id 0. _inherit_labels set label_box but never label_frame_id, so every inherited label was mapped back to the source image with the first frame's offset. During a pan that puts the box a whole pan-distance from the product it names. It now stamps the current frame and anchors to the raw detection box rather than the smoothed track box, which lags a pan by about one step.
  • The reference lane upscaled views smaller than a pyramid level. The sku_l09.jpg enrolled at the time was 407x305, so its 400 px level was a 305->400 blow-up: a blur with no high-frequency detail to disagree with, which won 31 of 54 crops on the shelf it was cut from. A view is now used only at the levels it has. The guard is in LocalLane._load and still applies; the file itself has since been replaced by a 1000x1000 packshot, which is why the current run no longer exercises it.

Coverage is still a gate (G1). Four 20 s pan replays of add/image6.jpg through the shipped lane name 63-70% of the in-catalog facings a window can show, at 76-84% precision (score_stream.py over data/evaluations/2026-09-29-session/stream/fin_*.json). Van's contract on the same stream answers more and is wrong far more often, which is the trade the decision makes. The earlier "53 of 131 tracks" figure was a harness artifact rather than a recognition limit.

The shipped default scale now has test coverage. make_session builds only matcher-backend engines, so scale == "local" in _on_classified never ran in the suite - and that is the branch that was wrong twice. LocalScaleEngine injects the lanes' evidence into the live path, and LocalScaleTests pins the two jobs (a text-only read still labels the track; a text veto blocks a local accept). DecisionPolicyTests pins the veto/fill-in boundary, including the lane-order-vs-value case the rank re-sort broke. Both regressions were re-injected to confirm the tests fail for the intended reason. Suite 251 -> 291.

Two further review findings, both fixed and both measured:

  • The evaluator had no always-argmax lane, so every "vs Van" claim compared against the gated matcher rather than Van's contract. The van lane is that contract; the numbers above use it.
  • A crop reading only "400g" scored 0.667 on three SKUs, above TEXT_ACCEPT, so the text lane named a product from a size line. The fill-in job now requires two non-digit bigrams; the veto is unchanged, so "65%" still contradicts a local answer. Three tests cover it and all three fail when the gate is removed.

The headline number is reproducible from the repo, not from a scratch script:

.venv/bin/python code/eval_identity.py --stock stock-lopia-clean \
  --labels eval/labels/image6.json \
  --image add/image6.jpg --lanes local --live

live resolution: catalog 83% · identity 85% · decision 60% ·
end-to-end 59% · precision 93% · 20.3 ms/crop

false accepts: 3 of 109 scored facings — 2 of the 45 unknowns and 1
enrolled misidentification; unenrolled_false_accepts 0.

The catalog figure fell from 87% to 83% because three facings were relabelled from unknown to sku_l12 once the tare was identified (§ Re-measured 2026-09-29), and sku_l12 has no enrolled view. That is a catalog gap reported honestly, not a recognition regression: the same three facings were unanswerable before and after.

Re-measured 2026-09-29

The text lane's evidence floor (shared bigrams between the read and the winning row's name) and the local lane's signature pass-through landed, and the decision ceiling was corrected: it divided a numerator that counted unenrolled facings by the enrolled count, so it read 117%.

full resolution — 109 scored facings (64 in-catalog, 45 unknown)
  lane     catalog  identity  decision  end-to-end  precision  false acc
  van          83%       77%       77%         64%        38%         68
  matcher      83%       77%       74%         61%        51%         38
  local        83%       92%       83%         77%        91%          5

live resolution — 109 scored facings (64 in-catalog, 45 unknown)
  lane     catalog  identity  decision  end-to-end  precision  false acc
  local        83%       91%       64%         62%        93%          3

E-3 answered. --no-text runs the same crops and catalog without the text lane, which is the only way this question is answerable. Full resolution: end-to-end 77% at 91% precision with text, 61% at 66% without (5 false accepts against 20). Live: 62% at 93% against 56% at 84% (3 against 7). The lane adds 16 pp and 6 pp of coverage while raising precision, against E-3's +5 pp bar. It is not a veto-only lane.

E-2 answered. LocalLane is RootSIFT-only; ORB and BRISK survive only in the matcher baseline lane, so there is nothing to drop from the shipped path.

Live path. _plan_classification had lost its in_flight increment while a label_frame_id write was removed, so the guard never blocked, every frame re-planned the same tracks, and the executor queue grew until the server stopped answering. Restored. Label memory boxes now carry the shelf origin they were measured against and are shifted before the IoU test, so a pan no longer slides a stale box onto its neighbour. The text lane is resolved before the session lock is taken, removing a lock-ordering hazard against its build lock.

Stream, 20 s pan, four runs: 63-70% coverage at 76-84% precision. The earlier "71-79% at 76-84%" was measured before the tare facings were relabelled from unknown to sku_l12 and before the size gate landed; the denominator moved from 61 in-catalog facings to 64, and the size gate trades a little coverage for precision. Still-image endpoint: 53 named detections in 2.6 s, against 41 before.

Catalog corrections 2026-09-29

Three rows named products the shelf does not hold, all found by reading the shelf tags and the enrolled packshots rather than by trusting the CSV:

SKU Was Is Evidence
sku_l10 ピュアセレクト マヨネーズ 450g 400g Shelf tag reads 400g; Ajinomoto's lineup is 250/400/600/1000g, no 450g. Packshot enrolled (JAN 4901001114030).
sku_l11 キューピー ハーフマヨネーズ 450g 400g Shelf tag reads 〈400g〉; the enrolled packshot's own OCR reads 400g.
sku_l12 焼肉のたれ 中辛 ユーラス 野菜をおいしく食べる旨たれ 380g Tag under facings #46-48 reads that title at ¥299, the price the row carried; the package reads お子さまOK 甘めテイスト, the opposite of 中辛.

The l12 row was the dangerous one: the text lane scores a crop against the row's printed name, so a correct read of the real bottle would have been vetoed by a row describing a different product. Facings #46-48 were relabelled from unknown to sku_l12 to match.

Enrolling l10 took the catalog ceiling from 69% to 87% of in-catalog facings answerable by image; relabelling the tare facings moved it back to 83%, because sku_l12 has no enrolled view. sku_l04, sku_l07 and sku_l08 remain unenrolled. l04 and l07 have exact packshots whose name/OCR check is a false negative, recorded in data/packshots/research/ with the retailer, JAN and the reason the check fails. l08 could not be sourced at all: no retailer listing for a スパイシーストーム ペリペリミックス 30g exists, and the only corroborated ペリペリミックス is a different brand at 50g, so the row's name and size are themselves unverified.

The near-unknown case is now rejected

The documented failure - Kewpie 450g bags the catalog does not hold, labelled as catalog rows - had no fix in the text ranking. Every mayonnaise row explains "マヨネーズ" equally, so the ranking ties at 1.0 and the strict-greater veto never fires; the veto compares two catalog rows and the crop's product is not one of them.

DecisionPolicy now also checks the package size the read prints. Only a mass in PACKAGE_MASS_RANGE counts, because below it are fragments of a longer number ("20" out of "200g") and above it are two numbers run together ("5210" out of "210g"), and percentages are excluded because "カロリー 50% カット" is a claim about the contents. Measured on the corrected labels, full resolution: precision 86% -> 91%, false accepts 8 -> 5, coverage unchanged at 79%.

External benchmark: 19 scored, 0 false accepts

The image6 shelf is one photo with one label set, so every number above is measured on data the design was tuned against. Twenty images for eight SKUs were collected from independent sources (six retailer and marketplace domains) to test the pipeline on data it had no part in building. None is Tier 2; all are external_proxy and every manifest says so.

Two leak checks run before any number is reported, both in code/eval_external.py:

  • Against the evaluation photo: the highest template match of any image against add/image6.jpg is 0.675, so none is a crop of it.
  • Against the catalog: an image that has since been enrolled is refused rather than scored. sku_l06_1.jpg is one - it is now the enrolled l06 view, so scoring it would be training accuracy wearing a held-out label. It is reported as leaked, not as a correct accept.

Scored through the shipped LocalLaneAdapter + DecisionPolicy against stock-lopia-clean (data/external-eval/*/eval_result.json):

Set Scored Correct Abstain False accepts Leaked
kewpie/ 11 8 3 0 1
other-mayo/ 8 4 4 0 0
Total 19 12 7 0 1
  • sku_l07 is answered with no enrolled image. Both its images accept through the text lane alone - one reads ラッキー フヨネーズ, the other the rear label's 製造者:丸和油脂株式会社 with 内容量:200g, which together name the row 丸和油脂 ラッキーマヨネーズ 200g. This is the text lane's coverage claim holding on data that was not used to build it. sku_l02 also accepts one image on text alone, but it does have an enrolled view.
  • The l04 abstentions are correct behaviour. l04 is also unenrolled and its reads are weak: one reads only コと旨 タ 味 (every row scores 0.0), another scores 0.379 against a 0.45 accept line. Abstaining on a weak read is the designed outcome, not a miss.

The seven abstentions are all unenrolled or weakly-read SKUs. No image was given a wrong name, which is the property the decision optimises for.

Tier-3 sources are staged, not satisfied

Two reusable pan clips were found and saved (data/external-eval/tier3-sources/): a 1920x1080 CC BY 3.0 supermarket pan and a 360p CC BY mayonnaise-bay pan, with 11 rejected candidates recorded. Neither gives per-SKU identity for this catalog, and the only LOPIA store walk found carries the standard YouTube licence and was therefore not downloaded. Tier 3 still needs the operator's own recorded walks.

Still open, and not implementable from here: Tier 2 held-out labels (F-11), the remaining E-1/E-4..E-9 experiments, and the completion gate G1-G7, all of which need a second independent shelf visit.

Out of scope

Detector recall and box quality (all experiments use the same boxes). Tag and planogram pipeline internals. Native UI. On-device performance. Learned local features (not installed; downloading them needs your approval). image.jpg / stock/ (no labels exist). Timings from parallel runs are relative; scale.py timings are single-process on this Mac.