[object Object]

← back to Designerwallcoverings

TK-11574: exhaustive phrasing-agnostic title_tag backstop (read-only bulk export + identity classifier)

230e03d39357112bf9f0192139b0830848dfc99c · 2026-09-13 16:24:13 -0700 · Steve Abrams

Full-catalog global.title_tag {value,updatedAt} bulk export plus an identity-based
classifier, replacing TK-11564's three text-signature matches.

- tk11574-titletag-backstop.mjs refuses to download a bulk operation it did not start
  (PROVENANCE FAIL). It previously attached to a concurrent session's op that was
  exporting global.description_tag, which would have reported the wrong field.
- tk11574_classify.py / tk11574_subclass2.py: product-identity test + defect tiers.

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 230e03d39357112bf9f0192139b0830848dfc99c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 16:24:13 2026 -0700

    TK-11574: exhaustive phrasing-agnostic title_tag backstop (read-only bulk export + identity classifier)
    
    Full-catalog global.title_tag {value,updatedAt} bulk export plus an identity-based
    classifier, replacing TK-11564's three text-signature matches.
    
    - tk11574-titletag-backstop.mjs refuses to download a bulk operation it did not start
      (PROVENANCE FAIL). It previously attached to a concurrent session's op that was
      exporting global.description_tag, which would have reported the wrong field.
    - tk11574_classify.py / tk11574_subclass2.py: product-identity test + defect tiers.
    
    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 | 128 ++++++++++++++++++++++++++++++++++
 scripts/tk11574_classify.py           |  79 +++++++++++++++++++++
 scripts/tk11574_subclass2.py          |  57 +++++++++++++++
 3 files changed, 264 insertions(+)

