[object Object]

← back to Dw Color Image Audit

color-image-audit: fix family-mapper trailing-material hijack (leather->brown) + multi-word colorways

307493e8936df0c590d5168f80e8929575d21c4d · 2026-06-21 14:50:13 -0700 · Steve

Root cause: nameToFamily/name_family kept the LAST mappable token in a DW title's
color segment. DW titles read 'Pattern - Colorway MaterialType' so the trailing
'Vegan Leather' (leather->brown in lexicon) overrode the true colorway. 27 Tier-1
HIGH rows mis-bucketed brown.

Fix (DTD verdict A, 3/3 Claude+Codex+Qwen): dash-aware colorway parse (segment
after last ' - '), material-stopword skip gated on a real color surviving,
multi-word colorway map (sea green->teal), fallback to whole segment when the
after-dash part has no color (preserves pattern-half colorways). Same fix mirrored
in JS (color-classify.js) and both Python mappers (reextract.py/reextract2.py).
Added reextract2 __main__ guard so the read-only rerun harness imports the real code.

Re-run on existing flagged set (cached hexes, no image re-download, $0): 88
declaredFamily corrected, 17 false-positive HIGHs cleared (incl. Baroque-Orange,
Pinpoint-Sea Green, Crushed Coral-Amber), 21 previously-MASKED real mismatches
unmasked (Sapphire/Azure/Lagoon/Peacock... on off-hue images). Real alarm preserved
(Hawksbill-Walnut brown vs orange still HIGH). No DB/Shopify writes.

Files touched

Diff

commit 307493e8936df0c590d5168f80e8929575d21c4d
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 21 14:50:13 2026 -0700

    color-image-audit: fix family-mapper trailing-material hijack (leather->brown) + multi-word colorways
    
    Root cause: nameToFamily/name_family kept the LAST mappable token in a DW title's
    color segment. DW titles read 'Pattern - Colorway MaterialType' so the trailing
    'Vegan Leather' (leather->brown in lexicon) overrode the true colorway. 27 Tier-1
    HIGH rows mis-bucketed brown.
    
    Fix (DTD verdict A, 3/3 Claude+Codex+Qwen): dash-aware colorway parse (segment
    after last ' - '), material-stopword skip gated on a real color surviving,
    multi-word colorway map (sea green->teal), fallback to whole segment when the
    after-dash part has no color (preserves pattern-half colorways). Same fix mirrored
    in JS (color-classify.js) and both Python mappers (reextract.py/reextract2.py).
    Added reextract2 __main__ guard so the read-only rerun harness imports the real code.
    
    Re-run on existing flagged set (cached hexes, no image re-download, $0): 88
    declaredFamily corrected, 17 false-positive HIGHs cleared (incl. Baroque-Orange,
    Pinpoint-Sea Green, Crushed Coral-Amber), 21 previously-MASKED real mismatches
    unmasked (Sapphire/Azure/Lagoon/Peacock... on off-hue images). Real alarm preserved
    (Hawksbill-Walnut brown vs orange still HIGH). No DB/Shopify writes.
---
 scripts/audit-shard.js    |  7 +++--
 scripts/color-classify.js | 63 ++++++++++++++++++++++++++++++++-----
 scripts/reextract.py      | 34 ++++++++++++++++++--
 scripts/reextract2.py     | 80 +++++++++++++++++++++++++++++++++--------------
 scripts/rerun_remap.py    | 78 +++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 225 insertions(+), 37 deletions(-)

diff --git a/scripts/audit-shard.js b/scripts/audit-shard.js
index f1eb663..bec725a 100644
--- a/scripts/audit-shard.js
+++ b/scripts/audit-shard.js
@@ -4,9 +4,10 @@
 const { classify } = require('./color-classify.js');
 const readline = require('readline');
 
