fix: a tag may not accuse on weak evidence, nor name a distant product

Three findings from the deep review, all about the tag channel claiming more than it
can support.

1. The exact signal is a barcode. The reconciliation compared `tag.jan` against the
   catalog's JAN, but that field held the regular price - a number compared against
   a product code. It now reads a `barcode` field (populated only by a close-up
   decode) and the printed number is compared as a price.

2. A price-mismatch violation required "an anchor", which was `tag.jan` - a price.
   The anchor is now the tag's own arithmetic agreeing, or a name that actually
   resembles the product, and the name signal carries its score as a field instead
   of being parsed back out of a human-readable note string.

3. Tag identity could reach anywhere along a row: a facing whose own tag was not
   detected inherited whichever readable tag was nearest, which can be a different
   product several columns away. Assignment is now capped at the product's own run
   (2.5 facing widths, at least 200 px), and a tag may only overrule the packaging
   channel when it is corroborated or its name matches at 0.6 rather than 0.4.

162 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-25 01:30:03 +09:00
parent 3a960b1471
commit 99bce4d7bf
3 changed files with 86 additions and 21 deletions

View file

@ -50,6 +50,8 @@ def tag(state=TAG_STATE_READABLE, **overrides):
"price": None,
"regular_price": None,
"name": None,
"barcode": None,
"flags": [],
"confidence": 0.8,
"observations": 3,
"settled": True,
@ -96,12 +98,14 @@ class ReconcileTests(unittest.TestCase):
result = reconcile(accepted(), tag(name="Tropicana エッセンシャルズ"), product(price=None, name="Tropicana エッセンシャルズ マルチビタミン"))
self.assertEqual(result["state"], RECONCILED_CONFIRMED)
def test_jan_disagreement_is_a_mismatch(self):
result = reconcile(accepted(), tag(price=168, jan="4902105000001"), product(price=168.0, jan="4902105000002"))
def test_barcode_disagreement_is_a_mismatch(self):
result = reconcile(accepted(), tag(price=168, barcode="4902105000001"),
product(price=168.0, jan="4902105000002"))
self.assertEqual(result["state"], RECONCILED_MISMATCH)
def test_jan_agreement_confirms(self):
result = reconcile(accepted(), tag(price=None, jan="4902105000001"), product(price=None, jan="4902105000001"))
def test_barcode_agreement_confirms(self):
result = reconcile(accepted(), tag(price=None, barcode="4902105000001"),
product(price=None, jan="4902105000001"))
self.assertEqual(result["state"], RECONCILED_CONFIRMED)
def test_tag_read_but_nothing_comparable(self):
@ -284,7 +288,8 @@ class ComplianceTests(unittest.TestCase):
# A reportable mismatch carries an anchor: the tag's JAN was read, so we
# know which tag we are comparing.
tracks = [track(1, [10, 100, 90, 300])]
tags = {1: tag(state=TAG_STATE_READABLE, price=269, jan="357")}
# The anchor is the tag's own arithmetic agreeing, not a price field.
tags = {1: tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])}
reconciliations = {1: reconcile(accepted(), tags[1], product(price=168.0))}
findings = evaluate_compliance(tracks, tags, [tags[1]], BANDS, reconciliations)
mismatches = [finding for finding in findings if finding.rule == RULE_PRICE_MISMATCH]
@ -308,8 +313,8 @@ class ComplianceTests(unittest.TestCase):
findings = [
evaluate_compliance(
[track(1, [10, 100, 90, 300])],
{1: tag(state=TAG_STATE_READABLE, price=269, jan="357")},
[tag(state=TAG_STATE_READABLE, price=269, jan="357")],
{1: tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])},
[tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])],
BANDS,
{1: reconcile(accepted(), tag(price=269), product(price=168.0))},
)