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

@ -115,7 +115,7 @@ class LocalScaleEngine(RecognitionEngine):
def score_crop_lanes(self, crop, method=None):
if self.lane_answers:
return self.lane_answers.pop(0)
return {}, []
return {}, [], ""
class FakeInventory:
@ -331,7 +331,7 @@ class LocalScaleTests(unittest.TestCase):
dropping that path silently removed the lane's fill-in job.
"""
session, engine, _, _ = make_session(engine_cls=LocalScaleEngine)
engine.lane_answers = [({}, [("sku_a", 0.9)])]
engine.lane_answers = [({}, [("sku_a", 0.9)], "コクうま 65%")]
result = process(session, 1)
self.assertTrue(
wait_for(lambda: any(
@ -355,7 +355,7 @@ class LocalScaleTests(unittest.TestCase):
wrong reason.
"""
session, engine, _, _ = make_session(engine_cls=LocalScaleEngine)
engine.lane_answers = [({"sku_a": 5.0}, [("other", 0.3)])] * 4
engine.lane_answers = [({"sku_a": 5.0}, [("other", 0.3)], "other マヨネーズ")] * 4
for frame_id in range(1, 14):
result = process(session, frame_id)
for track in result["tracks"]:
@ -365,6 +365,22 @@ class LocalScaleTests(unittest.TestCase):
)
engine.shutdown()
def test_a_size_only_read_cannot_name_a_product(self):
"""A crop reading "400g" matches every 400g product's size line.
Size and number tokens are not identity text: without this gate the
text lane answered a distractor crop reading only "400g" as a product.
"""
session, engine, _, _ = make_session(engine_cls=LocalScaleEngine)
engine.lane_answers = [({}, [("sku_a", 0.9)], "400g")] * 4
for frame_id in range(1, 14):
for track in process(session, frame_id)["tracks"]:
self.assertIsNone(
track["identity"]["sku_id"],
"a size-only read must not name a product",
)
engine.shutdown()
class TagPassIntegrationTests(unittest.TestCase):
"""The tag pass must reach the live result without blocking frames."""