@mcptoolshop/style-dataset-lab 1.2.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +138 -113
  3. package/bin/sdlab.js +126 -0
  4. package/lib/args.js +106 -0
  5. package/lib/card.js +145 -0
  6. package/lib/comfyui.js +109 -0
  7. package/lib/config.js +248 -0
  8. package/lib/deprecation.js +14 -0
  9. package/lib/eligibility.js +110 -0
  10. package/lib/eval-pack.js +283 -0
  11. package/lib/export.js +259 -0
  12. package/lib/paths.js +38 -0
  13. package/lib/records.js +69 -0
  14. package/lib/snapshot.js +211 -0
  15. package/lib/split.js +409 -0
  16. package/package.json +20 -9
  17. package/scripts/bulk-curate-wave2-5.js +1 -1
  18. package/scripts/bulk-curate-waves11-18.js +1 -1
  19. package/scripts/canon-bind.js +188 -338
  20. package/scripts/card.js +73 -0
  21. package/scripts/compare.js +40 -30
  22. package/scripts/curate-wave25.js +1 -1
  23. package/scripts/curate.js +34 -28
  24. package/scripts/doctor.js +324 -0
  25. package/scripts/eligibility.js +91 -0
  26. package/scripts/eval-pack.js +92 -0
  27. package/scripts/export.js +85 -0
  28. package/scripts/generate-controlnet.js +36 -121
  29. package/scripts/generate-identity.js +38 -106
  30. package/scripts/generate-ipadapter.js +42 -119
  31. package/scripts/generate.js +22 -81
  32. package/scripts/init.js +229 -0
  33. package/scripts/migrate-records.js +15 -21
  34. package/scripts/painterly-test.js +20 -47
  35. package/scripts/painterly.js +27 -106
  36. package/scripts/snapshot.js +132 -0
  37. package/scripts/split.js +154 -0
  38. package/templates/domains/architecture/constitution.json +97 -0
  39. package/templates/domains/architecture/lanes.json +41 -0
  40. package/templates/domains/architecture/project.json +20 -0
  41. package/templates/domains/architecture/rubric.json +40 -0
  42. package/templates/domains/architecture/terminology.json +19 -0
  43. package/templates/domains/character-design/constitution.json +88 -0
  44. package/templates/domains/character-design/lanes.json +35 -0
  45. package/templates/domains/character-design/project.json +17 -0
  46. package/templates/domains/character-design/rubric.json +38 -0
  47. package/templates/domains/character-design/terminology.json +21 -0
  48. package/templates/domains/creature-design/constitution.json +87 -0
  49. package/templates/domains/creature-design/lanes.json +41 -0
  50. package/templates/domains/creature-design/project.json +18 -0
  51. package/templates/domains/creature-design/rubric.json +39 -0
  52. package/templates/domains/creature-design/terminology.json +20 -0
  53. package/templates/domains/game-art/constitution.json +113 -0
  54. package/templates/domains/game-art/lanes.json +47 -0
  55. package/templates/domains/game-art/project.json +17 -0
  56. package/templates/domains/game-art/rubric.json +36 -0
  57. package/templates/domains/game-art/terminology.json +30 -0
  58. package/templates/domains/vehicle-mech/constitution.json +87 -0
  59. package/templates/domains/vehicle-mech/lanes.json +41 -0
  60. package/templates/domains/vehicle-mech/project.json +17 -0
  61. package/templates/domains/vehicle-mech/rubric.json +40 -0
  62. package/templates/domains/vehicle-mech/terminology.json +21 -0
@@ -6,218 +6,96 @@
6
6
  * a verdict (pass/fail/partial) and a one-line rationale derived from
7
7
  * the judgment scores and failure modes.
8
8
  *
9
+ * Loads rules, lanes, rubric, and terminology from per-project config files.
10
+ * Missing config files cause a hard failure — no silent fallback.
11
+ *
9
12
  * Usage:
10
13
  * node scripts/canon-bind.js # bind all records
11
14
  * node scripts/canon-bind.js --dry-run # preview without writing
12
15
  * node scripts/canon-bind.js --stats # print coverage stats
16
+ * sdlab bind --project star-freight --stats
13
17
  */
14
18
 
15
- import { readFileSync, writeFileSync, readdirSync } from 'fs';
19
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
16
20
  import { join } from 'path';
21
+ import { getProjectName } from '../lib/args.js';
22
+ import { REPO_ROOT } from '../lib/paths.js';
23
+ import { loadConstitution, loadLanes, loadRubric, loadTerminology, detectLane, detectGroup } from '../lib/config.js';
17
24
 
