[object Object]

← back to Designerwallcoverings

TK-11574: fix classifier substring bug + make the job window self-deriving

7aa2e4eef9987a5e630f43e4115ad4b2a63161ef · 2026-09-13 16:40:51 -0700 · Steve Abrams

Contrarian review found the identity test read `(' '+t+' ') in tag or t in tag`, whose
second clause made the word-boundary check dead code. Pure substring matching laundered
real defects into the clean bucket ('rings' matched 'Wallcoverings', 'fort' matched a
different product's 'Forte'). Now strict word boundaries.

The export script also hardcoded the ASSUMED window, so it could not reproduce its own
headline count. It now re-derives the burst from the data every run and exits with
WINDOW DRIFT if the configured window fails to contain it.

Adds the wrong-colourway detector and the SKU-in-handle cross-check that rescues 183 rows
(140 ACTIVE Roberto Cavalli) whose tags are correct and must not be swept.

Read-only, no Shopify writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Eu4qMc5WBStSPq2FSnEcAG

Files touched

Diff

commit 7aa2e4eef9987a5e630f43e4115ad4b2a63161ef
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 16:40:51 2026 -0700

    TK-11574: fix classifier substring bug + make the job window self-deriving
    
    Contrarian review found the identity test read `(' '+t+' ') in tag or t in tag`, whose
    second clause made the word-boundary check dead code. Pure substring matching laundered
    real defects into the clean bucket ('rings' matched 'Wallcoverings', 'fort' matched a
    different product's 'Forte'). Now strict word boundaries.
    
    The export script also hardcoded the ASSUMED window, so it could not reproduce its own
    headline count. It now re-derives the burst from the data every run and exits with
    WINDOW DRIFT if the configured window fails to contain it.
    
    Adds the wrong-colourway detector and the SKU-in-handle cross-check that rescues 183 rows
    (140 ACTIVE Roberto Cavalli) whose tags are correct and must not be swept.
    
    Read-only, no Shopify writes.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Eu4qMc5WBStSPq2FSnEcAG
---
 scripts/tk11574-titletag-backstop.mjs | 45 +++++++++++++++++++++++++---
 scripts/tk11574_classify.py           |  5 +++-
 scripts/tk11574_final.py              | 56 +++++++++++++++++++++++++++++++++++
 scripts/tk11574_tier2.py              | 38 ++++++++++++++++++++++++
 4 files changed, 139 insertions(+), 5 deletions(-)

diff --git a/scripts/tk11574-titletag-backstop.mjs b/scripts/tk11574-titletag-backstop.mjs
index 346e93e..afb9092 100644
--- a/scripts/tk11574-titletag-backstop.mjs
+++ b/scripts/tk11574-titletag-backstop.mjs
@@ -8,10 +8,14 @@
 import { gql } from './lib/shopify.mjs';
 import fs from 'node:fs';
 
-// Window per ticket: 2026-04-05T23:20:00Z .. 23:26:59Z  (= 16:20-16:26 PT)
-// Padded by 60s on each side so a boundary write cannot escape the backstop.
-const WIN_LO = Date.parse('2026-04-05T23:19:00Z');
-const WIN_HI = Date.parse('2026-04-05T23:27:59Z');
+// The window is DERIVED from the data, not assumed. The ticket assumed 23:20:00..23:26:59Z;
+// measured, the job is one contiguous burst 23:17:33..23:27:08Z, so the assumed window clips
+// ~31% of it. Defaults below are those measured boundaries; the script re-derives the burst on
+// every run (contiguous non-empty minutes around the densest minute of 2026-04-05) and FAILS
+// LOUDLY if the derived burst does not match, so the headline count can never silently drift
+// from the window that produced it. Override with TK11574_LO / TK11574_HI.
+const WIN_LO = Date.parse(process.env.TK11574_LO || '2026-04-05T23:17:00Z');
+const WIN_HI = Date.parse(process.env.TK11574_HI || '2026-04-05T23:28:00Z');
 const OUT = process.env.TK11574_OUT || '/tmp/tk11574_titletag_backstop.jsonl';
 const META = OUT.replace(/\.jsonl$/, '.meta.json');
 const RAW = process.env.TK11574_RAW || '/tmp/tk11574_bulk_raw.jsonl';
@@ -72,6 +76,39 @@ const jsonl = await res.text();
 fs.writeFileSync(RAW, jsonl);
 const lines = jsonl.split('\n').filter(Boolean);
 
+// ---- derive the job burst empirically from every title_tag updatedAt in the catalog ----
+{
+  const mins = new Map();
+  for (const raw of lines) {
+    let n; try { n = JSON.parse(raw); } catch { continue; }
+    if (!n.id || !String(n.id).includes('/Product/')) continue;
+    const u = n.metafield?.updatedAt; if (!u) continue;
+    const k = u.slice(0, 16);
+    mins.set(k, (mins.get(k) || 0) + 1);
+  }
+  const day = [...mins.entries()].filter(([k]) => k.startsWith('2026-04-05')).sort();
+  if (day.length) {
+    const peak = day.reduce((a, b) => (b[1] > a[1] ? b : a));
+    const idx = day.findIndex(d => d[0] === peak[0]);
+    const step = k => { const d = new Date(k + ':00Z'); return d; };
+    let lo = idx, hi = idx;
+    while (lo > 0 && (step(day[lo][0]) - step(day[lo - 1][0])) === 60000) lo--;
+    while (hi < day.length - 1 && (step(day[hi + 1][0]) - step(day[hi][0])) === 60000) hi++;
+    const burst = day.slice(lo, hi + 1);
+    const burstRows = burst.reduce((a, b) => a + b[1], 0);
+    console.error('derived burst: ' + burst[0][0] + ' .. ' + burst[burst.length - 1][0] +
+      '  minutes=' + burst.length + '  rows=' + burstRows);
+    console.error('minute histogram: ' + burst.map(([k, v]) => k.slice(11) + '=' + v).join(' '));
+    const dLo = step(burst[0][0]).getTime(), dHi = step(burst[burst.length - 1][0]).getTime() + 60000;
+    if (dLo < WIN_LO || dHi > WIN_HI) {
+      console.error(`WINDOW DRIFT: derived burst [${new Date(dLo).toISOString()},${new Date(dHi).toISOString()}] ` +
+        `is not contained by the configured window [${new Date(WIN_LO).toISOString()},${new Date(WIN_HI).toISOString()}]. ` +
+        `The configured window would UNDERCOUNT the job. Refusing to report.`);
+      process.exit(2);
+    }
+  }
+}
+
 let totalProducts = 0, withTag = 0, inWindow = 0, noTag = 0;
 let tsMin = null, tsMax = null;
 const byVendor = {}, byStatus = {};
diff --git a/scripts/tk11574_classify.py b/scripts/tk11574_classify.py
index eb13cb2..ea76552 100644
--- a/scripts/tk11574_classify.py
+++ b/scripts/tk11574_classify.py
@@ -32,7 +32,10 @@ for r in rows:
         verdict = 'NOT_MEASURED'   # no distinctive token to test against
         hit = []
     else:
-        hit = [t for t in d if (' ' + t + ' ') in tagn or t in tagn]
+        # STRICT word-boundary only. The old `or t in tagn` made this pure substring matching,
+        # which laundered real defects into HAS_IDENTITY (e.g. 'rings' matched 'Wallcove-rings',
+        # 'fort' matched a different product's 'Forte').
+        hit = [t for t in d if re.search(r'(?<![a-z0-9])' + re.escape(t) + r'(?![a-z0-9])', tagn)]
         verdict = 'HAS_IDENTITY' if hit else 'NO_IDENTITY'
     r2 = dict(r)
     r2['distinctive'] = d
diff --git a/scripts/tk11574_final.py b/scripts/tk11574_final.py
new file mode 100644
index 0000000..2fce91b
--- /dev/null
+++ b/scripts/tk11574_final.py
@@ -0,0 +1,56 @@
+import json, re, collections, datetime
+rows=json.load(open('tk11574_flagged.json'))
+def sq(s): return ' '.join(re.sub(r'[^a-z0-9 ]+',' ',(s or '').lower()).split())
+MKT = re.compile(r'available at|available exclusively|authorized dealer|samples and purchasing|samples & purchasing|'
+                 r'designer wallcoverings|designerwallcoverings|complimentary samples|no charge|largest selection|'
+                 r'interior design|online resource|trade pricing|exclusively at|^shop\b|^explore\b|^discover\b|'
+                 r'^imported\b|leading resource|one stop|1-888|live chat|world.s leader|commercial grade', re.I)
+def cores(s): return set(re.findall(r'\d{4,8}', (s or '').lower()))
+COLORS=set(open('/tmp/colors.txt').read().split()) if False else set("""white ivory cream beige taupe greige sand oatmeal linen natural camel tan bronze copper gold silver pewter platinum charcoal graphite slate grey gray black ebony onyx navy blue indigo cobalt teal aqua turquoise azure sky celadon sage green olive moss emerald jade lime mint yellow ochre mustard citron amber orange coral peach apricot terracotta rust red crimson scarlet burgundy wine rose blush pink fuchsia magenta purple violet lilac lavender plum aubergine mauve brown chocolate mocha espresso walnut chestnut caramel honey wheat straw pearl opal smoke stone flax denim""".split())
+
+T={}
+noid=[x for x in rows if x['identity']=='NO_IDENTITY']
+T['T1b_wrong_product_name'] = [x for x in noid if not MKT.search(x['tag'])]
+T['T1a_vendor_boilerplate'] = [x for x in noid if MKT.search(x['tag'])]
+mm=[]
+for x in rows:
+    if x['identity']!='HAS_IDENTITY': continue
+    tc=set(sq(x['title']).split())&COLORS; gc=set(sq(x['tag']).split())&COLORS
+    if tc and gc and not (tc&gc): mm.append(x)
+T['T1c_wrong_colorway']=mm
+T['T2_truncated_ellipsis']=[x for x in rows if 'truncated_ellipsis' in x['flags']]
+T['T3_doubled_vendor_or_phrase']=[x for x in rows if 'doubled_vendor' in x['flags'] or 'doubled_phrase' in x['flags']]
+nm=[x for x in rows if x['identity']=='NOT_MEASURED']
+T['T4_still_untestable']=[x for x in nm if not (cores(x['handle'])&cores(x['tag']))]
+resolved=[x for x in nm if cores(x['handle'])&cores(x['tag'])]
+T['T5_over_60_chars_only']=[x for x in rows if x['flags'] and set(x['flags'])<={'over_60_chars','identical_to_title'}]
+
+A=lambda s: sum(1 for x in s if x['status']=='ACTIVE')
+print(f'{"tier":32s}{"ACTIVE":>8s}{"ALL":>8s}')
+print('-'*48)
+for k,v in T.items(): print(f'{k:32s}{A(v):>8d}{len(v):>8d}')
+print(f'\nT4 rows RESOLVED as correct (SKU in handle == SKU in tag, do NOT delete): {len(resolved)} (ACTIVE {A(resolved)})')
+
+sub=set(); 
+for k in ('T1b_wrong_product_name','T1a_vendor_boilerplate','T1c_wrong_colorway','T2_truncated_ellipsis','T3_doubled_vendor_or_phrase','T4_still_untestable'):
+    sub |= {x['pid'] for x in T[k] if x['status']=='ACTIVE'}
+print(f'\nUNION ACTIVE with >=1 substantive defect: {len(sub)} of {A(rows)} in-window ACTIVE')
+
+summary={'ticket':'TK-11574','generated_at':datetime.datetime.now(datetime.UTC).isoformat(),
+ 'job_window_utc':['2026-04-05T23:17:33Z','2026-04-05T23:27:08Z'],
+ 'catalog_products':178826,'products_with_title_tag':98676,
+ 'in_window_total':len(rows),'by_status':dict(collections.Counter(x['status'] for x in rows)),
+ 'tiers':{k:{'all':len(v),'active':A(v)} for k,v in T.items()},
+ 't4_resolved_correct':{'all':len(resolved),'active':A(resolved)},
+ 'active_union_substantive_defect':len(sub),
+ 'classifier':'strict word-boundary identity match (substring bug fixed after contrarian review)'}
+json.dump(summary, open('tk11574_summary.json','w'), indent=1)
+import os
+A_=os.path.expanduser('~/.claude/yolo-queue/pending-approval/assets')
+for k,v in T.items():
+    json.dump([{kk:x[kk] for kk in ('pid','handle','vendor','status','title','tag','ts')} for x in v if x['status']=='ACTIVE'],
+              open(f'{A_}/TK-11574-{k.replace("_","-")}-ACTIVE.json','w'))
+json.dump([{kk:x[kk] for kk in ('pid','handle','vendor','status','title','tag','ts')} for x in resolved],
+          open(f'{A_}/TK-11574-T4-resolved-correct-DO-NOT-DELETE.json','w'))
+json.dump(summary, open(f'{A_}/TK-11574-backstop-summary.json','w'), indent=1)
+print('\nrewrote assets + summary')
diff --git a/scripts/tk11574_tier2.py b/scripts/tk11574_tier2.py
new file mode 100644
index 0000000..27c417d
--- /dev/null
+++ b/scripts/tk11574_tier2.py
@@ -0,0 +1,38 @@
+import json, re, collections
+rows=json.load(open('tk11574_classified.json'))
+def sq(s): return ' '.join(re.sub(r'[^a-z0-9 ]+',' ',(s or '').lower()).split())
+
+COLORS = set("""white ivory cream beige taupe greige sand oatmeal linen natural camel tan bronze copper gold
+silver pewter platinum charcoal graphite slate grey gray black ebony onyx navy blue indigo cobalt teal aqua
+turquoise azure sky celadon sage green olive moss emerald jade lime mint yellow ochre mustard citron amber
+orange coral peach apricot terracotta rust red crimson scarlet burgundy wine rose blush pink fuchsia magenta
+purple violet lilac lavender plum aubergine mauve brown chocolate mocha espresso walnut chestnut caramel
+honey wheat straw pearl opal smoke stone flax denim""".split())
+
+# (b) T4 SKU cross-check
+nm=[x for x in rows if x['identity']=='NOT_MEASURED']
+def skus(s): return set(re.findall(r'[a-z]{1,4}\d{3,7}|\d{4,7}', (s or '').lower()))
+resolved=[x for x in nm if skus(x['handle']) & skus(x['tag'])]
+still=[x for x in nm if not (skus(x['handle']) & skus(x['tag']))]
+ca=lambda s: collections.Counter(x['status'] for x in s)['ACTIVE']
+print(f'T4 NOT_MEASURED {len(nm)} (ACTIVE {ca(nm)})')
+print(f'  -> resolved CORRECT via SKU-in-handle == SKU-in-tag : {len(resolved)} (ACTIVE {ca(resolved)})')
+print(f'  -> still genuinely untestable                       : {len(still)} (ACTIVE {ca(still)})')
+for x in resolved[:3]: print('   RESOLVED ', x['handle'][:44], '=>', x['tag'][:66])
+for x in still[:4]:    print('   UNTESTED ', x['handle'][:44], '=>', x['tag'][:66])
+
+# (c) wrong colorway hiding inside HAS_IDENTITY
+has=[x for x in rows if x['identity']=='HAS_IDENTITY']
+mm=[]
+for x in has:
+    tc=set(sq(x['title']).split()) & COLORS
+    gc=set(sq(x['tag']).split()) & COLORS
+    if tc and gc and not (tc & gc):
+        mm.append({**x,'title_colors':sorted(tc),'tag_colors':sorted(gc)})
+print(f'\nT1c WRONG COLORWAY (right pattern, wrong colour) hiding inside HAS_IDENTITY: {len(mm)} ', dict(collections.Counter(x["status"] for x in mm)))
+for x in [y for y in mm if y['status']=='ACTIVE'][:10]:
+    print(f'   [{x["vendor"][:15]:15s}] {x["title"][:44]!r}\n        TAG {x["tag"][:62]!r}   {x["title_colors"]} vs {x["tag_colors"]}')
+json.dump(mm, open('tk11574_tier_T1c_wrong_colorway.json','w'))
+json.dump(still, open('tk11574_tier_T4_still_untestable.json','w'))
+json.dump(resolved, open('tk11574_T4_resolved_correct.json','w'))
+print('\nwrote T1c / T4-still / T4-resolved')

← 1d0b9dd TK-11644: description_tag mass-overwrite — sweep, proof, and  ·  back to Designerwallcoverings  ·  TK-11574: --offline mode so the headline count is reproducib 8e95060 →