The live path abstained too much: a detected product whose best candidate missed the accept line was left blank, so a shelf where 59% of in-catalog facings were shown read as broken. Show the best candidate immediately as `provisional`, keep the track re-classifiable so a later attempt upgrades or corrects it, and never blank a label already on screen. - identity.py / identity_lanes.py: every decision now carries `provisional_sku` - the row the evidence supports well enough to show, separate from `best_sku`, which still means "cleared the gate". The accept line does not move: far-unknown false accepts stay at 0.066%. - live.py: a below-gate candidate labels the track with `identity_level: provisional` and `confirmed = False`, so the planner and the crop lane keep offering it. Accepted labels stay frozen. Inherited provisional labels stay re-classifiable. - Clients render provisional distinctly (amber chip, dashed box) via `LiveScanOverlayPolicy.isProvisional(identity)`, which now reads the wire level instead of only the state. - build_stock_all.py merges stock, stock-lopia, stock-mediya and stock-milk150 into stock-all (111 images, 60 SKUs). The server could only read one folder, so anything outside it came back blank. stock-demo is byte-identical to mediya+milk150 and is not a source. Measured on add/image6.jpg at live resolution: in-catalog facings shown 59.4% -> 86.0%, correct shown 38 -> 55, precision 97.4% -> 87.3%. On the wire at 8fps 0.5x: 65 of 79 tracks labelled, 742 accepted + 309 provisional observations, first label 1.27s. docs/research/2026-09-29-shelf-intelligence/ carries the experiments and the decision: group signals (combined AUC 0.991 sibling / 0.990 unrelated), OCR scheduling (group representatives cut calls 4.4x and wall 3.3x), and the finding that group propagation damages more labels than it rescues on this shelf, so groups schedule compute rather than overwrite labels. 331 tests pass.
11 KiB
Dorayaki: shelf-intelligence decision
Measured 2026-09-29 · server product-recognition-demo/recognization at
fbf31d7 plus the coverage-first diff described in §F · evidence in
evidence/ · every number below is read from a JSON in that
directory, named inline.
Scope: the live decision path (group inference, OCR scheduling, decision levels), the catalog the server matches against, and the two native overlays. Not in scope: detector quality, tag/planogram pipeline, on-device runtime.
The previous phase (../2026-09-28-recognition-architecture/DECISION.md)
settled what matches a product: a local-feature instance matcher plus a text
lane behind a calibrated open-set gate. This phase settles how much of a shelf
to label, how fast, and how to spend compute doing it.
Verdict
Label every detected product with its best candidate immediately, mark it provisional, and refine it as stronger evidence arrives. Keep the accepted line where it is; stop treating abstention as quality. Group adjacent facings so one strong observation can answer for a run, and spend OCR on group representatives and ambiguous tracks instead of every crop.
The measured case for it, on add/image6.jpg at live resolution
(ocr-ablation.json, anytime-policies.json):
| Policy | Shown | Correct | Precision | Coverage of 64 in-catalog facings | Blank |
|---|---|---|---|---|---|
| strict / individual | 39 | 38 | 97.4 % | 59.4 % | 64.2 % |
| coverage-first (shipped) | 63 | 55 | 87.3 % | 86.0 % | 42.2 % |
The shipped policy shows 24 more products and gets 17 more of them right, at the cost of 7 more wrong labels. That is the trade the product asked for: a shelf where 86 % of products carry a name reads as working; one where 59 % do reads as broken.
A. Where the live time goes
replay.py on the running server, image6 at 8 fps (replay-image6-8fps-*.json):
| Stage | p50 | p95 |
|---|---|---|
| decode | 8–12 ms | 20 ms |
| detect | 67–107 ms | 131–278 ms |
| track | 1–3 ms | 6 ms |
| total | 90–140 ms | 200–300 ms |
Detection dominates frame cost by an order of magnitude; tracking is free.
Identity work is off the frame path (a worker pool), so it shows up as
label latency, not frame latency. scheduler-benchmark.json prices it:
218 classification tasks, worker p50 147 ms with OCR on every attempt versus
19.6 ms with OCR once per track. OCR is ~87 % of per-task identity cost.
Ranked bottlenecks: (1) OCR invocations, (2) matcher invocations per facing, (3) detector. Only the first two are ours to schedule.
B. Compute and evidence schedule
scheduler-benchmark.json, same 218 tasks:
| Schedule | OCR calls | Worker p50 | First facing | All first facings | Wall | CPU |
|---|---|---|---|---|---|---|
| every attempt | 218 | 147 ms | 1177 ms | 1890 ms | 4.32 s | 4.48 s |
| once per track | 109 | 20 ms | 1235 ms | 2680 ms | 2.77 s | 3.42 s |
| ambiguous track only | 83 | 16 ms | 806 ms | 1951 ms | 2.05 s | 2.97 s |
| group representative | 49 | 16 ms | 454 ms | 1168 ms | 1.30 s | 2.64 s |
Group representatives cut OCR calls 4.4× and wall time 3.3×, and they make the
first label appear 2.6× sooner. The accuracy cost is small and measured
(ocr-ablation.json, group metrics): full-resolution coverage 57.8 % → 56.0 %,
precision 87.3 % → 80.3 %; at live resolution coverage rises 56.0 % → 58.7 %
and precision 90.2 % → 87.5 %. Ship the group-representative schedule; the
ambiguous_track schedule is the fallback when grouping is unavailable.
The shipped live path already does the cheap half of this: OCR runs once per
track (live.py caches text_read on the track), and the local lane re-runs
on every attempt.
C. Repeated-facing group detection
analysis.json (resolutions.full and .live) scores eleven pairwise signals
on 83 adjacent pairs from image6 (72 same-SKU, 3 sibling, 8 unrelated), with
leave-one-row-out thresholds:
| Signal | AUC same-vs-sibling | AUC same-vs-unrelated | Precision | Recall | Boundary acc. |
|---|---|---|---|---|---|
| hue histogram | 0.977 | 0.967 | 98.3 % | 80.6 % | 81.9 % |
| logo pixels | 0.968 | 0.981 | 100 % | 93.1 % | 94.0 % |
| pixels | 0.944 | 0.979 | 100 % | 88.9 % | 90.4 % |
| inliers | 0.944 | 0.944 | 100 % | 88.9 % | 90.4 % |
| height | 0.972 | 0.775 | 92.6 % | 87.5 % | 83.1 % |
| aspect | 0.403 | 0.691 | 93.9 % | 43.1 % | 48.2 % |
| ocr_jaccard | 0.667 | 0.806 | 97.7 % | 58.3 % | 62.7 % |
| combined | 0.991 | 0.990 | 98.5 % | 91.7 % | 91.6 % |
Use the combined signal: it is the only one that is simultaneously strong on siblings (the hard case) and on unrelated pairs. Hue alone is nearly as good and is the cheapest single signal; logo pixels is the best single signal but needs a logo detector. Aspect ratio and OCR similarity are actively misleading on siblings (AUC 0.40 and 0.67) — do not gate a group on either.
At live resolution the combined signal holds: AUC 0.991 / 0.984, precision 96.9 %, recall 87.5 %, boundary accuracy 86.7 %.
D. Group-to-individual inference
representative.json prices propagating a group identity to its members, on
image6 (109 facings, 64 in-catalog):
| Arm | Matcher calls | Total ms | Shown | Correct | Precision | Coverage | Rescued | Damaged |
|---|---|---|---|---|---|---|---|---|
| sharpest-one | 47 | 1114 | 50 | 46 | 92.0 % | 45.9 % | 1 | 8 |
| sharpest-two | 70 | 1413 | 52 | 48 | 92.3 % | 47.7 % | 1 | 6 |
| all (no grouping) | 109 | 2024 | 63 | 55 | 87.3 % | 57.8 % | 2 | 0 |
At full resolution, recognising only the sharpest member of each group damages 8 correct labels to rescue 1. At live resolution the same arm rescues 3 and damages 8. Group propagation is therefore not a coverage win on this shelf: the members are individually recognisable, and the group's answer is only as good as its weakest member when the group is wrong.
Decision: use groups to schedule compute (C, B), not to overwrite individual
labels. Propagate a group identity only to members whose own evidence is
empty, and never over a member that produced a confident individual answer.
The rescued_correct column is the ceiling for that rule; it is small here and
should be re-measured on a shelf with more blur and occlusion.
E. Sibling boundaries
The combined signal's boundary accuracy is 91.6 % at full resolution, 86.7 % at
live (analysis.json). The failure mode is a boundary missed, not a boundary
invented: precision 98.5 % vs recall 91.7 %. A missed boundary merges two
sibling runs into one group.
Because §D says groups do not overwrite individual labels, a missed boundary is now a scheduling error (one representative OCR'd for two SKUs), not a labelling error. That is the safe direction to fail. Two rules keep it safe:
- A member whose own individual answer disagrees with the group's is a boundary candidate, not an outlier to be smoothed away.
- A group never spans a detected price-tag change or a gap wider than the shelf's own facing pitch.
F. Provisional and confirmed transitions
Shipped in live.py _on_classified, identity.py, identity_lanes.py:
accepted— the decision cleared its gate.state: recognized,identity_level: accepted,confirmed = True, never re-classified.provisional— the decision produced a candidate but did not clear the gate.state: recognized,identity_level: provisional,confirmed = False, and the track stays eligible for re-classification so a later, sharper attempt can upgrade it to accepted or correct it.unrecognized— no candidate at all, or the attempt budget is spent with no provisional label on screen.state: unrecognized, no chip.
A provisional label is never blanked once shown: when the budget runs out the track keeps its guess and simply stops being re-classified. An inherited provisional label stays re-classifiable; an inherited accepted label does not.
Measured on the wire (replay-image6-8fps-0.5x-0.json): 65 of 79 tracks
labelled, 742 accepted + 309 provisional observations, first label at 1.27 s.
identity_level is the only new wire field; an older client that ignores it
renders a provisional label as a normal one, which is the intended degradation.
Both overlays render provisional distinctly: amber chip and dashed box
(LiveScanOverlayPolicy.isProvisional(identity), ScanScreen.kt,
LiveScanOverlay.swift).
G. Operating recommendation
far-unknown-policies.json prices the accept floor on 4580 crops from 64
unrelated Wikimedia shelf photos:
| Policy | local_accept | Accepts | Rate |
|---|---|---|---|
| strict | 4.0 | 3 | 0.066 % |
| balanced | 2.0 | 8 | 0.175 % |
| coverage-first | 0.5 | 83 | 1.81 % |
| near-argmax | 0.0 + fallback | 119 | 2.60 % |
The shipped LOCAL_ACCEPT stays at 4.0 for the accepted level: it is the
line that keeps far-unknown false accepts at 0.066 %, and the accepted level is
what the product treats as settled. Coverage comes from the provisional
level, which is a separate, much looser rule and is not gated by this number.
This is the key structural choice: do not lower the accept line to raise coverage. Lowering it to 0.5 would raise far-unknown false accepts 27× and would still leave the shelf's own hard cases (siblings) unresolved. Showing the best candidate as provisional gets the coverage without moving the line that protects precision.
anytime-policies.json confirms the shape: the strict policy reaches 59.4 %
coverage and stays there at every time budget; coverage-first reaches 86 % and
is already at 28 % of facings by 250 ms.
H. Measured improvement
| Metric | Before | After |
|---|---|---|
| Catalog the server matches | 7 SKUs / 13 views | 60 SKUs / 86 views |
Tracks labelled, image6 8 fps 0.5× |
57 / 80 | 65 / 79 |
| In-catalog facings shown | 59.4 % | 86.0 % |
| Precision of shown labels | 97.4 % | 87.3 % |
| First label | 623 ms | 1.27 s |
| OCR calls per 218 tasks | 218 | 49 (group-representative schedule) |
| Wall time per 218 tasks | 4.32 s | 1.30 s |
The catalog change is code/build_stock_all.py, which merges stock,
stock-lopia, stock-mediya and stock-milk150 into stock-all (111 images,
60 SKUs; stock-demo is byte-identical to mediya+milk150 and is not a source).
SKU ids are unique across the folders, so the merge is a copy plus a CSV concat.
Limitations
- Every shelf number is one photo,
add/image6.jpg, and its labels are the corrected ones from the previous phase. The group signals and the representative arms are measured on that photo's own facings. - The far-unknown rates come from a cache that records no catalog, so the catalog match is read off the worktree, not attested by the cache.
scheduler-benchmark.jsonandanytime-policies.jsonare diagnostic counterfactuals: they price schedules against frozen evidence, not against a live deployment. Transport and grouping time are excluded.- The native overlays were changed but not compiled: no JDK is installed on
this machine, so
./gradlewcannot run. The shared signature change is mechanical and every caller was updated, but this is unverified by a compiler. - Group propagation (§D) is deliberately not shipped as a labelling rule. Its ceiling on this shelf is 1–3 rescued labels against 8 damaged; re-measure on a shelf with real blur and occlusion before revisiting.