18
- const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
19
- const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
20
- const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
21
-
22
- const RECORDS_DIR = join(GAME_ROOT, 'records');
23
- const DRY_RUN = process.argv.includes('--dry-run');
24
- const STATS_ONLY = process.argv.includes('--stats');
25
-
26
- // ─── Constitution Rules ──────────────────────────────────────────────
27
- // Each rule has: id, category, description, scoring_dimensions (which
28
- // judgment scores map to this rule), and faction_specific flag.
29
-
30
- const CONSTITUTION_RULES = [
31
- // Rendering rules (universal)
32
- { id: 'RND-001', category: 'rendering', desc: 'Semi-realistic painterly style',
33
- dims: ['style_consistency'], faction_specific: false },
34
- { id: 'RND-002', category: 'rendering', desc: 'Single upper-left warm lighting with soft AO shadows',
35
- dims: ['composition'], faction_specific: false },
36
- { id: 'RND-003', category: 'rendering', desc: 'Muted dusty color saturation',
37
- dims: ['palette_adherence'], faction_specific: false },
38
- { id: 'RND-004', category: 'rendering', desc: 'Full body front-facing on plain background, 70-85% fill',
39
- dims: ['composition'], faction_specific: false },
40
- { id: 'RND-005', category: 'rendering', desc: 'Readable at 128px, identifiable at 64px',
41
- dims: ['silhouette_clarity'], faction_specific: false },
42
-
43
- // Material rules (faction-specific)
44
- { id: 'MAT-001', category: 'material', desc: 'Every surface reads as faction material vocabulary',
45
- dims: ['material_fidelity', 'faction_read'], faction_specific: true },
46
- { id: 'MAT-002', category: 'material', desc: 'Faction-appropriate aging and wear',
47
- dims: ['wear_level'], faction_specific: true },
48
-
49
- // Shape language (faction-specific)
50
- { id: 'SHP-001', category: 'shape', desc: 'Faction shape language compliance',
51
- dims: ['silhouette_clarity', 'faction_read'], faction_specific: true },
52
-
53
- // Color rules (faction-specific)
54
- { id: 'COL-001', category: 'color', desc: 'Correct faction palette colors',
55
- dims: ['palette_adherence', 'faction_read'], faction_specific: true },
56
- { id: 'COL-002', category: 'color', desc: 'Palette ratio ~60/30/10',
57
- dims: ['palette_adherence'], faction_specific: true },
58
- { id: 'COL-003', category: 'color', desc: 'No bright saturated colors — everything muted/dusty',
59
- dims: ['palette_adherence'], faction_specific: false },
60
-
61
- // Clothing rules (faction-specific — costume subjects only)
62
- { id: 'CLO-001', category: 'clothing', desc: 'Layer logic: base → functional → identity → accessory',
63
- dims: ['clothing_logic'], faction_specific: true, lanes: ['costume'] },
64
- { id: 'CLO-002', category: 'clothing', desc: 'Construction matches faction building method',
65
- dims: ['clothing_logic', 'material_fidelity'], faction_specific: true, lanes: ['costume'] },
66
- { id: 'CLO-003', category: 'clothing', desc: 'Not generic sci-fi (no spandex/Star Trek/Marvel/asset store)',
67
- dims: ['faction_read', 'clothing_logic'], faction_specific: false },
68
-
69
- // Ship rules (ship subjects only)
70
- { id: 'SHP-EXT-001', category: 'ship', desc: 'Faction hull language — construction reads as faction-built',
71
- dims: ['material_fidelity', 'faction_read'], faction_specific: true, lanes: ['ship'] },
72
- { id: 'SHP-EXT-002', category: 'ship', desc: 'Scale and grit — working vehicle not showpiece',
73
- dims: ['wear_level', 'material_fidelity'], faction_specific: false, lanes: ['ship'] },
74
- { id: 'SHP-INT-001', category: 'interior', desc: 'Interior faction character — environmental storytelling',
75
- dims: ['faction_read', 'material_fidelity'], faction_specific: true, lanes: ['interior'] },
76
- { id: 'SHP-INT-002', category: 'interior', desc: 'Lived-in detail — coffee stains, tool marks, wear',
77
- dims: ['wear_level'], faction_specific: false, lanes: ['interior'] },
78
-
79
- // Equipment rules (equipment/prop subjects only)
80
- { id: 'EQP-001', category: 'equipment', desc: 'Faction design language for tools and weapons',
81
- dims: ['material_fidelity', 'faction_read'], faction_specific: true, lanes: ['equipment', 'prop'] },
82
- { id: 'EQP-002', category: 'equipment', desc: 'Tool not toy — used, worn, functional',
83
- dims: ['wear_level'], faction_specific: false, lanes: ['equipment', 'prop'] },
84
- { id: 'EQP-003', category: 'equipment', desc: 'Props carry faction identity or universal grit',
85
- dims: ['faction_read', 'wear_level'], faction_specific: false, lanes: ['prop'] },
86
-
87
- // Environment rules (environment subjects only)
88
- { id: 'ENV-001', category: 'environment', desc: 'Atmosphere over beauty — industrial, tense, functional',
89
- dims: ['style_consistency', 'wear_level'], faction_specific: false, lanes: ['environment', 'station'] },
90
- { id: 'ENV-002', category: 'environment', desc: 'Faction footprint in architecture and signage',
91
- dims: ['faction_read', 'material_fidelity'], faction_specific: true, lanes: ['environment', 'station'] },
92
- { id: 'ENV-003', category: 'environment', desc: 'Lighting carries mood — faction-specific lighting character',
93
- dims: ['palette_adherence'], faction_specific: true, lanes: ['environment', 'station', 'interior'] },
94
- ];
95
-
96
- // ─── Lane Detection ──────────────────────────────────────────────────
97
- // Determines which subject category an asset belongs to, so lane-specific
98
- // rules (CLO for costumes, SHP for ships, etc.) are applied correctly.
99
-
100
- function detectLane(id, prompt) {
101
- const lower = id.toLowerCase();
102
- // Ship exterior prefixes
103
- if (/^ship_ext_|^se_|^ship_/.test(lower)) return 'ship';
104
- // Ship interior prefixes
105
- if (/^ship_int_|^si_|^int_|^interior_/.test(lower)) return 'interior';
106
- // Station/port prefixes
107
- if (/^station_|^port_|^stn_/.test(lower)) return 'station';
108
- // Equipment/weapon prefixes
109
- if (/^eqp_|^wpn_|^weapon_|^tool_|^equip_/.test(lower)) return 'equipment';
110
- // Prop prefixes
111
- if (/^prop_|^food_|^sign_|^furn_/.test(lower)) return 'prop';
112
- // Environment prefixes
113
- if (/^env_|^planet_|^landing_|^mine_/.test(lower)) return 'environment';
114
- // Default: costume (all existing assets are costumes)
115
- return 'costume';
116
- }
25
+ /**
26
+ * Build rationale string from rule's template, interpolating variables.
27
+ */
28
+ function buildRationale(rule, verdict, score, groupName, termConfig) {
29
+ const scorePct = Math.round(score * 100);
30
+ const group = groupName ? termConfig.groups[groupName] : null;
31
+ const groupMaterial = group?.material || 'faction material';
32
+ const groupShapes = group?.shapes || 'faction shapes';
33
+ const groupWear = group?.wear || 'faction-correct aging';
117
34
 
118
- // ─── Faction Detection ───────────────────────────────────────────────
119
-
120
- const FACTION_MATERIAL = {
121
- compact: 'Pressed (machine-stamped, regulation-issued)',
122
- keth: 'Grown (cultivated, secreted, bio-film)',
123
- veshan: 'Forged (hammered, riveted, layered)',
124
- orryn: 'Woven (modular, interlocking, reconfigurable)',
125
- reach: 'Stripped (torn, welded from scrap, jury-rigged)',
126
- };
127
-
128
- const FACTION_SHAPES = {
129
- compact: 'Rectangles, straight edges, 90° corners',
130
- keth: 'Segments, arcs, compound curves',
131
- veshan: 'Triangles, chevrons, sharp angles',
132
- orryn: 'Ellipses, flowing curves, translucent layers',
133
- reach: 'Asymmetric, irregular, mismatched',
134
- };
135
-
136
- const FACTION_WEAR = {
137
- compact: 'Maintained but impersonal — kept to standard',
138
- keth: 'Alive — aging means growing, not decaying',
139
- veshan: 'Battle-scarred with pride — damage displayed',
140
- orryn: 'Adaptive — reconfigured, not worn',
141
- reach: 'Jury-rigged — functional ugly',
142
- };
143
-
144
- function detectFaction(id, prompt) {
145
- // ID-based detection (most reliable)
146
- if (/^(anchor_|c_|c2_|c6_|c10_)/.test(id)) return 'compact';
147
- if (/^(v_|v3_|v7_|v10_)/.test(id)) return 'veshan';
148
- if (/^(sr_|sr4_|sr8_|sr10_)/.test(id)) return 'reach';
149
- if (/^(k_|k\d+_)/.test(id)) return 'keth';
150
- if (/^(o_|o\d+_)/.test(id)) return 'orryn';
151
-
152
- // Cross-faction: detect from ID suffix
153
- if (/^cf_/.test(id)) {
154
- if (/compact/.test(id)) return 'compact';
155
- if (/veshan/.test(id)) return 'veshan';
156
- if (/reach/.test(id)) return 'reach';
157
- if (/keth/.test(id)) return 'keth';
158
- if (/orryn/.test(id)) return 'orryn';
35
+ let template;
36
+ if (verdict === 'pass') {
37
+ template = rule.rationale_pass;
38
+ } else if (verdict === 'fail') {
39
+ template = rule.rationale_fail;
40
+ } else {
41
+ // partial — use generic template
42
+ return `Partially meets ${rule.desc.toLowerCase()} (${scorePct}%)`;
159
43
  }
160
44
 
161
- // Edge cases: detect from prompt
162
- if (/^edge_/.test(id)) return 'compact'; // edge cases are human subjects
163
-
164
- // Reject/borderline: detect from prompt content
165
- if (prompt) {
166
- const p = prompt.toLowerCase();
167
- if (/reptilian|veshan|scales|crest/.test(p)) return 'veshan';
168
- if (/arthropod|keth|chitin|antennae/.test(p)) return 'keth';
169
- if (/cephalopod|orryn|tentacle|chromatophore/.test(p)) return 'orryn';
170
- if (/salvaged|scrap|jury.rigged|outlaw|pirate/.test(p)) return 'reach';
45
+ if (!template) {
46
+ // Fallback for rules without templates
47
+ return verdict === 'pass'
48
+ ? `Passes ${rule.id} (${scorePct}%)`
49
+ : `Fails ${rule.id} (${scorePct}%)`;
171
50
  }
172
51
 
173
- // Reject IDs: try to detect from naming
174
- if (/wrong_material_v/.test(id)) return 'veshan';
175
- if (/generic_scifi|too_clean|too_heroic|star_trek|cyberpunk|fantasy_knight|warhammer|too_sexy|steampunk|fashion_model|pin_up|fortnite|mmorpg|superhero|invisible|xenomorph|zombified|samurai/.test(id)) return null; // no faction — generic reject
52
+ // Interpolate template variables
53
+ return template
54
+ .replace(/\$\{scorePct\}/g, String(scorePct))
55
+ .replace(/\$\{groupMaterial\}/g, groupMaterial)
56
+ .replace(/\$\{groupShapes\}/g, groupShapes)
57
+ .replace(/\$\{groupWear\}/g, groupWear);
58
+ }
176
59
 
177
- if (/veshan|cute_dragon|generic_lizard|veshan_robot|veshan_human/.test(id)) return 'veshan';
178
- if (/reach|pirate|matching_gang|space_cowboy/.test(id)) return 'reach';
179
- if (/keth/.test(id)) return 'keth';
60
+ /**
61
+ * Get faction context string for a rule from terminology config.
62
+ */
63
+ function getGroupContext(rule, groupName, termConfig) {
64
+ // Check rule-specific overrides in terminology
65
+ const contextOverride = termConfig.faction_context?.[rule.id];
66
+ if (contextOverride) return contextOverride;
180
67
 
181
- // Borderline
182
- if (/almost_right_compact/.test(id)) return 'compact';
183
- if (/almost_right_veshan/.test(id)) return 'veshan';
184
- if (/almost_right_reach/.test(id)) return 'reach';
68
+ // Check group-specific construction context
69
+ const group = groupName ? termConfig.groups[groupName] : null;
70
+ if (group?.construction?.[rule.id]) return group.construction[rule.id];
185
71
 
186
- return null; // unknown faction
72
+ // Fallback by rule category
73
+ switch (rule.id) {
74
+ case 'MAT-001': return group?.material || null;
75
+ case 'MAT-002': return group?.wear || null;
76
+ case 'SHP-001': return group?.shapes || null;
77
+ default: return null;
78
+ }
187
79
  }
188
80
 
189
- // ─── Failure Mode → Rule Mapping ─────────────────────────────────────
190
-
191
- const FAILURE_TO_RULES = {
192
- generic_scifi: ['CLO-003'],
193
- too_clean: ['MAT-002', 'COL-003'],
194
- wrong_material: ['MAT-001'],
195
- wrong_shapes: ['SHP-001'],
196
- wrong_palette: ['COL-001', 'COL-002', 'COL-003'],
197
- photorealistic: ['RND-001'],
198
- '3d_render': ['RND-001'],
199
- bad_composition: ['RND-004'],
200
- costume_not_clothes: ['CLO-001', 'CLO-002'],
201
- faction_unreadable: ['MAT-001', 'SHP-001', 'COL-001'],
202
- anime_style: ['RND-001'],
203
- hero_pose: ['RND-004'],
204
- };
205
-
206
81
  // ─── Assertion Generation ────────────────────────────────────────────
207
82
 
208
- function generateAssertions(record) {
83
+ function generateAssertions(record, constitutionRules, lanesConfig, rubricConfig, termConfig) {
209
84
  const { id, judgment, provenance } = record;
210
85
  if (!judgment || !judgment.criteria_scores) return null;
211
86
 
212
87
  const { status, criteria_scores, failure_modes = [], explanation = '' } = judgment;
213
88
  const prompt = provenance?.prompt || '';
214
- const faction = detectFaction(id, prompt);
215
- const lane = detectLane(id, prompt);
89
+ const group = detectGroup(id, prompt, termConfig);
90
+ const lane = detectLane(id, prompt, lanesConfig);
216
91
  const assertions = [];
217
92
 
218
- for (const rule of CONSTITUTION_RULES) {
219
- // Skip faction-specific rules for unknown-faction rejects
220
- if (rule.faction_specific && !faction) continue;
93
+ const failureToRules = rubricConfig.failure_to_rules || {};
94
+ const thresholds = rubricConfig.thresholds || {};
95
+
96
+ for (const rule of constitutionRules) {
97
+ // Skip group-specific rules for unknown-group rejects
98
+ if (rule.faction_specific && !group) continue;
221
99
 
222
100
  // Skip lane-specific rules that don't apply to this asset's lane
223
101
  if (rule.lanes && !rule.lanes.includes(lane)) continue;
@@ -231,31 +109,33 @@ function generateAssertions(record) {
231
109
 
232
110
  // Check if any failure mode maps to this rule
233
111
  const failedByMode = failure_modes.some(fm =>
234
- (FAILURE_TO_RULES[fm] || []).includes(rule.id)
112
+ (failureToRules[fm] || []).includes(rule.id)
235
113
  );
236
114
 
237
- // Determine verdict
115
+ // Determine verdict using rubric thresholds
238
116
  let verdict;
239
117
  if (status === 'rejected') {
240
- if (failedByMode || avgScore < 0.4) {
118
+ const t = thresholds.rejected || { fail_ceiling: 0.4, partial_ceiling: 0.6 };
119
+ if (failedByMode || avgScore < t.fail_ceiling) {
241
120
  verdict = 'fail';
242
- } else if (avgScore < 0.6) {
121
+ } else if (avgScore < t.partial_ceiling) {
243
122
  verdict = 'partial';
244
123
  } else {
245
124
  verdict = 'pass'; // rejected asset can still pass some rules
246
125
  }
247
126
  } else if (status === 'borderline') {
248
- if (avgScore >= 0.7) verdict = 'pass';
249
- else if (avgScore >= 0.5) verdict = 'partial';
127
+ const t = thresholds.borderline || { pass: 0.7, partial: 0.5 };
128
+ if (avgScore >= t.pass) verdict = 'pass';
129
+ else if (avgScore >= t.partial) verdict = 'partial';
250
130
  else verdict = 'fail';
251
131
  } else { // approved
252
- if (avgScore >= 0.7) verdict = 'pass';
253
- else if (avgScore >= 0.5) verdict = 'partial';
132
+ const t = thresholds.approved || { pass: 0.7, partial: 0.5 };
133
+ if (avgScore >= t.pass) verdict = 'pass';
134
+ else if (avgScore >= t.partial) verdict = 'partial';
254
135
  else verdict = 'fail'; // unlikely for approved
255
136
  }
256
137
 
257
- // Build rationale
258
- let rationale = buildRationale(rule, verdict, avgScore, faction, explanation);
138
+ const rationale = buildRationale(rule, verdict, avgScore, group, termConfig);
259
139
 
260
140
  assertions.push({
261
141
  rule_id: rule.id,
@@ -263,175 +143,145 @@ function generateAssertions(record) {
263
143
  verdict,
264
144
  score: Math.round(avgScore * 100) / 100,
265
145
  rationale,
266
- ...(faction && rule.faction_specific ? { faction, faction_context: getFactionContext(rule, faction) } : {}),
146
+ ...(group && rule.faction_specific ? { faction: group, faction_context: getGroupContext(rule, group, termConfig) } : {}),
267
147
  });
268
148
  }
269
149
 
270
150
  return assertions;
271
151
  }
272
152
 
273
- function buildRationale(rule, verdict, score, faction, explanation) {
274
- const scorePct = Math.round(score * 100);
153
+ // ─── Main ────────────────────────────────────────────────────────────
275
154
 
276
- if (verdict === 'pass') {
277
- switch (rule.id) {
278
- case 'RND-001': return `Painterly rendering achieved (${scorePct}%)`;
279
- case 'RND-002': return `Lighting reads correctly — warm upper-left with soft shadows`;
280
- case 'RND-003': return `Palette is muted and dusty — no bright saturated colors`;
281
- case 'RND-004': return `Full body, front-facing, plain background, correct fill`;
282
- case 'RND-005': return `Silhouette readable at gameplay scale (${scorePct}%)`;
283
- case 'MAT-001': return `Surfaces read as ${faction ? FACTION_MATERIAL[faction] : 'faction material'} (${scorePct}%)`;
284
- case 'MAT-002': return `Wear level appropriate — ${faction ? FACTION_WEAR[faction] : 'faction-correct aging'}`;
285
- case 'SHP-001': return `Shape language compliant — ${faction ? FACTION_SHAPES[faction] : 'faction shapes'}`;
286
- case 'COL-001': return `Faction palette present and dominant (${scorePct}%)`;
287
- case 'COL-002': return `Color ratios read as ~60/30/10`;
288
- case 'COL-003': return `Colors muted and lived-in — no neon or bright saturated tones`;
289
- case 'CLO-001': return `Layer logic follows base → functional → identity → accessory`;
290
- case 'CLO-002': return `Construction matches ${faction ? 'faction' : ''} building method`;
291
- case 'CLO-003': return `Not generic sci-fi — reads as faction-specific and lived-in`;
292
- default: return `Passes ${rule.id} (${scorePct}%)`;
293
- }
155
+ export async function run(argv = process.argv.slice(2)) {
156
+ const projectName = getProjectName(argv);
157
+ const PROJECT_ROOT = join(REPO_ROOT, 'projects', projectName);
158
+ const RECORDS_DIR = join(PROJECT_ROOT, 'records');
159
+ const DRY_RUN = argv.includes('--dry-run');
160
+ const STATS_ONLY = argv.includes('--stats');
161
+ const FORCE = argv.includes('--force');
162
+
163
+ // Load project config — fail loudly if missing
164
+ if (!existsSync(join(PROJECT_ROOT, 'constitution.json'))) {
165
+ throw new Error(
166
+ `No constitution.json found in ${PROJECT_ROOT}.\n` +
167
+ `Canon binding requires per-project config files.\n` +
168
+ `Run: sdlab init ${projectName} --domain <domain>`
169
+ );
294
170
  }
295
171
 
296
- if (verdict === 'fail') {
297
- switch (rule.id) {
298
- case 'RND-001': return `Not painterly — reads as photorealistic/3D/anime (${scorePct}%)`;
299
- case 'RND-002': return `Lighting incorrect or flat`;
300
- case 'RND-003': return `Colors too bright or saturated — not muted/dusty`;
301
- case 'RND-004': return `Composition fails — not full body/centered/plain bg (${scorePct}%)`;
302
- case 'RND-005': return `Silhouette unreadable at gameplay scale (${scorePct}%)`;
303
- case 'MAT-001': return `Surfaces don't read as ${faction ? FACTION_MATERIAL[faction] : 'any faction material'} (${scorePct}%)`;
304
- case 'MAT-002': return `Wear level wrong — ${faction ? 'doesn\'t match ' + FACTION_WEAR[faction] : 'inappropriate aging'}`;
305
- case 'SHP-001': return `Shape language violates faction — wrong geometry (${scorePct}%)`;
306
- case 'COL-001': return `Faction palette missing or wrong colors (${scorePct}%)`;
307
- case 'COL-002': return `Color ratios off — primary doesn't dominate`;
308
- case 'COL-003': return `Bright saturated colors present — violates muted/dusty rule`;
309
- case 'CLO-001': return `Layer logic broken — layers don't follow faction system`;
310
- case 'CLO-002': return `Construction doesn't match faction building method (${scorePct}%)`;
311
- case 'CLO-003': return `Reads as generic sci-fi — not faction-specific (${scorePct}%)`;
312
- default: return `Fails ${rule.id} (${scorePct}%)`;
313
- }
172
+ const constitutionConfig = loadConstitution(PROJECT_ROOT);
173
+ const lanesConfig = loadLanes(PROJECT_ROOT);
174
+ const rubricConfig = loadRubric(PROJECT_ROOT);
175
+ const termConfig = loadTerminology(PROJECT_ROOT);
176
+
177
+ if (!constitutionConfig.rules || constitutionConfig.rules.length === 0) {
178
+ throw new Error(`constitution.json has no rules — cannot bind.`);
314
179
  }
315
180
 
316
- // partial
317
- return `Partially meets ${rule.desc.toLowerCase()} (${scorePct}%)`;
318
- }
181
+ const constitutionRules = constitutionConfig.rules;
182
+ const constitutionVersion = constitutionConfig.version || '1.0.0';
319
183
 
320
- function getFactionContext(rule, faction) {
321
- switch (rule.id) {
322
- case 'MAT-001': return FACTION_MATERIAL[faction] || null;
323
- case 'MAT-002': return FACTION_WEAR[faction] || null;
324
- case 'SHP-001': return FACTION_SHAPES[faction] || null;
325
- case 'COL-001':
326
- case 'COL-002': return `Faction primary should dominate at ~60%`;
327
- case 'CLO-001': return `base → functional → identity → accessory`;
328
- case 'CLO-002':
329
- switch (faction) {
330
- case 'compact': return 'Machine-made, identical cuts, zero personalization';
331
- case 'keth': return 'Grown on wearer or harvested from communal bio-stocks';
332
- case 'veshan': return 'Smith-forged, imperfections intentional';
333
- case 'orryn': return 'Modular panels that clip together magnetically';
334
- case 'reach': return 'Assembled from salvage, nothing matches';
335
- default: return null;
336
- }
337
- default: return null;
338
- }
339
- }
184
+ const files = readdirSync(RECORDS_DIR).filter(f => f.endsWith('.json'));
185
+ let bound = 0, skipped = 0, alreadyBound = 0;
186
+ const groupCounts = {};
187
+ const verdictCounts = { pass: 0, fail: 0, partial: 0 };
340
188
 
341
- // ─── Main ────────────────────────────────────────────────────────────
189
+ for (const file of files) {
190
+ const path = join(RECORDS_DIR, file);
191
+ const record = JSON.parse(readFileSync(path, 'utf-8'));
342
192
 
343
- const files = readdirSync(RECORDS_DIR).filter(f => f.endsWith('.json'));
344
- let bound = 0, skipped = 0, alreadyBound = 0;
345
- const factionCounts = {};
346
- const verdictCounts = { pass: 0, fail: 0, partial: 0 };
193
+ // Skip records without judgment
194
+ if (!record.judgment || !record.judgment.criteria_scores) {
195
+ skipped++;
196
+ continue;
197
+ }
347
198
 
348
- for (const file of files) {
349
- const path = join(RECORDS_DIR, file);
350
- const record = JSON.parse(readFileSync(path, 'utf-8'));
199
+ // Check if already bound
200
+ if (record.canon?.assertions?.length > 0 && !FORCE) {
201
+ alreadyBound++;
202
+ continue;
203
+ }
351
204
 
352
- // Skip records without judgment
353
- if (!record.judgment || !record.judgment.criteria_scores) {
354
- skipped++;
355
- continue;
356
- }
205
+ const assertions = generateAssertions(record, constitutionRules, lanesConfig, rubricConfig, termConfig);
206
+ if (!assertions || assertions.length === 0) {
207
+ skipped++;
208
+ continue;
209
+ }
357
210
 
358
- // Check if already bound
359
- if (record.canon?.assertions?.length > 0 && !process.argv.includes('--force')) {
360
- alreadyBound++;
361
- continue;
362
- }
211
+ // Track stats
212
+ const group = detectGroup(record.id, record.provenance?.prompt, termConfig);
213
+ groupCounts[group || 'unknown'] = (groupCounts[group || 'unknown'] || 0) + 1;
214
+ for (const a of assertions) {
215
+ verdictCounts[a.verdict] = (verdictCounts[a.verdict] || 0) + 1;
216
+ }
363
217
 
364
- const assertions = generateAssertions(record);
365
- if (!assertions || assertions.length === 0) {
366
- skipped++;
367
- continue;
368
- }
218
+ if (STATS_ONLY) {
219
+ bound++;
220
+ continue;
221
+ }
369
222
 
370
- // Track stats
371
- const faction = detectFaction(record.id, record.provenance?.prompt);
372
- factionCounts[faction || 'unknown'] = (factionCounts[faction || 'unknown'] || 0) + 1;
373
- for (const a of assertions) {
374
- verdictCounts[a.verdict] = (verdictCounts[a.verdict] || 0) + 1;
375
- }
223
+ // Build canon object
224
+ record.canon = {
225
+ constitution_version: constitutionVersion,
226
+ bound_at: new Date().toISOString(),
227
+ bound_by: 'canon-bind-v1',
228
+ faction: group || null,
229
+ assertions,
230
+ assertion_count: assertions.length,
231
+ pass_count: assertions.filter(a => a.verdict === 'pass').length,
232
+ fail_count: assertions.filter(a => a.verdict === 'fail').length,
233
+ partial_count: assertions.filter(a => a.verdict === 'partial').length,
234
+ };
235
+
236
+ // Flat canon_assertions for repo-dataset
237
+ record.canon_assertions = assertions;
238
+
239
+ // Canon explanation
240
+ const passCount = assertions.filter(a => a.verdict === 'pass').length;
241
+ const failCount = assertions.filter(a => a.verdict === 'fail').length;
242
+ const status = record.judgment.status;
243
+ if (status === 'approved') {
244
+ record.canon_explanation = `Approved: passes ${passCount}/${assertions.length} constitution rules. ${group ? `Faction: ${group}.` : ''} ${record.judgment.explanation || ''}`.trim();
245
+ } else if (status === 'rejected') {
246
+ const failedRules = assertions.filter(a => a.verdict === 'fail').map(a => a.rule_id).join(', ');
247
+ record.canon_explanation = `Rejected: fails ${failedRules}. ${record.judgment.explanation || ''}`.trim();
248
+ } else {
249
+ record.canon_explanation = `Borderline: ${passCount} pass, ${failCount} fail out of ${assertions.length} rules. ${record.judgment.explanation || ''}`.trim();
250
+ }
376
251
 
377
- if (STATS_ONLY) {
252
+ if (!DRY_RUN) {
253
+ writeFileSync(path, JSON.stringify(record, null, 2) + '\n');
254
+ }
378
255
  bound++;
379
- continue;
380
- }
381
256
 
382
- // Build canon object (nested — our rich format)
383
- record.canon = {
384
- constitution_version: '1.0.0',
385
- bound_at: new Date().toISOString(),
386
- bound_by: 'canon-bind-v1',
387
- faction: faction || null,
388
- assertions,
389
- assertion_count: assertions.length,
390
- pass_count: assertions.filter(a => a.verdict === 'pass').length,
391
- fail_count: assertions.filter(a => a.verdict === 'fail').length,
392
- partial_count: assertions.filter(a => a.verdict === 'partial').length,
393
- };
394
-
395
- // Also write flat canon_assertions (repo-dataset expects this at top level)
396
- record.canon_assertions = assertions;
397
-
398
- // Write canon_explanation for triangle completion
399
- const passCount = assertions.filter(a => a.verdict === 'pass').length;
400
- const failCount = assertions.filter(a => a.verdict === 'fail').length;
401
- const status = record.judgment.status;
402
- if (status === 'approved') {
403
- record.canon_explanation = `Approved: passes ${passCount}/${assertions.length} constitution rules. ${faction ? `Faction: ${faction}.` : ''} ${record.judgment.explanation || ''}`.trim();
404
- } else if (status === 'rejected') {
405
- const failedRules = assertions.filter(a => a.verdict === 'fail').map(a => a.rule_id).join(', ');
406
- record.canon_explanation = `Rejected: fails ${failedRules}. ${record.judgment.explanation || ''}`.trim();
407
- } else {
408
- record.canon_explanation = `Borderline: ${passCount} pass, ${failCount} fail out of ${assertions.length} rules. ${record.judgment.explanation || ''}`.trim();
257
+ if (DRY_RUN && bound <= 3) {
258
+ console.log(`\n─── ${record.id} (${record.judgment.status}) ───`);
259
+ console.log(`Faction: ${group || 'unknown'}`);
260
+ for (const a of assertions) {
261
+ console.log(` ${a.verdict.toUpperCase().padEnd(7)} ${a.rule_id} — ${a.rationale}`);
262
+ }
263
+ }
409
264
  }
410
265
 
411
- if (!DRY_RUN) {
412
- writeFileSync(path, JSON.stringify(record, null, 2) + '\n');
266
+ console.log(`\n═══ Canon Binding ${DRY_RUN ? '(DRY RUN) ' : STATS_ONLY ? '(STATS) ' : ''}Summary ═══`);
267
+ console.log(`Records: ${files.length}`);
268
+ console.log(`Bound: ${bound}`);
269
+ console.log(`Already bound: ${alreadyBound}`);
270
+ console.log(`Skipped (no judgment): ${skipped}`);
271
+ console.log(`\nFaction distribution:`);
272
+ for (const [f, c] of Object.entries(groupCounts).sort((a, b) => b[1] - a[1])) {
273
+ console.log(` ${f}: ${c}`);
413
274
  }
414
- bound++;
415
-
416
- if (DRY_RUN && bound <= 3) {
417
- console.log(`\n─── ${record.id} (${record.judgment.status}) ───`);
418
- console.log(`Faction: ${faction || 'unknown'}`);
419
- for (const a of assertions) {
420
- console.log(` ${a.verdict.toUpperCase().padEnd(7)} ${a.rule_id} — ${a.rationale}`);
421
- }
275
+ console.log(`\nVerdict totals (across all assertions):`);
276
+ for (const [v, c] of Object.entries(verdictCounts)) {
277
+ console.log(` ${v}: ${c}`);
422
278
  }
423
279
  }
424
280
 
425
- console.log(`\n═══ Canon Binding ${DRY_RUN ? '(DRY RUN) ' : STATS_ONLY ? '(STATS) ' : ''}Summary ═══`);
426
- console.log(`Records: ${files.length}`);
427
- console.log(`Bound: ${bound}`);
428
- console.log(`Already bound: ${alreadyBound}`);
429
- console.log(`Skipped (no judgment): ${skipped}`);
430
- console.log(`\nFaction distribution:`);
431
- for (const [f, c] of Object.entries(factionCounts).sort((a, b) => b[1] - a[1])) {
432
- console.log(` ${f}: ${c}`);
433
- }
434
- console.log(`\nVerdict totals (across all assertions):`);
435
- for (const [v, c] of Object.entries(verdictCounts)) {
436
- console.log(` ${v}: ${c}`);
281
+ // Direct execution guard
282
+ if (process.argv[1] && (process.argv[1].endsWith('canon-bind.js') || process.argv[1].endsWith('canon-bind'))) {
283
+ run().catch((err) => {
284
+ console.error(err.message || err);
285
+ process.exit(1);
286
+ });
437
287
  }