[object Object]

← back to Dw Color Image Audit

color-image-audit: refine no-dash mapper — demote pattern/place/material homonyms (grass/ocean/marine/leaf/apple/forest)

30306bff8fb17ef4ffb62176cd80339080e794ec · 2026-06-21 16:57:21 -0700 · Steve

Second mapper-completeness gap the dash-fix (307493e) didn't cover. On NO-DASH
titles the whole title is scanned and a botanical/place/motif homonym in the
lexicon (grass->green [grasscloth], ocean->teal, marine->blue ['Cote Marine'],
leaf->green, apple->chartreuse ['Big Apple'], forest->green ['Fir/Virgin Forest'])
was mis-read as the colorway, inflating raw-HIGH noise.

Fix (DTD verdict A, 3/3 Claude+Codex+Qwen): add AMBIGUOUS_COLORWORDS set +
demote_ambiguous flag in _familyFromPhrase / _family_from_phrase. Demotion is
gated to the NO-DASH / whole-title path ONLY — the clean after-dash colorway
segment is untouched, so 'X - Ocean' still resolves teal and Hawksbill-Forest /
Hawksbill-Ocean stay genuine HIGHs. When a real colorway co-occurs it survives
and wins; when the only signal is a homonym, fall through to UNKNOWN instead of
asserting a wrong family. Mirrored identically in JS + both Python mappers.

Read-only rerun on the existing flagged set (cached hexes, no image re-download,
$0, no network): vs the post-dash-fix baseline HIGH 629->602 (27 false-positive
HIGHs cleared), 26 homonym-only no-dash titles routed HIGH->UNKNOWN, ZERO new
HIGH regressions (new_high held at 21, all dash-fix unmaskings). JS<->Python
parity verified across all 1831 titles (0 real mismatches). Self-test 13/13 PASS.
No DB/Shopify writes; catalog disposition unchanged (~925 set stays HOLD).

Files touched

Diff

commit 30306bff8fb17ef4ffb62176cd80339080e794ec
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 21 16:57:21 2026 -0700

    color-image-audit: refine no-dash mapper — demote pattern/place/material homonyms (grass/ocean/marine/leaf/apple/forest)
    
    Second mapper-completeness gap the dash-fix (307493e) didn't cover. On NO-DASH
    titles the whole title is scanned and a botanical/place/motif homonym in the
    lexicon (grass->green [grasscloth], ocean->teal, marine->blue ['Cote Marine'],
    leaf->green, apple->chartreuse ['Big Apple'], forest->green ['Fir/Virgin Forest'])
    was mis-read as the colorway, inflating raw-HIGH noise.
    
    Fix (DTD verdict A, 3/3 Claude+Codex+Qwen): add AMBIGUOUS_COLORWORDS set +
    demote_ambiguous flag in _familyFromPhrase / _family_from_phrase. Demotion is
    gated to the NO-DASH / whole-title path ONLY — the clean after-dash colorway
    segment is untouched, so 'X - Ocean' still resolves teal and Hawksbill-Forest /
    Hawksbill-Ocean stay genuine HIGHs. When a real colorway co-occurs it survives
    and wins; when the only signal is a homonym, fall through to UNKNOWN instead of
    asserting a wrong family. Mirrored identically in JS + both Python mappers.
    
    Read-only rerun on the existing flagged set (cached hexes, no image re-download,
    $0, no network): vs the post-dash-fix baseline HIGH 629->602 (27 false-positive
    HIGHs cleared), 26 homonym-only no-dash titles routed HIGH->UNKNOWN, ZERO new
    HIGH regressions (new_high held at 21, all dash-fix unmaskings). JS<->Python
    parity verified across all 1831 titles (0 real mismatches). Self-test 13/13 PASS.
    No DB/Shopify writes; catalog disposition unchanged (~925 set stays HOLD).
---
 scripts/color-classify.js | 65 ++++++++++++++++++++++++++++++++++++++++++++---
 scripts/reextract.py      | 21 ++++++++++++---
 scripts/reextract2.py     | 21 ++++++++++++---
 3 files changed, 95 insertions(+), 12 deletions(-)

