The text lane now requires shared bigrams between the read and the winning
row's name before it will answer, and the local lane passes the catalog
signatures through so the decision can weigh text evidence. On the labeled
shelf this moves the local lane from 66% to 80% end-to-end at full
resolution (91% precision) and 66% at live resolution (95% precision, 2
false accepts), against 57%/35% for the matcher alone.
Live fixes:
- `_plan_classification` counts `in_flight` again. The increment had been
dropped while removing a `label_frame_id` write, so the guard never
blocked, every frame re-planned the same tracks, and the executor queue
grew without bound until the server stopped answering.
- Label memory boxes carry the shelf origin they were measured against and
are shifted before the IoU test, so a panning camera 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 the lane's own build lock.
- A native crop always reads; the frame crop skips OCR only when it already
has a reading.
Stream (20 s pan, 4 runs): 71-79% coverage at 76-84% precision, against
62-71% at 73-75% before.
The per-track OCR cache kept any non-empty read, so a first crop that read
only a size ("400g") was replayed on every later attempt. A size matches every
400g product's size line, so the track could never be named even after a clean
crop arrived: the cached read replaced it. Only a read carrying identity text
is cached now, so the next attempt gets a fresh OCR.
`carries_identity` moves to module scope in live.py; a function-local import
shadowed the name at the cache site and raised UnboundLocalError.
Vision took a crop from 5 ms to 25 ms, so re-reading the same package on every
attempt was the measured cause of the live coverage collapse: 789 -> 60 crops/s
at 8 workers, and 12/8/2/6 labels on the same clip at four pacings.
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.
An empty read is not cached - a crop too small or too blurry to read says
nothing about the next one - and the cache is cleared with the rest of the
evidence on a method change or a drift relabel, so a reading never outlives the
product it described. The native crop lane skips OCR on the same rule.
Test: OCR runs once per track while the local lane keeps running. Disabling the
skip makes it fail 2 != 1. Suite 280 -> 281.
Two review findings, both confirmed by measurement.
1. The evaluator had no always-argmax lane, so every "vs Van" claim compared
against the *gated* matcher, which is not Van's contract - the original demo
answered every crop with its best-scoring SKU and had no accept line. The new
'van' lane is that contract: same scores, no gate. Against it the local lane
wins both axes at both resolutions (full 61%/77% vs 52%/29%; live 59%/95% vs
57%/32%), but the live coverage margin is one facing, not seven, and the 95%
figure is live-resolution only - at full resolution the same lane reads 77%
with 11 false accepts and a hub alarm.
2. 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. The raw OCR read travels with the ranking through
score_crop_lanes for that check.
Tests: 3 for the size gate (policy + live) and the van lane's own row. Removing
the gate fails all three. Suite 276 -> 280.
The shipped default scale had no test: make_session only builds matcher-backend
engines, so scale == "local" in _on_classified never ran in the suite - and
that is the branch that was wrong twice (the rank re-sort, and the early return
that made the text lane's fill-in job unreachable).
Adds LocalScaleEngine, which injects the lanes' evidence into the live path, and
two live tests: a text-only read still labels the track, and a text veto blocks a
local accept. Adds DecisionPolicyTests for the veto/fill-in boundary, including
the lane-order-vs-value case that the rank re-sort broke.
Both regressions were re-injected to confirm the tests fail for the intended
reason: restoring the early return fails the fill-in test, and re-sorting the
lane output by value fails the order test. Suite 251 -> 261.
Replaces the DINO gallery as the recognition path with the measured design
from docs/research/2026-09-28-recognition-architecture/DECISION.md: RootSIFT
over a per-SKU reference pyramid, similarity-constrained RANSAC and a hue
check, fused with an OCR text lane that vetoes a match whose package text
names a different catalog row and answers on its own when geometry finds
nothing.
- identity_lanes.py: LocalLane, TextLane, DecisionPolicy. The text lane
builds each row's signature from the catalog name plus its packshot OCR, so
a row with no enrolled image is still answerable.
- engine.py: every backend groups scores by canonical SKU, so no
view-suffixed id reaches the wire and a name and price always resolve.
decide_crop fuses the lanes; score_crop_lanes feeds the live path. Both
lanes are built in warm_up (non-fatally) so the first frame is not the slow
one, and the text lane shares the engine's OCR lock.
- live.py: decides per crop on the lane's own rank order. The lane's order
comes from its rank key and its value is only the gate, so re-sorting by
value named a different SKU than the lane chose; averaging in an empty
observation also diluted a good confidence below the accept line.
- identity.py: a `local` score scale whose gate is one absolute line, because
the value is already a margin over the runner-up.
- app.py, run-demo-server.sh: `--identity local` is the default; the demo
server no longer starts on the leaked `stock-lopia` catalog.
- eval_live.py: the replay vocabulary accepts `local`, so a recording from
the shipped server can be scored.
- README.md: documents the three backends and what each is for.
Measured on the corrected image6 labels at live resolution: 36/61 in-catalog
facings named at 95% precision, 0 of 48 unknowns accepted, 11 labels sourced
from the text lane alone. Through the real server: /v1/recognize 90%
precision, /v1/stream 100% precision, no view-suffixed ids on either.
The previous commit stopped the live path re-gating gallery scores at
COSINE_ACCEPT, but it passed zero accept lines. `aggregate_votes` divides by the
whole observation window, so one rejected crop beside one accepted 0.45 averages
to 0.225 - and a zero gate labelled that, at a score the gallery itself calls
unknown.
The aggregate now clears the gallery's own line: `head_threshold` (0.40) when a
fine-tuned head is loaded, `threshold` (0.55) otherwise. The margin gate is
still the only extra gate, so a head match between 0.40 and 0.60 is labelled.
Regression test: a rejected crop followed by an accepted 0.45 must not label the
track (fails on the zero-gate version).
Verified: `python -m unittest discover -s code -p 'test_*.py'` - 258 tests, OK,
2 skipped.
`score_crop` returns only scores the gallery already accepted: the raw 0.55
line, or the fine-tuned head's own 0.40. The live path then ranked that
aggregate with `decide_identity(scale="cosine")`, whose defaults rewrite to
COSINE_ACCEPT 0.60 / COSINE_AMBIGUOUS 0.50 - a second, higher line. Every head
match between 0.40 and 0.60 was therefore refused or downgraded to ambiguous,
which is the live regression: the head's own training views score 0.57-0.77.
`engine.inspect` and `_classify_with_gallery` already avoid this by ranking
through `decision_for_match`, which applies only the published margin. The live
path now does the same: on the cosine scale it passes explicit zero accept
lines, so the margin gate is the only extra gate.
Regression tests: a 0.45 head-scale match is labelled (fails on the previous
code), and a 0.45/0.44 pair inside the margin stays unnamed.
Verified: `python -m unittest discover -s code -p 'test_*.py'` - 257 tests, OK,
2 skipped.
The crop lane asked the phone for a full-resolution crop of a facing the
frame could not decide. Four defects made that unsafe or useless:
* A capability was half-applied. `crop: true` without a usable
`max_crop_bytes` enabled the lane on the client's say-so, leaving the two
sides believing different limits. A capability is now believed whole or
refused with BAD_MESSAGE, and the limit must be an integer in 1..1048576
(bool is an int in Python and is not a limit).
* A small JPEG could claim enormous dimensions. The header is now read
without decoding and anything over 4 megapixels is refused before the
decode allocates it.
* A request was single-use, so an expired one stranded a facing only the
crop lane could decide. The lane now spends the same max_attempts budget
the frame path spends, refilled on method change and on settled-label
drift, and a phone that never answers still cannot loop.
* A candidate could be asked about from a box the frame path would refuse:
clipped by the frame edge, carried over from an earlier frame, or measured
at a score the tracker does not trust. Both lanes now share one edge gate,
and only a track whose box this frame actually measured is asked about.
Drift and method change also cleared `identity` but left `confirmed = True`,
which permanently blocked both the crop lane and label inheritance for that
track; both now clear it, without which the budget reset was dead code.
Blur and glare are refused as whole-capture faults, while `too_far` is
exempt on purpose: a distant shelf is the one frame a native-resolution crop
exists to decide.
A tag is only readable when its own arithmetic corroborates the price, so the
fixture needs the 162.00 that belongs with its 150 (150 x 1.08 = 162.00). Without
it the fixture asserted the old behaviour.
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The engine owns the OCR backend, a shared OCR lock and a dedicated tag worker, so
a tag pass can never occupy a classification worker or slow a frame. A live
session plans rail bands on the frame loop (cheap), runs them on that worker, and
attaches whatever the cache already knows; a still runs one synchronous pass.
Tracks and detections now carry price_tag and reconciliation, and the summary
carries price_tags plus compliance. Config can turn tags off per session.
Compliance now fails closed on evidence quality. The first real run produced 106
"missing tag" violations on a shelf where only 8 tags were readable - our tag
*detection* was incomplete, not the shelf. A row is only judged when at least
half its facings carry tag evidence; otherwise it reports "tags detected for 14
of 91 facings in this row" as a hint. Missing-tag findings are one per row with a
count, not one per facing, and price_mismatch still reports every disagreement.
On add/image6.jpg the result is now 7 real price mismatches and no false
violations.
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
test_live: a product is unconfirmed, then labelled, without blocking frames; a
weak match never names a SKU and ends as needs_confirmation; a restarted id is
re-verified rather than inheriting its old label; inherited labels are flagged;
latest-frame-wins accounting; payload shape; glare hint; per-session
classification cap; a method change clears labels and drops late results.
test_api: health, catalog shape (no local paths), template-image 404s including
traversal-looking ids, stock validation, recognize over raw bytes and multipart,
415/422/400 with one error shape, classify/inspect roundtrip, IMAGE_EXPIRED,
token auth on REST and the socket, the WebSocket protocol (hello, frames,
config/reset/ping, recoverable BAD_FRAME and UNDECODABLE_IMAGE, inspect and
TRACK_NOT_IN_VIEW), the session limit, and the legacy session route.
Server fixes the tests forced out: FastAPI validation errors now use the same
{"error": {...}} envelope as every other failure, and the legacy session routes
accept a missing JSON body instead of returning 500.
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>