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

@ -34,6 +34,15 @@ RECONCILED_TAG_ONLY = "tag_only"
RULE_MISSING_TAG = "missing_price_tag"
RULE_MISSING_TAG_GROUP = "missing_tag_group"
# How far along a row a tag may name facings: enough to cover the product's own run
# of facings (measured: up to four facings, 70-120 px each), not enough to reach
# across the shelf. The cap exists because a product whose own tag was not detected
# would otherwise inherit whichever readable tag happens to be nearest.
TAG_REACH_FACINGS = 2.5
TAG_REACH_PX = 200
# The name similarity a tag needs before it may overrule the packaging channel.
TAG_OVERRIDE_SIMILARITY = 0.6
RULE_ORPHAN_TAG = "orphan_tag"
RULE_PRICE_MISMATCH = "price_mismatch"
RULE_DUPLICATE_TAG = "duplicate_tag"
@ -81,16 +90,19 @@ def longest_common_substring_ratio(a: str, b: str) -> float:
@dataclass
class Signal:
kind: str # "jan" | "name" | "price"
kind: str # "barcode" | "name" | "price"
agrees: bool
item: Any
tag: Any
note: Optional[str] = None
score: Optional[float] = None
def as_dict(self) -> Dict[str, Any]:
payload = {"kind": self.kind, "agrees": self.agrees, "item": self.item, "tag": self.tag}
if self.note:
payload["note"] = self.note
if self.score is not None:
payload["score"] = round(float(self.score), 3)
return payload
@ -125,11 +137,14 @@ def reconcile(
signals: List[Signal] = []
# JAN is the only exact signal; a checksummed barcode is not a guess.
item_jan = str((product or {}).get("jan") or "").strip()
tag_jan = str((tag or {}).get("jan") or "").strip()
if item_jan and tag_jan:
signals.append(Signal("jan", item_jan == tag_jan, item_jan, tag_jan))
# A barcode is the only exact signal; a checksummed code is not a guess. The
# number a tag prints large beside the sale price is NOT a barcode - it is the
# regular price (verified against the manufacturer: 4901577092381 for a tag
# printing 357), so it is compared as a price below.
item_barcode = str((product or {}).get("jan") or "").strip()
tag_barcode = str((tag or {}).get("barcode") or "").strip()
if item_barcode and tag_barcode:
signals.append(Signal("barcode", item_barcode == tag_barcode, item_barcode, tag_barcode))
item_name = normalize_name((product or {}).get("product_name"))
tag_name = normalize_name((tag or {}).get("name"))
@ -137,7 +152,7 @@ def reconcile(
ratio = longest_common_substring_ratio(item_name, tag_name)
signals.append(
Signal("name", ratio >= NAME_MATCH_THRESHOLD, item_name[:32], tag_name[:32],
note=f"match {ratio:.2f}")
note=f"match {ratio:.2f}", score=ratio)
)
item_price = (product or {}).get("price")
@ -155,8 +170,8 @@ def reconcile(
"message": "tag read, but nothing comparable in the catalog",
}
if any(signal.kind == "jan" for signal in signals):
decided = [signal for signal in signals if signal.kind == "jan"][0]
if any(signal.kind == "barcode" for signal in signals):
decided = [signal for signal in signals if signal.kind == "barcode"][0]
state = RECONCILED_CONFIRMED if decided.agrees else RECONCILED_MISMATCH
elif any(signal.agrees for signal in signals):
state = RECONCILED_CONFIRMED
@ -479,9 +494,12 @@ def evaluate_compliance(
name_signal = next(
(signal for signal in signals if signal.get("kind") == "name"), None
)
has_anchor = bool(tag_for_track.get("jan")) or bool(
name_signal and float(name_signal.get("note", "0").split()[-1] or 0) >= 0.35
)
# The anchor is the tag's own arithmetic agreeing, or a name that actually
# resembles the product. (It used to be `tag.jan`, which held a *price*.)
flags = set(tag_for_track.get("flags") or [])
corroborated = bool({"tax_consistent", "discount_consistent"} & flags)
name_score = float(name_signal.get("score") or 0.0) if name_signal else 0.0
has_anchor = corroborated or name_score >= 0.35
if not has_anchor:
findings.append(
Finding(
@ -663,8 +681,20 @@ def identities_from_tags(
for track in row:
box = track.get("box") or track.get("raw_box")
center = (box[0] + box[2]) / 2.0
facing_width = max(1, box[2] - box[0])
# A tag names the product at its own position on the rail. Without a
# distance cap a facing whose own tag was not detected inherits the
# nearest readable tag's product, which can belong to a different
# product several columns away.
limit = max(TAG_REACH_FACINGS * facing_width, TAG_REACH_PX)
candidates = [
tag for tag in row_tags
if abs(center - (tag["box"][0] + tag["box"][2]) / 2.0) <= limit
]
if not candidates:
continue
nearest = min(
row_tags,
candidates,
key=lambda tag: abs(center - (tag["box"][0] + tag["box"][2]) / 2.0),
)
sku_id = nearest.get("sku_from_name")
@ -737,6 +767,20 @@ def attach_tags_to_boxes(
current = track["identity"] or {}
if current.get("state") == "recognized":
if current.get("sku_id") != derived["sku_id"]:
# Only a corroborated tag, or a name that closely resembles the
# product, may overrule the packaging channel. A weak 0.40 name
# match used to be enough, which is not evidence.
tag_flags = set((tag_by_track.get(int(track["track_id"])) or {}).get("flags") or [])
strong = bool({"tax_consistent", "discount_consistent"} & tag_flags) or \
float(derived.get("score") or 0.0) >= TAG_OVERRIDE_SIMILARITY
if not strong:
track["identity_conflict"] = {
"packaging": current.get("sku_id"),
"tag": derived["sku_id"],
"packaging_score": current.get("score"),
"note": "tag name match too weak to overrule packaging",
}
continue
# Two channels disagree. Measured on the demo shelf: where both
# named the same facing they agreed only 69% of the time, and the
# packaging side of every disagreement carried a low score - so

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))},
)

View file

@ -41,7 +41,8 @@ def facing(track_id, box, sku=None):
def tag(box, name=None, state=TAG_STATE_READABLE):
return {"state": state, "box": box, "name": name, "jan": None, "price": None}
return {"state": state, "box": box, "name": name, "regular_price": None, "price": None,
"barcode": None, "flags": []}
class NameMatchTests(unittest.TestCase):
@ -71,6 +72,21 @@ class AssignmentTests(unittest.TestCase):
self.assertEqual(len(found), 4, "one tag covers the whole run of facings")
self.assertTrue(all(info["sku_id"] == "sku_l05" for info in found.values()))
def test_a_distant_facing_does_not_inherit_a_tag(self):
"""A product whose own tag was not detected must not take its neighbour's.
Without a reach limit the nearest readable tag names it, which is how a
facing several columns away can acquire the wrong product.
"""
tracks = [
facing(0, [10, 100, 100, 300]),
facing(1, [1600, 100, 1690, 300]), # far along the same row
]
tags = [tag([20, 320, 180, 380], name="キューピー ゼロ ノンコレステロール 310g")]
found = identities_from_tags(tracks, {}, CATALOG, all_tags=tags)
self.assertIn(0, found)
self.assertNotIn(1, found, "out of reach of that tag")
def test_the_nearest_tag_wins_along_a_row(self):
# Two products side by side, one tag each.
tracks = [