fix: matcher exclusion words + precision floor for clean grocery matching

- Add exclusion words: soda, rotisserie, tuna/tonno/salmon/sardine/anchovy
  to prevent beverages, prepared poultry, and seafood-in-oil from matching
  raw cooking ingredients
- Add min precision floor (0.45): grocery sig-word count must be ≤ 2× the
  ingredient's sig-word count, catching long branded products that pass
  the word-overlap recall check but are clearly wrong category matches
  (e.g. "Garlic Herb Rotisserie Chicken" precision=0.25 now rejected)

Result: all previously wrong matches now show '—' (no match) rather than
a wrong product; correct matches unchanged

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 12:00:19 -07:00
co-authored by Claude Sonnet 4.6
parent ac2b575f6b
commit d7a3f5c081
+38
View File
@@ -39,6 +39,34 @@ _STOP_WORDS = frozenset({
"and", "with", "for", "the", "and", "with", "for", "the",
}) })
# If any of these words appear in a grocery item's sig-words but NOT in the
# ingredient's sig-words, the match is rejected outright. Prevents category
# cross-contamination: "Garlic" must not match "Garlic Bread", "Lime" must not
# match "Lime Margarita", etc.
_EXCLUSION_WORDS = frozenset({
# Baked goods / bread products
"bread", "loaf", "rolls", "bun", "buns", "croissant",
"cracker", "crackers", "cookie", "cookies", "cake", "cupcake", "muffin", "bagel",
# Chips / snack foods
"chips",
# Pasta / noodles
"pasta", "noodle", "noodles", "vermicelli", "spaghetti", "linguine",
"fettuccine", "penne", "rigatoni", "macaroni", "rotini", "orzo",
# Alcoholic / mixed beverages
"margarita", "rita", "cocktail", "beer", "ale", "lager", "cider", "malt",
"wine", "spirits", "liquor",
"vodka", "tequila", "whiskey", "rum", "gin", "bourbon",
"lemonade", "limeade", "seltzer", "soda",
# Butter / spreads (prevents "Garlic & Herb Butter Spread" matching "Garlic")
"butter", "spread", "margarine",
# Prepared proteins / seafood-in-oil (prevents "Tuna in Olive Oil" matching "Olive Oil")
"tuna", "tonno", "salmon", "sardine", "anchovy",
# Prepared poultry (prevents "Garlic Herb Rotisserie Chicken" matching "Garlic")
"rotisserie",
# Baby / personal care (belt-and-suspenders after stop-word rework)
"baby", "wipes", "diaper",
})
@dataclass @dataclass
class MatchResult: class MatchResult:
@@ -127,8 +155,18 @@ def run_match_job(
# 100% recall: every ingredient sig-word must appear in the grocery name. # 100% recall: every ingredient sig-word must appear in the grocery name.
if not ing_sig.issubset(gsig): if not ing_sig.issubset(gsig):
continue continue
# Category exclusion: reject if grocery has a disqualifying word
# (e.g. "bread", "chips", "margarita") absent from the ingredient.
bad_words = (gsig & _EXCLUSION_WORDS) - ing_sig
if bad_words:
continue
# Precision penalises grocery items with many extra words. # Precision penalises grocery items with many extra words.
precision = len(ing_sig) / len(gsig) precision = len(ing_sig) / len(gsig)
# Hard floor: grocery must not have >2× the sig-words of the ingredient.
# Catches long branded products that sneak past exclusion words, e.g.
# "Garlic Herb Rotisserie Chicken" for "Garlic".
if precision < 0.45:
continue
combined = (score / 100.0) * precision combined = (score / 100.0) * precision
if combined > best_combined: if combined > best_combined:
best_combined = combined best_combined = combined