-// Extract the declared color phrase from a DW title "Pattern Name Real Color Name | Brand".
-// The color is the segment before the pipe; we feed the whole left segment to the name mapper
-// which picks the last mappable color token.
+// Extract the declared color phrase from a DW title "Pattern Name - Colorway MaterialType | Brand".
+// We feed the whole left segment (before the pipe) to the name mapper; nameToFamily() then
+// isolates the colorway after the last " - ", resolves multi-word colorways, and ignores pure
+// material/finish words so a trailing "Vegan Leather" can't hijack the family.
 function declaredFrom(title){
   if(!title) return '';
   const left = title.split('|')[0].trim();
diff --git a/scripts/color-classify.js b/scripts/color-classify.js
index 590cb1e..e415bce 100644
--- a/scripts/color-classify.js
+++ b/scripts/color-classify.js
@@ -40,17 +40,66 @@ const NAME_FAMILY = {
 
 function tokens(s){ return (s||'').toLowerCase().replace(/[^a-z ]/g,' ').split(/\s+/).filter(Boolean); }
 
-// Pick a declared family from a color phrase: take the LAST color token that maps
-// (modifiers like "dusty blue", "sea green" -> the head color usually last).
-function nameToFamily(name){
-  const ts = tokens(name);
-  let fam = null, matched = null;
-  for (const t of ts){
-    if (NAME_FAMILY[t]){ fam = NAME_FAMILY[t]; matched = t; }
+// Pure material / finish / substrate words that name the SUBSTRATE, never the colorway.
+// DW titles read "PatternName - Colorway MaterialType" (e.g. "Baroque - Orange Vegan Leather").
+// "leather" maps to brown in the lexicon, so as a LAST token it used to hijack the family and
+// bury the real colorway. We strip these — but only when a real color token still remains, so a
+// product whose colorway genuinely IS a material-ish word (e.g. "- Linen") is preserved.
+// NOTE: linen / silk / raffia / jute / grasscloth are intentionally NOT here — they double as
+// legitimate beige colorways.
+const MATERIAL_STOPWORDS = new Set([
+  'vegan','leather','suede','vinyl','velvet','wallcovering','wallpaper',
+  'commercial','contract','fabric','paper','cloth','faux','textile','weave',
+]);
+
+// 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
+// BEFORE single tokens so the more specific phrase wins.
+const MULTIWORD_FAMILY = {
+  'sea green':'teal', 'sea foam':'teal', 'sea glass':'teal', 'sea blue':'teal',
+};
+
+// Isolate the colorway phrase from a DW title's left segment.
+// DW convention is "PatternName - Colorway MaterialType"; the colorway sits AFTER the last
+// " - " separator. When there is no separator, the whole segment is the candidate.
+function colorwaySegment(name){
+  const s = (name||'').trim();
+  const idx = s.lastIndexOf(' - ');
+  return idx >= 0 ? s.slice(idx + 3) : s;
+}
+
+// Resolve a family from a single phrase (multi-word colorway first, else material-aware last token).
+function _familyFromPhrase(phrase){
+  const ts = tokens(phrase);
+  // multi-word colorway (scan bigrams; last match wins, consistent with the last-token rule)
+  let mwFam = null, mwTok = null;
+  for (let i = 0; i + 1 < ts.length; i++){
+    const bg = ts[i] + ' ' + ts[i+1];
+    if (MULTIWORD_FAMILY[bg]){ mwFam = MULTIWORD_FAMILY[bg]; mwTok = bg; }
   }
+  if (mwFam) return { family: mwFam, token: mwTok };
+  // 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 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;
 }
 
+// Pick a declared family from a title's left segment.
+//   1. prefer the colorway phrase after the last " - " (DW "Pattern - Colorway Material" convention)
+//   2. if that phrase has no resolvable color (colorway lives in the pattern half, or the after-dash
+//      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);
+  if (r) return r;
+  if (seg !== (name||'').trim()) return _familyFromPhrase(name); // fallback to full segment
+  return null;
+}
+
 // ---- hex -> family ----
 function hexToRgb(hex){
   if(!hex) return null;
diff --git a/scripts/reextract.py b/scripts/reextract.py
index e5fc7fd..6922756 100644
--- a/scripts/reextract.py
+++ b/scripts/reextract.py
@@ -28,10 +28,38 @@ def fam(r,g,b):
 
 NAME_FAMILY = json.loads(open(sys.argv[1]).read()) if len(sys.argv)>1 else {}
 
-def name_family(name):
+# Substrate/finish words that name the material, never the colorway. As a trailing token
+# "leather"->brown used to hijack the family (DW titles read "Pattern - Colorway Material").
+# linen/silk/raffia/jute/grasscloth intentionally excluded — they double as beige colorways.
+MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering","wallpaper",
+                      "commercial","contract","fabric","paper","cloth","faux","textile","weave"}
+# 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"}
+
+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):
+    toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
+    mw=None
+    for i in range(len(toks)-1):
+        bg=toks[i]+" "+toks[i+1]
+        if bg in MULTIWORD_FAMILY: mw=MULTIWORD_FAMILY[bg]
+    if mw: return mw
+    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
     fam_=None
-    for t in ''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in name).split():
-        if t in NAME_FAMILY: fam_=NAME_FAMILY[t]
+    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
     return fam_
 
 def is_chrom(f): return f in CHROM
diff --git a/scripts/reextract2.py b/scripts/reextract2.py
index f4542b7..add6c38 100644
--- a/scripts/reextract2.py
+++ b/scripts/reextract2.py
@@ -37,10 +37,38 @@ def fam(r,g,b):
 
 NAME_FAMILY = json.loads(open(sys.argv[1]).read()) if len(sys.argv)>1 else {}
 
-def name_family(name):
+# Substrate/finish words that name the material, never the colorway. As a trailing token
+# "leather"->brown used to hijack the family (DW titles read "Pattern - Colorway Material").
+# linen/silk/raffia/jute/grasscloth intentionally excluded — they double as beige colorways.
+MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering","wallpaper",
+                      "commercial","contract","fabric","paper","cloth","faux","textile","weave"}
+# 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"}
+
+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):
+    toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
+    mw=None
+    for i in range(len(toks)-1):
+        bg=toks[i]+" "+toks[i+1]
+        if bg in MULTIWORD_FAMILY: mw=MULTIWORD_FAMILY[bg]
+    if mw: return mw
+    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
     fam_=None
