← back to Tk 11331 Exec

d3b_heldreview_classify.mjs

78 lines

#!/usr/bin/env node
// TK-11331 D3b-heldreview — READ-ONLY classifier. No DB writes, no network. Emits table + counts.
import { createRequire } from 'module';
import fs from 'fs';
import path from 'path';
const require = createRequire(import.meta.url);
const { Pool } = require('pg');
const HERE = path.dirname(new URL(import.meta.url).pathname);
const TSV = path.join(HERE, 'data', 'momentum_feed_full.tsv');
const OUT = path.join(HERE, 'data', 'd3b-heldreview-table.json');
const pool = new Pool({ host: '/tmp', database: 'dw_unified' });
const norm = s => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const fcolor = s => norm(s).replace(/gray/g, 'grey').replace(/[^a-z0-9]/g, '');
const KEEP = new Set(['Wallcovering', 'Acoustic']);

function readTSV() {
  const lines = fs.readFileSync(TSV, 'utf8').split('\n').filter(Boolean);
  const cols = lines[0].split('\t');
  return lines.slice(1).map(l => { const p = l.split('\t'); const o = {}; cols.forEach((c, i) => o[c] = p[i]); return o; });
}

async function main() {
  const { rows: pr } = await pool.query('SELECT pattern_name, color_name FROM momentum_colorways');
  const stagedPatternNorm = new Set(pr.map(r => norm(r.pattern_name)));
  const stagedPairNorm = new Set(pr.map(r => norm(r.pattern_name) + '' + norm(r.color_name)));
  const patRaw = new Map(); const patFcolors = new Map();
  for (const r of pr) {
    const n = norm(r.pattern_name);
    if (!patRaw.has(n)) patRaw.set(n, new Map());
    patRaw.get(n).set(r.pattern_name, (patRaw.get(n).get(r.pattern_name) || 0) + 1);
    if (!patFcolors.has(n)) patFcolors.set(n, new Set());
    patFcolors.get(n).add(fcolor(r.color_name));
  }
  const canon = n => { const m = patRaw.get(n); return m ? [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].length - b[0].length)[0][0] : null; };

  const feed = readTSV();
  const inscope = feed.filter(r => KEEP.has(r.category_name) && (r.pattern_name || '').trim() && (r.preferred_color_name || '').trim());
  const seen = new Set(); const dedup = [];
  for (const r of inscope) { const k = r.pattern_name + '' + r.preferred_color_name; if (seen.has(k)) continue; seen.add(k); dedup.push(r); }

  const held = dedup.filter(r => stagedPatternNorm.has(norm(r.pattern_name)) && !stagedPairNorm.has(norm(r.pattern_name) + '' + norm(r.preferred_color_name)));
  const table = [];
  for (const r of held) {
    const np = norm(r.pattern_name); const cp = canon(np); const disamb = cp !== r.pattern_name;
    const fc = fcolor(r.preferred_color_name); const fuzzyDup = patFcolors.get(np)?.has(fc);
    let verdict, evidence;
    if (fuzzyDup) { verdict = 'DROP-DUP'; evidence = `fuzzy-color match to staged colorway of "${cp.trim()}" (spelling variant); fcolor=${fc}`; }
    else if (disamb) { verdict = 'DISAMBIGUATE'; evidence = `new colorway; feed spelling "${JSON.stringify(r.pattern_name)}" != staged "${JSON.stringify(cp)}" (whitespace/case) — stage under staged spelling`; }
    else { verdict = 'STAGE-NEW'; evidence = `norm pair + fuzzy color both absent under staged pattern "${cp.trim()}"`; }
    table.push({ item: `${r.pattern_name.trim()} / ${r.preferred_color_name}`, number: r.number, cat: r.category_name, verdict, stage_pattern: verdict === 'DROP-DUP' ? null : cp, evidence });
  }

  const COLLISION = ['Cavern II', 'Rectangular Hatch', 'Rectangular Linear', 'Rectangular Louvre', 'Rectangular Vault', 'Rectangular Vertex 9/16'];
  const collision = [];
  for (const c of COLLISION) {
    const cn = norm(c); const frs = dedup.filter(r => norm(r.pattern_name) === cn);
    const newOnes = frs.filter(r => !stagedPairNorm.has(cn + '' + norm(r.preferred_color_name)) && !patFcolors.get(cn)?.has(fcolor(r.preferred_color_name)));
    let verdict;
    if (frs.length === 0) verdict = 'NO-OP (absent from feed)';
    else if (newOnes.length === 0) verdict = 'DROP-DUP (all staged)';
    else verdict = `DISAMBIGUATE (${newOnes.length} new under "${(canon(cn) || '').trim()}")`;
    collision.push({ pattern: c, feed_cw: frs.length, pattern_staged: stagedPatternNorm.has(cn), staged_spelling: canon(cn), new_cw: newOnes.length, verdict });
  }

  const counts = {
    held_total: held.length,
    STAGE_NEW: table.filter(t => t.verdict === 'STAGE-NEW').length,
    DISAMBIGUATE: table.filter(t => t.verdict === 'DISAMBIGUATE').length,
    DROP_DUP: table.filter(t => t.verdict === 'DROP-DUP').length,
  };
  console.log('COUNTS', JSON.stringify(counts));
  console.log('\nCOLLISION SUMMARY'); console.table(collision);
  console.log('\nEXTEND TABLE (first 90)'); console.table(table.map(t => ({ item: t.item, cat: t.cat, verdict: t.verdict, stage_pattern: t.stage_pattern ? JSON.stringify(t.stage_pattern) : null })));
  fs.writeFileSync(OUT, JSON.stringify({ counts, table, collision }, null, 2));
  await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });