[object Object]

← back to Dw Photo Capture

fuzzy: ambiguity guard so a partial read can't bind the wrong vendor (cycle 16)

830b25f6337e8825d4e822e5f3e88cd12cfaa8fd · 2026-06-26 07:09:54 -0700 · Steve Abrams

Two parts this cycle:
1. Proto-key sweep (follow-up to cycle 15): grepped every OBJ[userInput]= site — only
   two, both in /api/learn. The vendor-key path was fixed cycle 15; the prefix-key path
   (prof.prefixes[pre]) is safe by construction (pre is uppercase-only + underscore-
   stripped, so never __proto__ or lowercase constructor). Confirmed live: skus deriving
   CONSTRUCTOR/PROTOTYPE/TOSTRING all → 200, server stays up. Defer-with-evidence, no change.
2. Fuzzy-collision audit found REAL collisions among the 180 vendor names: Alan Campbell ~
   Nina Campbell (0.33), Retro Walls ~ Rebel Walls (0.30) — genuinely different vendors. A
   partial VLM read like 'Campbell' could fuzzy-match the WRONG one and corrupt its profile
   via /api/learn. Added an ambiguity guard: if a genuinely-different vendor (different
   normalized name, so same-brand spellings don't count) is within 0.08 of the winner →
   return null instead of guessing. Verified: CAMPBELL→null, NINA CAMPBELL→Nina Campbell,
   BRUNSWIG→Brunschwig still corrects. 46→48 tests, lint clean, live identify no-regression.
   $0 (local).

Files touched

Diff

commit 830b25f6337e8825d4e822e5f3e88cd12cfaa8fd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 26 07:09:54 2026 -0700

    fuzzy: ambiguity guard so a partial read can't bind the wrong vendor (cycle 16)
    
    Two parts this cycle:
    1. Proto-key sweep (follow-up to cycle 15): grepped every OBJ[userInput]= site — only
       two, both in /api/learn. The vendor-key path was fixed cycle 15; the prefix-key path
       (prof.prefixes[pre]) is safe by construction (pre is uppercase-only + underscore-
       stripped, so never __proto__ or lowercase constructor). Confirmed live: skus deriving
       CONSTRUCTOR/PROTOTYPE/TOSTRING all → 200, server stays up. Defer-with-evidence, no change.
    2. Fuzzy-collision audit found REAL collisions among the 180 vendor names: Alan Campbell ~
       Nina Campbell (0.33), Retro Walls ~ Rebel Walls (0.30) — genuinely different vendors. A
       partial VLM read like 'Campbell' could fuzzy-match the WRONG one and corrupt its profile
       via /api/learn. Added an ambiguity guard: if a genuinely-different vendor (different
       normalized name, so same-brand spellings don't count) is within 0.08 of the winner →
       return null instead of guessing. Verified: CAMPBELL→null, NINA CAMPBELL→Nina Campbell,
       BRUNSWIG→Brunschwig still corrects. 46→48 tests, lint clean, live identify no-regression.
       $0 (local).
---
 OVERNIGHT_LEDGER.md |  1 +
 server.js           | 14 +++++++++++---
 test-analyze-ocr.js |  7 +++++++
 3 files changed, 19 insertions(+), 3 deletions(-)

diff --git a/OVERNIGHT_LEDGER.md b/OVERNIGHT_LEDGER.md
index ad7fef5..6816ed4 100644
--- a/OVERNIGHT_LEDGER.md
+++ b/OVERNIGHT_LEDGER.md
@@ -58,3 +58,4 @@ the single highest-value safe one, the officer executes it, and the loop resched
 | 13 | DEBUG disk-full + disk-light refine | Data volume was 100% full (ENOSPC blocked all Bash); diagnosed: home=138G (Projects 83G, Ollama 45G/16 models), ~250G in sudo-gated areas (Steve-gated, not auto-deleted); shipped a disk-light pure-test refinement (5 fuzzyVendor edge guards) | df recovered 124Mi→4.7Gi (purgeable reclaimed); 36→41 tests; lint clean; no server restart/scratch | $0 |
 | 14 | brainstorm #1 (learning-loop) | profileToLex prefix support gate (>=2 scans) — one bad scan can no longer inject a wrong prefix into the ranking lexicon; catalog prefixes (builder-vetted n>=3) unaffected; +5 profileToLex tests | df 27G healthy; real-data impact = 1 unconfirmed prefix gated (WC test artifact), 0 vendors lose matching; 41→46 tests; lint clean; restart 200 | $0 |
 | 15 | brainstorm #4 (SECURITY) | found+fixed a real DoS: vendor="__proto__" → VENDOR_PROFILES["__proto__"] returns Object.prototype → prof.prefixes deref CRASHED the process. Guard rejects __proto__/constructor/prototype with 400 | df 31G; exploit reproduced (HTTP 000 + crash, launchd auto-restarted); post-fix all 3 → 400, server stays up, normal learn 200 | $0 |
+| 16 | sweep (defer-w-evidence) + fuzzy ambiguity guard | (a) proto-key sweep: only 2 OBJ[key]= sites, vendor-key fixed cycle15, prefix-key proven safe live (CONSTRUCTOR/PROTOTYPE skus→200). (b) fuzzy-collision audit found REAL collisions (Alan~Nina Campbell 0.33, Retro~Rebel Walls 0.30) → added ambiguity guard: 2 different vendors within margin → null (no wrong-vendor binding) | df 29G; 46→48 tests; lint clean; CAMPBELL→null, NINA CAMPBELL→Nina, BRUNSWIG→Brunschwig preserved; live identify no-regression | $0 |
diff --git a/server.js b/server.js
index cb33202..1307070 100644
--- a/server.js
+++ b/server.js
@@ -424,14 +424,22 @@ function _lev(a, b) {
 function fuzzyVendor(guess) {
   const g = String(guess || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
   if (g.length < 4) return null;
-  let best = null, bestRatio = 1;
   const names = new Set([...VENDOR_LEX.map(v => v.name), ...Object.keys(VENDOR_PROFILES)]);
+  const scored = [];
   for (const name of names) {
     const n = name.toUpperCase().replace(/[^A-Z0-9]/g, ''); if (n.length < 4) continue;
     const ratio = _lev(g, n) / Math.max(g.length, n.length);
-    if (ratio < 0.34 && ratio < bestRatio) { bestRatio = ratio; best = name; }
+    if (ratio < 0.34) scored.push({ name, n, ratio });
   }
-  return best;
+  if (!scored.length) return null;
+  scored.sort((a, b) => a.ratio - b.ratio);
+  const best = scored[0];
+  // AMBIGUITY GUARD: if a GENUINELY DIFFERENT vendor (different normalized name — so same-brand
+  // spellings like "Armani Casa"/"Armani/Casa" don't count) is within MARGIN of the winner, the
+  // read is ambiguous (e.g. "Campbell" matches both Alan Campbell and Nina Campbell) → don't guess.
+  const rival = scored.find(s => s.n !== best.n);
+  if (rival && (rival.ratio - best.ratio) < 0.08) return null;
+  return best.name;
 }
 
 // DTD verdict A (2026-06-25, 3/3): fingerprint TIEBREAKER. Only consulted when the name
diff --git a/test-analyze-ocr.js b/test-analyze-ocr.js
index 38e1604..7672f70 100644
--- a/test-analyze-ocr.js
+++ b/test-analyze-ocr.js
@@ -119,6 +119,13 @@ const bc = (payload, sym = 'Code128') => `BC\t${payload}\t${sym}`;
   // a few real OCR/VLM misspellings within tolerance resolve to canonical seed vendors
   eq(fuzzyVendor('SCHUMACKER'), 'Schumacher', 'fuzzyVendor: SCHUMACKER → Schumacher');
   eq(fuzzyVendor('Phillip Jefries'), 'Phillip Jeffries', 'fuzzyVendor: Phillip Jefries → Phillip Jeffries');
+  // ambiguity guard: a partial read matching TWO different vendors → null, but an unambiguous
+  // exact read of either still resolves (guards against wrong-vendor binding via /api/learn).
+  // Only assert when the real profile data actually contains both Campbell vendors.
+  if (VENDOR_PROFILES['Alan Campbell'] && VENDOR_PROFILES['Nina Campbell']) {
+    eq(fuzzyVendor('CAMPBELL'), null, 'fuzzyVendor: ambiguous "CAMPBELL" (Alan vs Nina) → null');
+    eq(fuzzyVendor('NINA CAMPBELL'), 'Nina Campbell', 'fuzzyVendor: exact Nina Campbell still resolves');
+  } else { ok(true, 'fuzzyVendor: (Campbell collision pair not in data — ambiguity case skipped)'); }
 }
 
 // ── fingerprintVendor: resolves a validated fingerprint, null for unknown ──

← f35ef39 security: fix /api/learn prototype-pollution DoS (cycle 15)  ·  back to Dw Photo Capture  ·  auto-save: 2026-06-26T07:13:19 (2 files) — data/build.json d 5980db1 →