-    for t in ''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in name).split():
-        if t in NAME_FAMILY: fam_=NAME_FAMILY[t]
+    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
     return fam_
 
 def is_chrom(f): return f in CHROM
@@ -106,24 +134,28 @@ def extract(url):
         dom_v2 = dom_v1                    # essentially all white/black -> keep v1
     return dom_v1, dom_v2
 
-for line in sys.stdin:
-    line = line.rstrip("\n")
-    if not line.strip(): continue
-    parts = line.split("\t")
-    if len(parts) < 7: continue
-    vendor,spid,status,mfrsku,title,cached,url = parts[:7]
-    declared = title.split("|")[0].strip()
-    df = name_family(declared)
-    rec = {"shopify_product_id":spid, "vendor":vendor, "status":status, "title":title,
-           "declared":declared, "cached_hex":cached, "image_url":url, "declaredFamily":df}
-    try:
-        v1, v2 = extract(url)
-        imf, s, l = fam(*v2)
-        rec["real_hex_v1"] = "#%02X%02X%02X" % v1
-        rec["real_hex"]    = "#%02X%02X%02X" % v2
-        rec["imageFamily"] = imf
-        verdict, reason = classify(df, imf, s, l)
-        rec["verdict"] = verdict; rec["reason"] = reason
-    except Exception as e:
-        rec["verdict"] = "ERROR"; rec["reason"] = f"{type(e).__name__}:{str(e)[:50]}"
-    sys.stdout.write(json.dumps(rec)+"\n")
+def _main():
+    for line in sys.stdin:
+        line = line.rstrip("\n")
+        if not line.strip(): continue
+        parts = line.split("\t")
+        if len(parts) < 7: continue
+        vendor,spid,status,mfrsku,title,cached,url = parts[:7]
+        declared = title.split("|")[0].strip()
+        df = name_family(declared)
+        rec = {"shopify_product_id":spid, "vendor":vendor, "status":status, "title":title,
+               "declared":declared, "cached_hex":cached, "image_url":url, "declaredFamily":df}
+        try:
+            v1, v2 = extract(url)
+            imf, s, l = fam(*v2)
+            rec["real_hex_v1"] = "#%02X%02X%02X" % v1
+            rec["real_hex"]    = "#%02X%02X%02X" % v2
+            rec["imageFamily"] = imf
+            verdict, reason = classify(df, imf, s, l)
+            rec["verdict"] = verdict; rec["reason"] = reason
+        except Exception as e:
+            rec["verdict"] = "ERROR"; rec["reason"] = f"{type(e).__name__}:{str(e)[:50]}"
+        sys.stdout.write(json.dumps(rec)+"\n")
+
+if __name__ == "__main__":
+    _main()
diff --git a/scripts/rerun_remap.py b/scripts/rerun_remap.py
new file mode 100644
index 0000000..412568e
--- /dev/null
+++ b/scripts/rerun_remap.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""READ-ONLY re-classification of the existing flagged set with the FIXED family-mapper.
+
+Re-runs the audit verdict for every row in out/reextracted.jsonl WITHOUT re-downloading any
+image: it re-derives the declared family from the title with the fixed name_family(), recomputes
+the image family from the already-cached real_hex via fam(), and re-runs classify(). This proves
+the false-positive collapse from the leather-hijack / sea-green bug. $0, no network, no DB/Shopify.
+
+Imports the ACTUAL shipped logic from reextract2 (name_family/fam/classify) so the test can't
+drift from the code it validates.
+"""
+import sys, os, json
+from collections import Counter
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+SCRIPTS = os.path.join(ROOT, "scripts")
+sys.path.insert(0, SCRIPTS)
+sys.argv = [sys.argv[0]]                       # so reextract2 import sees no argv[1]
+import reextract2 as R                         # noqa: E402  (importable via __main__ guard)
+R.NAME_FAMILY = json.load(open(os.path.join(SCRIPTS, "name_family.json")))
+
+def hex_rgb(h):
+    h = h.lstrip("#")
+    return int(h[0:2],16), int(h[2:4],16), int(h[4:6],16)
+
+def reclassify(row):
+    """Return (declaredFamily, imageFamily, verdict, reason) under the FIXED mapper."""
+    df = R.name_family(row.get("declared",""))
+    hx = row.get("real_hex") or row.get("cached_hex")
+    if not hx:
+        return df, row.get("imageFamily"), "ERROR", "no-hex"
+    imf, s, l = R.fam(*hex_rgb(hx))
+    v, reason = R.classify(df, imf, s, l)
+    return df, imf, v, reason
+
+def main():
+    src = os.path.join(ROOT, "out", "reextracted.jsonl")
+    rows = [json.loads(x) for x in open(src) if x.strip()]
+    out_path = os.path.join(ROOT, "out", "reextracted_remapped.jsonl")
+
+    before = Counter(); after = Counter()
+    cleared = []          # was HIGH, now not HIGH
+    new_high = []         # was not HIGH, now HIGH (regression watch)
+    fam_changed = []      # declaredFamily changed
+    with open(out_path, "w") as f:
+        for r in rows:
+            ov = r.get("verdict"); odf = r.get("declaredFamily")
+            ndf, nimf, nv, nreason = reclassify(r)
+            before[ov] += 1; after[nv] += 1
+            r2 = dict(r)
+            r2["declaredFamily_old"] = odf; r2["verdict_old"] = ov
+            r2["declaredFamily"] = ndf; r2["imageFamily"] = nimf
+            r2["verdict"] = nv; r2["reason"] = nreason
+            f.write(json.dumps(r2) + "\n")
+            if odf != ndf: fam_changed.append((r2, odf, ndf))
+            if ov == "HIGH" and nv != "HIGH": cleared.append((r2, nv, nreason))
+            if ov != "HIGH" and nv == "HIGH": new_high.append((r2, ov, nreason))
+
+    print("=== VERDICT COUNTS (all", len(rows), "re-extracted rows) ===")
+    print("BEFORE:", dict(before))
+    print("AFTER :", dict(after))
+    print(f"\ndeclaredFamily changed by fix: {len(fam_changed)} rows")
+    print(f"HIGH -> non-HIGH (false positives cleared): {len(cleared)}")
+    print(f"non-HIGH -> HIGH (new flags, regression watch): {len(new_high)}")
+
+    print("\n=== CLEARED (former Tier-1 HIGH now OK/REVIEW) ===")
+    for r, nv, why in sorted(cleared, key=lambda x: x[0].get("dwsku","")):
+        print(f"  {r.get('dwsku',''):14} {r.get('declared','')[:42]:42} "
+              f"fam {r.get('declaredFamily_old','')}->{r.get('declaredFamily','')}"
+              f" img={r.get('imageFamily',''):7} -> {nv:6} ({why})")
+
+    if new_high:
+        print("\n=== NEW HIGH (introduced by fix — inspect) ===")
+        for r, ov, why in new_high:
+            print(f"  {r.get('dwsku',''):14} {r.get('declared','')[:42]:42} -> HIGH ({why})")
+
+if __name__ == "__main__":
+    main()

← 496eff3 prep: verified putty flat-shots for LIN-980023/980022 wrong-  ·  back to Dw Color Image Audit  ·  color-image-audit: refine no-dash mapper — demote pattern/pl 30306bf →