diff --git a/scripts/tk11574-titletag-backstop.mjs b/scripts/tk11574-titletag-backstop.mjs
new file mode 100644
index 0000000..346e93e
--- /dev/null
+++ b/scripts/tk11574-titletag-backstop.mjs
@@ -0,0 +1,128 @@
+// TK-11574 — EXHAUSTIVE read-only backstop for the 2026-04-05 16:20-16:26 PT bad bulk
+// SEO-title job. TK-11564/TK-11558 remediated by TEXT SIGNATURE (3 known phrasings);
+// this pass is PHRASING-AGNOSTIC: bulk-export EVERY product's global.title_tag
+// {value, updatedAt}, keep every row whose metafield was written inside the job window,
+// and emit the complete set for identity-based classification.
+//
+// No writes. Pure GraphQL bulk read. $0.
+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');
+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';
+
+const BULK_QUERY = `{
+  products {
+    edges { node {
+      id handle vendor title status productType tags
+      metafield(namespace:"global", key:"title_tag") { value updatedAt }
+    } }
+  }
+}`;
+
+async function currentOp() {
+  const d = await gql(`{ currentBulkOperation(type: QUERY) { id status errorCode objectCount url createdAt } }`);
+  return d.currentBulkOperation;
+}
+async function startBulk() {
+  // NEVER attach to a bulk op this script did not start. Another session's op can carry a
+  // DIFFERENT query (this bit us once: a concurrent global.description_tag export was
+  // mislabelled as title_tag). Wait for the app's op slot to free, then start OUR op and
+  // return ITS id so poll() can assert provenance.
+  for (;;) {
+    const cur = await currentOp();
+    if (!cur || !['CREATED', 'RUNNING'].includes(cur.status)) break;
+    process.stderr.write(`\r  waiting for foreign bulk op ${cur.id} (${cur.status}, objs=${cur.objectCount}) to finish...   `);
+    await new Promise(r => setTimeout(r, 5000));
+  }
+  process.stderr.write('\n');
+  const q = `mutation { bulkOperationRunQuery(query: ${JSON.stringify(BULK_QUERY)}) {
+    bulkOperation { id status } userErrors { field message } } }`;
+  const d = await gql(q);
+  if (d.__err) { console.error('gql error', JSON.stringify(d.__err)); process.exit(1); }
+  const ue = d.bulkOperationRunQuery.userErrors;
+  if (ue.length) { console.error('userErrors', ue); process.exit(1); }
+  return d.bulkOperationRunQuery.bulkOperation.id;
+}
+async function poll(myId) {
+  for (;;) {
+    const b = await currentOp();
+    if (b && myId && b.id !== myId) { console.error(`\nPROVENANCE FAIL: currentBulkOperation is ${b.id}, expected ${myId}. Refusing to download a foreign op's results.`); process.exit(1); }
+    process.stderr.write(`\r  bulk ${b?.status} objs=${b?.objectCount}        `);
+    if (b?.status === 'COMPLETED') { process.stderr.write('\n'); return b; }
+    if (['FAILED', 'CANCELED', 'EXPIRED'].includes(b?.status)) { console.error('\nbulk failed', b); process.exit(1); }
+    await new Promise(r => setTimeout(r, 4000));
+  }
+}
+
+const t0 = Date.now();
+console.error('TK-11574 backstop: starting full-catalog bulk export (products + global.title_tag)...');
+const myId = await startBulk();
+const done = await poll(myId);
+if (!done.url) { console.error('no results url (0 objects)'); process.exit(1); }
+
+console.error(`downloading ${done.objectCount} objects ...`);
+const res = await fetch(done.url);
+const jsonl = await res.text();
+fs.writeFileSync(RAW, jsonl);
+const lines = jsonl.split('\n').filter(Boolean);
+
+let totalProducts = 0, withTag = 0, inWindow = 0, noTag = 0;
+let tsMin = null, tsMax = null;
+const byVendor = {}, byStatus = {};
+const rows = [];
+const outFh = fs.openSync(OUT, 'w');
+
+for (const raw of lines) {
+  let n; try { n = JSON.parse(raw); } catch { continue; }
+  if (!n.id || !String(n.id).includes('/Product/')) continue;
+  totalProducts++;
+  const mf = n.metafield;
+  if (!mf || !mf.updatedAt) { noTag++; continue; }
+  withTag++;
+  const t = Date.parse(mf.updatedAt);
+  if (!(t >= WIN_LO && t <= WIN_HI)) continue;
+  inWindow++;
+  if (!tsMin || mf.updatedAt < tsMin) tsMin = mf.updatedAt;
+  if (!tsMax || mf.updatedAt > tsMax) tsMax = mf.updatedAt;
+  byVendor[n.vendor || '(none)'] = (byVendor[n.vendor || '(none)'] || 0) + 1;
+  byStatus[n.status] = (byStatus[n.status] || 0) + 1;
+  const row = {
+    pid: String(n.id).split('/').pop(),
+    handle: n.handle,
+    vendor: n.vendor || '',
+    status: n.status,
+    product_type: n.productType || '',
+    real_title: (n.title || '').trim(),
+    title_tag: (mf.value || '').trim(),
+    updated_at: mf.updatedAt,
+  };
+  rows.push(row);
+  fs.writeSync(outFh, JSON.stringify(row) + '\n');
+}
+fs.closeSync(outFh);
+
+const meta = {
+  ticket: 'TK-11574',
+  generated_at: new Date().toISOString(),
+  elapsed_sec: Math.round((Date.now() - t0) / 1000),
+  bulk_object_count: done.objectCount,
+  window_utc: ['2026-04-05T23:19:00Z', '2026-04-05T23:27:59Z'],
+  total_products: totalProducts,
+  products_with_title_tag: withTag,
+  products_without_title_tag: noTag,
+  in_window: inWindow,
+  ts_min: tsMin, ts_max: tsMax,
+  by_status: byStatus,
+  by_vendor_top: Object.fromEntries(Object.entries(byVendor).sort((a, b) => b[1] - a[1]).slice(0, 40)),
+  vendor_count: Object.keys(byVendor).length,
+  out: OUT, raw: RAW,
+};
+fs.writeFileSync(META, JSON.stringify(meta, null, 1));
+console.error(JSON.stringify(meta, null, 1));
+console.error(`\nrows -> ${OUT}\nmeta -> ${META}\nraw  -> ${RAW}`);
diff --git a/scripts/tk11574_classify.py b/scripts/tk11574_classify.py
new file mode 100644
index 0000000..eb13cb2
--- /dev/null
+++ b/scripts/tk11574_classify.py
@@ -0,0 +1,79 @@
+import json, collections, re, sys
+
+rows = json.load(open('tk11574_window_rows.json'))
+
+GENERIC = set("""wallcovering wallcoverings wallpaper wallpapers fabric fabrics sample samples mural murals
+commercial residential print prints panel panels new type collection collections designer designerwallcoverings
+color colour vinyl textile textiles grasscloth pillow pillows trim trims border borders roll rolls yard yards
+the and for with from your our all one stop shop now available purchasing premium luxury luxurious
+art wall walls europe usa inc llc co ltd studio design designs series style styles""".split())
+
+def norm(s):
+    return re.sub(r'[^a-z0-9 ]+', ' ', (s or '').lower())
+
+def toks(s):
+    return [t for t in norm(s).split() if t]
+
+def distinctive(title, vendor):
+    vt = set(toks(vendor))
+    out = []
+    for t in toks(title):
+        if len(t) < 4: continue
+        if t in GENERIC: continue
+        if t in vt: continue
+        out.append(t)
+    return out
+
+res = []
+for r in rows:
+    d = distinctive(r['title'], r['vendor'])
+    tagn = ' ' + norm(r['tag']) + ' '
+    if not d:
+        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]
+        verdict = 'HAS_IDENTITY' if hit else 'NO_IDENTITY'
+    r2 = dict(r)
+    r2['distinctive'] = d
+    r2['identity_hits'] = hit
+    r2['identity'] = verdict
+    r2['len'] = len(r['tag'])
+    res.append(r2)
+
+json.dump(res, open('tk11574_classified.json','w'))
+
+def tally(sel, label):
+    c = collections.Counter(x['identity'] for x in sel)
+    ln = collections.Counter('len>60' if x['len']>60 else 'len<=60' for x in sel)
+    print(f'{label}: n={len(sel)}  identity={dict(c)}  {dict(ln)}')
+
+tally(res, 'ALL in-window')
+for st in ('ACTIVE','ARCHIVED','DRAFT'):
+    tally([x for x in res if x['status']==st], f'  {st}')
+
+act = [x for x in res if x['status']=='ACTIVE']
+print()
+print('ACTIVE breakdown:')
+no_id = [x for x in act if x['identity']=='NO_IDENTITY']
+has   = [x for x in act if x['identity']=='HAS_IDENTITY']
+nm    = [x for x in act if x['identity']=='NOT_MEASURED']
+print(f'  NO_IDENTITY (does not describe its product): {len(no_id)}')
+print(f'  HAS_IDENTITY: {len(has)}   of which len>60: {sum(1 for x in has if x["len"]>60)}')
+print(f'  NOT_MEASURED (no distinctive token in real title): {len(nm)}')
+print()
+print('ACTIVE NO_IDENTITY by vendor (top 30):')
+for v,n in collections.Counter(x['vendor'] or '(none)' for x in no_id).most_common(30):
+    print(f'   {n:7d}  {v}')
+print()
+print('ACTIVE NO_IDENTITY sample rows:')
+for x in no_id[:12]:
+    print(f'   [{x["vendor"]}] {x["title"][:60]!r}\n        TAG: {x["tag"][:120]!r}')
+print()
+print('ACTIVE HAS_IDENTITY examples (len<=60):')
+for x in [y for y in has if y['len']<=60][:8]:
+    print(f'   [{x["vendor"]}] {x["title"][:60]!r}\n        TAG: {x["tag"][:120]!r}')
+print()
+print('ACTIVE NOT_MEASURED examples:')
+for x in nm[:8]:
+    print(f'   [{x["vendor"]}] {x["title"][:60]!r}  TAG: {x["tag"][:90]!r}')
diff --git a/scripts/tk11574_subclass2.py b/scripts/tk11574_subclass2.py
new file mode 100644
index 0000000..bf453e1
--- /dev/null
+++ b/scripts/tk11574_subclass2.py
@@ -0,0 +1,57 @@
+import json, collections, re
+rows = json.load(open('tk11574_classified.json'))
+def norm(s): return re.sub(r'[^a-z0-9 ]+',' ',(s or '').lower())
+def sq(s): return ' '.join(norm(s).split())
+
+TRUNC = re.compile(r'(\.\.\.|…)\s*$')
+
+out=[]
+for r in rows:
+    tag=r['tag']; title=r['title']; vendor=r['vendor']
+    tn, ttl = sq(tag), sq(title)
+    flags=[]
+    if r['identity']=='NO_IDENTITY': flags.append('no_product_identity')
+    if r['identity']=='NOT_MEASURED': flags.append('not_measured')
+    if TRUNC.search(tag): flags.append('truncated_ellipsis')
+    # doubled VENDOR suffix: vendor phrase occurs >=2x in tag but <=1x in the real title
+    v=sq(vendor)
+    if v and len(v)>4:
+        ct, cl = tn.count(v), ttl.count(v)
+        if ct>=2 and ct>cl: flags.append('doubled_vendor')
+    # doubled arbitrary phrase: 3+ word phrase repeated in tag but NOT repeated in the real title
+    w=tn.split(); seen={}
+    for n in (3,4):
+        for i in range(len(w)-n+1):
+            p=' '.join(w[i:i+n])
+            if p in seen and ttl.count(p)<2:
+                flags.append('doubled_phrase'); break
+            seen[p]=i
+        if 'doubled_phrase' in flags: break
+    if len(tag)>60: flags.append('over_60_chars')
+    if len(tag)<10: flags.append('under_10_chars')
+    if tn==ttl: flags.append('identical_to_title')
+    r2=dict(r); r2['flags']=flags; out.append(r2)
+json.dump(out, open('tk11574_flagged.json','w'))
+
+act=[x for x in out if x['status']=='ACTIVE']
+def n(sel,f): return sum(1 for x in sel if f in x['flags'])
+print(f'in-window total {len(out)}  ACTIVE {len(act)}  ARCHIVED {sum(1 for x in out if x["status"]=="ARCHIVED")}  DRAFT {sum(1 for x in out if x["status"]=="DRAFT")}')
+print()
+hdr=f'{"defect":26s}{"ACTIVE":>9s}{"ALL":>9s}'
+print(hdr); print('-'*len(hdr))
+for f in ('no_product_identity','truncated_ellipsis','doubled_vendor','doubled_phrase','not_measured','under_10_chars','over_60_chars','identical_to_title'):
+    print(f'{f:26s}{n(act,f):>9d}{n(out,f):>9d}')
+print()
+clean=[x for x in act if not (set(x['flags'])-{'over_60_chars','identical_to_title'})]
+print(f'ACTIVE with NO substantive defect (length/identical only): {len(clean)}')
+defect=[x for x in act if (set(x['flags'])-{'over_60_chars','identical_to_title'})]
+print(f'ACTIVE with >=1 substantive defect: {len(defect)}')
+print()
+print('--- doubled_vendor ACTIVE examples')
+for x in [y for y in act if 'doubled_vendor' in y['flags']][:6]:
+    print(f'  [{x["vendor"]}] {x["title"][:50]!r}\n     TAG {x["tag"][:95]!r}')
+print('--- doubled_phrase ACTIVE examples')
+for x in [y for y in act if 'doubled_phrase' in y['flags'] and 'doubled_vendor' not in y['flags']][:6]:
+    print(f'  [{x["vendor"]}] {x["title"][:50]!r}\n     TAG {x["tag"][:95]!r}')
+json.dump(defect, open('tk11574_active_defects.json','w'))
+print(f'\nwrote {len(defect)} ACTIVE defect rows -> tk11574_active_defects.json')

← 628279c TK-11635: restore rollback fidelity — recreate weight + trac  ·  back to Designerwallcoverings  ·  TK-11635: re-check each variant immediately before its delet 5f814e0 →