diff --git a/scripts/color-classify.js b/scripts/color-classify.js
index e415bce..55ff803 100644
--- a/scripts/color-classify.js
+++ b/scripts/color-classify.js
@@ -52,6 +52,23 @@ const MATERIAL_STOPWORDS = new Set([
   'commercial','contract','fabric','paper','cloth','faux','textile','weave',
 ]);
 
+// Botanical / place / motif homonyms that ARE in the color lexicon but, in a DW title,
+// almost always name the PATTERN / theme / material — not the declared colorway:
+//   grass  (grasscloth substrate)        ocean  (coastal theme)
+//   marine ("Cote Marine" place name)    leaf   (foliage motif)
+//   apple  ("Big Apple" motif)           forest ("Fir Forest"/"Virgin Forest" motif)
+// They only mislead on the NO-DASH / whole-title path: with no " - " separator the whole
+// title is scanned and one of these can become the (wrong) family when it's the sole color
+// signal. On the clean after-dash colorway segment ("Pattern - Ocean") the word genuinely IS
+// the colorway, so demotion is gated to the no-dash path only (see nameToFamily). When a real
+// colorway co-occurs it's the trailing token and already wins, so demotion only bites when the
+// homonym stands alone — in which case we fall through to UNKNOWN rather than assert a wrong
+// family (DTD verdict A, 3/3 Claude+Codex+Qwen, 2026-06-21). Deliberately conservative: common
+// real colorways (sage/olive/coral/pine/fern/forest-green via a surviving "green") are NOT here.
+const AMBIGUOUS_COLORWORDS = new Set([
+  'grass','ocean','marine','leaf','apple','forest',
+]);
+
 // Multi-word colorways whose family is NOT the family of any single token.
 // "Sea Green" reads as the trailing token "green" -> green, but sea-green is perceptually
 // teal/cyan (consistent with the lexicon's seafoam/seaglass -> teal). Checked as bigrams
@@ -70,7 +87,9 @@ function colorwaySegment(name){
 }
 
 // Resolve a family from a single phrase (multi-word colorway first, else material-aware last token).
