← 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);