feat: add Van's real baseline lane and stop size tokens naming products

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.
This commit is contained in:
tngo 2026-09-28 23:12:44 +09:00
parent 13ee7426e2
commit fd72251160
7 changed files with 180 additions and 22 deletions

View file

@ -514,17 +514,18 @@ class RecognitionEngine:
return decide_identity({}, method="local", scale="local")
text_lane = self.identity_text_lane()
text_rank: Any = None
read = ""
if text_lane is not None:
read = text_lane.read(crop)
text_rank = sorted(
text_lane.scores_from_text(read).items(), key=lambda kv: kv[1], reverse=True
)
return DecisionPolicy().decide(lane.rank(crop), text_rank)
return DecisionPolicy().decide(lane.rank(crop), text_rank, text_read=read)
def score_crop_lanes(
self, crop: Any, method: Optional[str] = None
) -> Tuple[Dict[str, float], List[Tuple[str, float]]]:
"""Both lanes' evidence for one crop: local scores and text ranking.
) -> Tuple[Dict[str, float], List[Tuple[str, float]], str]:
"""Both lanes' evidence for one crop: local scores, text ranking, raw read.
The live path accumulates evidence across looks, so it needs the lanes
separately rather than one fused decision. How it combines them depends
@ -532,16 +533,18 @@ class RecognitionEngine:
margin over the runner-up, and averaging in an empty observation halves
it below the accept line), while the cosine and inlier scales average
the scores over the track's window. The text reading is always the
newest. The still path calls `decide_crop`, which fuses a single crop.
newest. The raw read travels with the ranking because the decision needs
it to tell a product name from a size line. The still path calls
`decide_crop`, which fuses a single crop.
"""
scores = self.score_crop(crop, method)
text_lane = self.identity_text_lane()
if text_lane is None:
return scores, []
return scores, [], ""
read = text_lane.read(crop)
return scores, sorted(
text_lane.scores_from_text(read).items(), key=lambda kv: kv[1], reverse=True
)
), read
def identity_lane(self) -> Any:
"""The local lane, built once. None when the backend is not `local`."""