-function _familyFromPhrase(phrase){
+// demoteAmbiguous: when true (no-dash / whole-title path), a botanical/place/motif homonym does
+// NOT establish the family on its own — prefer a genuine colorway, else fall through to UNKNOWN.
+function _familyFromPhrase(phrase, demoteAmbiguous){
   const ts = tokens(phrase);
   // multi-word colorway (scan bigrams; last match wins, consistent with the last-token rule)
   let mwFam = null, mwTok = null;
@@ -82,7 +101,12 @@ function _familyFromPhrase(phrase){
   // single-token, material-stopword-aware
   const mappable = ts.filter(t => NAME_FAMILY[t]);
   const nonMaterial = mappable.filter(t => !MATERIAL_STOPWORDS.has(t));
-  const pool = nonMaterial.length ? nonMaterial : mappable; // keep a material word only if it's the sole color
+  let pool = nonMaterial.length ? nonMaterial : mappable; // keep a material word only if it's the sole color
+  if (demoteAmbiguous){
+    const nonAmbiguous = pool.filter(t => !AMBIGUOUS_COLORWORDS.has(t));
+    if (nonAmbiguous.length) pool = nonAmbiguous; // a real colorway survives -> use it
+    else return null;                            // only pattern/place/material homonyms -> UNKNOWN
+  }
   let fam = null, matched = null;
   for (const t of pool){ fam = NAME_FAMILY[t]; matched = t; } // LAST in pool
   return fam ? { family: fam, token: matched } : null;
@@ -94,9 +118,12 @@ function _familyFromPhrase(phrase){
 //      part is a non-color descriptor), FALL BACK to the whole segment so real signal isn't lost.
 function nameToFamily(name){
   const seg = colorwaySegment(name);
-  const r = _familyFromPhrase(seg);
+  const hasDash = seg !== (name||'').trim();
+  // After-dash colorway is the declared colorway verbatim -> no demotion ("X - Ocean" IS teal).
+  // No-dash whole title mixes pattern+colorway -> demote ambiguous homonyms.
+  const r = _familyFromPhrase(seg, !hasDash);
   if (r) return r;
-  if (seg !== (name||'').trim()) return _familyFromPhrase(name); // fallback to full segment
+  if (hasDash) return _familyFromPhrase(name, true); // fallback over whole title -> demote ambiguous
   return null;
 }
 
@@ -226,4 +253,34 @@ if (require.main === module){
     ['Sky Blue','#c2402a'], ['Ivory','#f4f0e6'], ['Emerald','#0e6b3a'], ['Emerald','#8a8d90']
   ];
   for(const [n,h] of tests) console.log(n, h, '->', JSON.stringify(classify(n,h)));
+
+  // ---- assertion harness: no-dash ambiguous-homonym demotion (Option A) ----
+  // Each case asserts the DECLARED family resolved from the title (image hex irrelevant here).
+  const famOf = (n) => { const r = nameToFamily(n); return r && r.family; };
+  const cases = [
+    // no-dash, ambiguous homonym is the SOLE color signal -> UNKNOWN (null family)
+    ['Faux Leaf Squares', null],
+    ['Westport Conn Forest Wallcovering', null],
+    ['Alana Abaca Tight Woven Grass', null],
+    ['Cote Marine Durable Vinyl', null],
+    ['Big Apple Squares', null],
+    ['Voyage Ocean Coastal', null],
+    // no-dash, a GENUINE colorway co-occurs -> real color survives (homonym demoted)
+    ['Big Blue Apple', 'blue'],
+    ['Miramar Navy Wallcovering', 'blue'],
+    ['Sage Plain', 'green'],            // sage is NOT ambiguous -> preserved
+    ['Seahorse Mangrove Spring Green Wallcovering', 'green'],
+    // after-dash colorway: homonym genuinely IS the colorway -> NOT demoted
+    ['Pinpoint - Ocean Vegan Leather', 'teal'],
+    ['Lazur Wallcovering - Sea Green Wallcovering', 'teal'],
+    ['Baroque - Orange Vegan Leather', 'orange'],
+  ];
+  let fails = 0;
+  for (const [n, want] of cases){
+    const got = famOf(n) || null;
+    const ok = got === want;
+    if (!ok){ fails++; console.error(`SELFTEST FAIL: "${n}" -> ${got} (want ${want})`); }
+  }
+  console.log(fails ? `SELF-TEST: ${fails} FAILED` : `SELF-TEST: ${cases.length}/${cases.length} PASS`);
+  if (fails) process.exit(1);
 }
diff --git a/scripts/reextract.py b/scripts/reextract.py
index 6922756..ba73d91 100644
--- a/scripts/reextract.py
+++ b/scripts/reextract.py
@@ -36,12 +36,18 @@ MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering",
 # Multi-word colorways whose family != any single token's (sea-green is teal, per the lexicon's
 # seafoam/seaglass->teal). Checked before single tokens.
 MULTIWORD_FAMILY = {"sea green":"teal","sea foam":"teal","sea glass":"teal","sea blue":"teal"}
+# Botanical/place/motif homonyms that ARE in the lexicon but, in a DW title, almost always name
+# the PATTERN/theme/material, not the colorway. They only mislead on the NO-DASH / whole-title
+# path; on the clean after-dash colorway segment the word genuinely IS the colorway, so demotion
+# is gated to the no-dash path (see name_family). Demotion only bites when the homonym is the SOLE
+# color signal -> fall through to UNKNOWN (DTD verdict A 3/3, 2026-06-21). Mirror of JS.
+AMBIGUOUS_COLORWORDS = {"grass","ocean","marine","leaf","apple","forest"}
 
 def _colorway_segment(name):
     s=(name or "").strip(); i=s.rfind(" - ")
     return s[i+3:] if i>=0 else s
 
-def _family_from_phrase(phrase):
+def _family_from_phrase(phrase, demote_ambiguous=False):
     toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
     mw=None
     for i in range(len(toks)-1):
@@ -51,15 +57,22 @@ def _family_from_phrase(phrase):
     mappable=[t for t in toks if t in NAME_FAMILY]
     non_mat=[t for t in mappable if t not in MATERIAL_STOPWORDS]
     pool=non_mat if non_mat else mappable
+    if demote_ambiguous:
+        non_amb=[t for t in pool if t not in AMBIGUOUS_COLORWORDS]
+        if non_amb: pool=non_amb        # a real colorway survives -> use it
+        else: return None               # only pattern/place/material homonyms -> UNKNOWN
     fam_=None
     for t in pool: fam_=NAME_FAMILY[t]
     return fam_
 
 def name_family(name):
     seg=_colorway_segment(name)
-    fam_=_family_from_phrase(seg)
-    if fam_ is None and seg != (name or "").strip():
-        fam_=_family_from_phrase(name)   # fallback: colorway in the pattern half
+    has_dash = seg != (name or "").strip()
+    # After-dash colorway is the declared colorway verbatim -> no demotion ("X - Ocean" IS teal).
+    # No-dash whole title mixes pattern+colorway -> demote ambiguous homonyms.
+    fam_=_family_from_phrase(seg, demote_ambiguous=not has_dash)
+    if fam_ is None and has_dash:
+        fam_=_family_from_phrase(name, demote_ambiguous=True)   # fallback over whole title
     return fam_
 
 def is_chrom(f): return f in CHROM
diff --git a/scripts/reextract2.py b/scripts/reextract2.py
index add6c38..f99306a 100644
--- a/scripts/reextract2.py
+++ b/scripts/reextract2.py
@@ -45,12 +45,18 @@ MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering",
 # Multi-word colorways whose family != any single token's (sea-green is teal, per the lexicon's
 # seafoam/seaglass->teal). Checked before single tokens.
 MULTIWORD_FAMILY = {"sea green":"teal","sea foam":"teal","sea glass":"teal","sea blue":"teal"}
+# Botanical/place/motif homonyms that ARE in the lexicon but, in a DW title, almost always name
+# the PATTERN/theme/material, not the colorway. They only mislead on the NO-DASH / whole-title
+# path; on the clean after-dash colorway segment the word genuinely IS the colorway, so demotion
+# is gated to the no-dash path (see name_family). Demotion only bites when the homonym is the SOLE
+# color signal -> fall through to UNKNOWN (DTD verdict A 3/3, 2026-06-21). Mirror of JS.
+AMBIGUOUS_COLORWORDS = {"grass","ocean","marine","leaf","apple","forest"}
 
 def _colorway_segment(name):
     s=(name or "").strip(); i=s.rfind(" - ")
     return s[i+3:] if i>=0 else s
 
-def _family_from_phrase(phrase):
+def _family_from_phrase(phrase, demote_ambiguous=False):
     toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
     mw=None
     for i in range(len(toks)-1):
@@ -60,15 +66,22 @@ def _family_from_phrase(phrase):
     mappable=[t for t in toks if t in NAME_FAMILY]
     non_mat=[t for t in mappable if t not in MATERIAL_STOPWORDS]
     pool=non_mat if non_mat else mappable
+    if demote_ambiguous:
+        non_amb=[t for t in pool if t not in AMBIGUOUS_COLORWORDS]
+        if non_amb: pool=non_amb        # a real colorway survives -> use it
+        else: return None               # only pattern/place/material homonyms -> UNKNOWN
     fam_=None
     for t in pool: fam_=NAME_FAMILY[t]
     return fam_
 
 def name_family(name):
     seg=_colorway_segment(name)
-    fam_=_family_from_phrase(seg)
-    if fam_ is None and seg != (name or "").strip():
-        fam_=_family_from_phrase(name)   # fallback: colorway in the pattern half
+    has_dash = seg != (name or "").strip()
+    # After-dash colorway is the declared colorway verbatim -> no demotion ("X - Ocean" IS teal).
+    # No-dash whole title mixes pattern+colorway -> demote ambiguous homonyms.
+    fam_=_family_from_phrase(seg, demote_ambiguous=not has_dash)
+    if fam_ is None and has_dash:
+        fam_=_family_from_phrase(name, demote_ambiguous=True)   # fallback over whole title
     return fam_
 
 def is_chrom(f): return f in CHROM

← 307493e color-image-audit: fix family-mapper trailing-material hijac  ·  back to Dw Color Image Audit  ·  PR raw-930 close-out: Gemini-vision spot-check on 40 stronge 7b31afd →