← back to Dw Pairs Well
tools/pattern-grouping-dryrun.js
118 lines
#!/usr/bin/env node
// WS-1 dry-run (READ-ONLY): compare candidate pattern-grouping strategies on the
// live dw_unified mirror so /dtd can commit a grouping-key choice on real numbers.
// Writes NOTHING. Outputs a report to stdout + tools/pattern-grouping-report.json.
//
// node tools/pattern-grouping-dryrun.js
//
// Strategies compared (all scoped WITHIN vendor to avoid cross-vendor collisions):
// T0 exact title
// T1 normalized title (strip " | vendor" suffix, product-type noise, punctuation)
// T2 T1 + strip color tokens (so "Albany Linen-Beige" and "-Cedar" collapse)
// PN pattern_name (baseline — known unreliable; quantifies how bad)
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const { COLORS } = require('../lib/classify');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// product-type / material noise to drop from titles when deriving a pattern key
const NOISE = [
'wallcovering','wallcoverings','wallpaper','wall covering','fabric','fabrics',
'durable vinyl','vinyl','grasscloth','print','commercial','residential',
'type ii','type iii','mural','wall mural','peel and stick','peel-and-stick',
'self adhesive','self-adhesive','by phillipe romano','phillipe romano'
];
// color tokens = the classify COLOR set + common modifiers
const COLOR_WORDS = new Set([...COLORS,
'light','dark','deep','pale','soft','warm','cool','muted','bright','antique',
'metallic','natural','neutral','multi','multicolor','multicolour'
]);
function stripVendorSuffix(title) {
// "Name ... | Vendor" → "Name ..."
const i = title.indexOf('|');
return (i === -1 ? title : title.slice(0, i)).trim();
}
function normTitle(title) {
let s = stripVendorSuffix(title).toLowerCase();
for (const n of NOISE) s = s.split(n).join(' ');
s = s.replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
return s;
}
function stripColors(normalized) {
const kept = normalized.split(' ').filter(w => w && !COLOR_WORDS.has(w));
return kept.join(' ').trim() || normalized; // never empty-out a title
}
function summarize(label, keyFn, rows) {
const groups = new Map(); // key -> {count, vendor, sampleTitles:Set}
let keyed = 0;
for (const r of rows) {
const k = keyFn(r);
if (!k) continue;
keyed++;
const gk = (r.vendor || '∅') + '::' + k;
let g = groups.get(gk);
if (!g) { g = { count: 0, vendor: r.vendor, key: k, samples: [] }; groups.set(gk, g); }
g.count++;
if (g.samples.length < 3) g.samples.push(r.title);
}
const arr = [...groups.values()];
const multi = arr.filter(g => g.count >= 2);
const productsInMulti = multi.reduce((a, g) => a + g.count, 0);
arr.sort((a, b) => b.count - a.count);
return {
label,
products_keyed: keyed,
distinct_groups: arr.length,
multi_colorway_groups: multi.length,
products_in_multi_groups: productsInMulti,
pct_collapsed: ((productsInMulti - multi.length) / keyed * 100).toFixed(1) + '%',
avg_group_size: (keyed / arr.length).toFixed(2),
top20: arr.slice(0, 20).map(g => ({ n: g.count, vendor: g.vendor, key: g.key, eg: g.samples })),
// over-grouping suspects = very large groups (could be legit like Fine Stripes×76 OR junk)
huge_groups_gt60: arr.filter(g => g.count > 60).length
};
}
(async () => {
console.log('Loading active products from mirror…');
const r = await pool.query(`
SELECT dw_sku, title, vendor, pattern_name
FROM shopify_products
WHERE status = 'ACTIVE' AND title IS NOT NULL AND handle IS NOT NULL
`);
const rows = r.rows;
console.log('Active products:', rows.length);
const reports = [
summarize('T0 exact title', x => stripVendorSuffix(x.title).toLowerCase(), rows),
summarize('T1 normalized title', x => normTitle(x.title), rows),
summarize('T2 normalized − colors', x => stripColors(normTitle(x.title)), rows),
summarize('PN pattern_name (baseline)',x => (x.pattern_name || '').toLowerCase().trim() || null, rows)
];
const out = { generated_at: new Date().toISOString(), active_products: rows.length, strategies: reports };
const file = path.join(__dirname, 'pattern-grouping-report.json');
fs.writeFileSync(file, JSON.stringify(out, null, 2));
// ---- console digest ----
for (const s of reports) {
console.log('\n========================================');
console.log(s.label);
console.log(' distinct groups :', s.distinct_groups);
console.log(' multi-colorway groups :', s.multi_colorway_groups);
console.log(' products in those :', s.products_in_multi_groups);
console.log(' grid rows removed :', (s.products_in_multi_groups - s.multi_colorway_groups), '(' + s.pct_collapsed + ' of catalog collapsed)');
console.log(' avg group size :', s.avg_group_size);
console.log(' huge groups (>60) :', s.huge_groups_gt60, '(inspect for over-grouping)');
console.log(' top 5 largest:');
s.top20.slice(0, 5).forEach(g => console.log(` ${g.n}× [${g.vendor}] "${g.key}" e.g. ${JSON.stringify(g.eg[0])}`));
}
console.log('\nFull report → ', file);
await pool.end();
})().catch(e => { console.error(e); process.exit(1); });