← back to Sanderson Onboard

scripts/settlement_gate.mjs

81 lines

// settlement_gate.mjs — applies the DW Settlement combined gate over a manifest, text-side, per design.
// Binding rule (SETTLEMENT-BINDING-TEXT.md): a design is PROHIBITED iff BOTH
//   Part A (directional foliage/leaves/fronds) AND Part B (bananas|grapes|birds|butterflies) are present.
// We have pattern name + colorway + collection (not a generated image), so this is a NAME-SIGNAL screen.
// Defendant-favorable: borderline -> BLOCK. A Part-B name signal on a foliage/botanical/tropical name = BLOCK.
// A Part-B signal alone on a clearly non-foliage substrate (geometric/stripe/plain/trellis) = allow.
// SDG-specific known blocks: Morris "Strawberry Thief" (birds), tropical/bird motif lines.
// Output: annotates each manifest item with settlement:{verdict, reason}; writes back + a blocked report.
import fs from 'node:fs';

// Part B — prohibited specific elements (any one)
const PART_B = [
  /\bbanana(s|\s*pods?)?\b/i, /\bgrapes?\b/i,
  /\bbird(s|life)?\b/i, /\bstrawberry\s*thief\b/i,           // Strawberry Thief = the canonical Morris bird print
  /\bbutterfl(y|ies)\b/i,
  // common bird species names that appear in SDG/Morris/Harlequin pattern names
  /\b(finch|swallow|swift|heron|crane|peacock|pheasant|dove|owl|kingfisher|wren|robin|nightingale|hummingbird|cockatoo|parrot|toucan|flamingo|sparrow|magpie|starling|thrush|lovebird)\b/i,
];
// Part A — directional foliage / open-botanical signals (leaves, palm fronds, similar foliage)
const PART_A = [
  /\b(leaf|leaves|foliage|frond|palm|jungle|tropical|botanic(al)?|forest|vine|fern|bough|willow|acanthus|arboreal|meadow|garden|orchard|blossom|floral|flower)\b/i,
];
// Clearly NON-foliage substrate -> Part A NOT satisfied -> allow even with a Part B token
const NON_FOLIAGE = [
  /\b(stripe|check|plaid|geometr|trellis|lattice|cane|chevron|herringbone|dot|spot|plain|texture|weave|moir|damask|paisley|ikat|houndstooth|grid|diamond|key|fret)\b/i,
];
// Acceptable-element carve-out (tree trunks / branches / non-prohibited fruit-animal) -> permitted
const ACCEPTABLE = [/\b(tree|trunk|branch|bough)\b/i]; // conservative: only clear trunk/branch words

// "Dove" (dove-grey), "Robin"(robin's-egg), "Wren", "Crane"(colorway) are COMMON PAINT-COLOR names.
// In the COLOR field alone they are NOT a bird-motif signal — only a bird token in the PATTERN name,
// or a bird token anywhere combined with a foliage pattern, is a real motif signal. This kills the
// "Koto Dove/Moonstone" / "Lotus Dove/Moonstone" false positives while keeping "Squirrel and Dove Embroidery".
const BIRD_RE = /\b(bird|strawberry\s*thief|butterfl(y|ies)|finch|swallow|swift|heron|peacock|pheasant|owl|kingfisher|nightingale|hummingbird|cockatoo|parrot|toucan|flamingo|magpie|starling|thrush|lovebird)\b/i;
// ambiguous words that are also paint colors — only count as bird when in PATTERN, not COLOR
const AMBIG_BIRD_RE = /\b(dove|robin|wren|crane|sparrow)\b/i;
const OTHER_B_RE = /\b(banana(s|\s*pods?)?|grapes?)\b/i;

function classify(pattern, color, collection) {
  const patt = String(pattern || '');
  const all = `${pattern || ''} ${color || ''} ${collection || ''}`;
  const a = PART_A.some(re => re.test(all));
  const nonFoliage = NON_FOLIAGE.some(re => re.test(all));
  const acceptable = ACCEPTABLE.some(re => re.test(all));
  // bird/butterfly motif present iff: an unambiguous bird word ANYWHERE, OR an ambiguous
  // paint-color-ish bird word only when it's in the PATTERN (motif), OR ambiguous word + foliage.
  const birdUnambig = BIRD_RE.test(all);
  const birdInPattern = AMBIG_BIRD_RE.test(patt);
  const birdWithFoliage = AMBIG_BIRD_RE.test(all) && a;
  const birdOrBfly = birdUnambig || birdInPattern || birdWithFoliage;
  const otherB = OTHER_B_RE.test(all);

  if ((birdOrBfly || otherB) && a && !nonFoliage) {
    if (acceptable && !birdOrBfly) return { verdict: 'OK', reason: 'partA+partB but acceptable carve-out (trunk/branch), non-bird' };
    return { verdict: 'BLOCK', reason: birdOrBfly ? 'partA-foliage + bird/butterfly (Strawberry-Thief class)' : 'partA-foliage + partB(banana/grape)' };
  }
  // bird/butterfly motif with no clear non-foliage substrate = borderline -> defendant-favorable BLOCK
  if (birdOrBfly && !nonFoliage && !acceptable) return { verdict: 'BLOCK', reason: 'bird/butterfly motif, substrate ambiguous — defendant-favorable' };
  return { verdict: 'OK', reason: '' };
}

function run(brand) {
  const path = new URL(`../pilot/manifest-${brand}.json`, import.meta.url).pathname;
  if (!fs.existsSync(path)) { console.log(`${brand}: no manifest`); return; }
  const items = JSON.parse(fs.readFileSync(path, 'utf8'));
  const kept = [], blocked = [];
  for (const it of items) {
    const c = classify(it.pattern, it.color, it.collection);
    it.settlement = c;
    if (c.verdict === 'BLOCK') blocked.push({ dw_sku: it.dw_sku, name: `${it.pattern} ${it.color}`, reason: c.reason });
    else kept.push(it);
  }
  fs.writeFileSync(path, JSON.stringify(kept, null, 2));  // manifest now settlement-clean
  fs.writeFileSync(new URL(`../pilot/settlement-blocked-${brand}.json`, import.meta.url).pathname, JSON.stringify(blocked, null, 2));
  const byReason = blocked.reduce((a, x) => { a[x.reason] = (a[x.reason] || 0) + 1; return a; }, {});
  console.log(`${brand}: settlement-clean=${kept.length} BLOCKED=${blocked.length} ${JSON.stringify(byReason)}`);
}

const only = (process.argv.find(a => a.startsWith('--brand=')) || '').split('=')[1];
for (const b of only ? [only] : ['sanderson', 'harlequin', 'zoffany', 'morris']) run(b);