@mcptoolshop/style-dataset-lab 1.2.0 → 2.0.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 (57) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.es.md +94 -54
  3. package/README.fr.md +86 -46
  4. package/README.hi.md +86 -46
  5. package/README.it.md +93 -53
  6. package/README.ja.md +86 -46
  7. package/README.md +95 -116
  8. package/README.pt-BR.md +86 -46
  9. package/README.zh.md +90 -50
  10. package/bin/sdlab.js +103 -0
  11. package/lib/args.js +106 -0
  12. package/lib/comfyui.js +109 -0
  13. package/lib/config.js +104 -0
  14. package/lib/deprecation.js +14 -0
  15. package/lib/paths.js +38 -0
  16. package/lib/records.js +69 -0
  17. package/package.json +13 -4
  18. package/scripts/bulk-curate-wave2-5.js +1 -1
  19. package/scripts/bulk-curate-waves11-18.js +1 -1
  20. package/scripts/canon-bind.js +268 -332
  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 +321 -0
  25. package/scripts/generate-controlnet.js +36 -121
  26. package/scripts/generate-identity.js +38 -106
  27. package/scripts/generate-ipadapter.js +42 -119
  28. package/scripts/generate.js +22 -81
  29. package/scripts/init.js +226 -0
  30. package/scripts/migrate-records.js +15 -21
  31. package/scripts/painterly-test.js +20 -47
  32. package/scripts/painterly.js +27 -106
  33. package/templates/domains/architecture/constitution.json +97 -0
  34. package/templates/domains/architecture/lanes.json +41 -0
  35. package/templates/domains/architecture/project.json +20 -0
  36. package/templates/domains/architecture/rubric.json +40 -0
  37. package/templates/domains/architecture/terminology.json +19 -0
  38. package/templates/domains/character-design/constitution.json +88 -0
  39. package/templates/domains/character-design/lanes.json +35 -0
  40. package/templates/domains/character-design/project.json +17 -0
  41. package/templates/domains/character-design/rubric.json +38 -0
  42. package/templates/domains/character-design/terminology.json +21 -0
  43. package/templates/domains/creature-design/constitution.json +87 -0
  44. package/templates/domains/creature-design/lanes.json +41 -0
  45. package/templates/domains/creature-design/project.json +18 -0
  46. package/templates/domains/creature-design/rubric.json +39 -0
  47. package/templates/domains/creature-design/terminology.json +20 -0
  48. package/templates/domains/game-art/constitution.json +113 -0
  49. package/templates/domains/game-art/lanes.json +47 -0
  50. package/templates/domains/game-art/project.json +17 -0
  51. package/templates/domains/game-art/rubric.json +36 -0
  52. package/templates/domains/game-art/terminology.json +30 -0
  53. package/templates/domains/vehicle-mech/constitution.json +87 -0
  54. package/templates/domains/vehicle-mech/lanes.json +41 -0
  55. package/templates/domains/vehicle-mech/project.json +17 -0
  56. package/templates/domains/vehicle-mech/rubric.json +40 -0
  57. package/templates/domains/vehicle-mech/terminology.json +21 -0
@@ -6,218 +6,182 @@
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 } from '../lib/config.js';
24
+
25
+ // ─── Config-Driven Detection ────────────────────────────────────────
17
26
 
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) {
27
+ /**
28
+ * Detect lane from asset ID using project lane config.
29
+ * Tests each lane's id_patterns as regex against the lowercase ID.
30
+ * Returns the first match, or the default_lane.
31
+ */
32
+ function detectLane(id, prompt, lanesConfig) {
101
33
  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';
34
+ for (const lane of lanesConfig.lanes) {
35
+ if (!lane.id_patterns || lane.id_patterns.length === 0) continue;
36
+ for (const pattern of lane.id_patterns) {
37
+ if (new RegExp(pattern).test(lower)) return lane.id;
38
+ }
39
+ }
40
+ return lanesConfig.default_lane;
116
41
  }
117
42
 
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';
43
+ /**
44
+ * Detect group (faction) from asset ID and prompt using terminology config.
45
+ * Mirrors the exact detection order from the original hardcoded detectFaction():
46
+ * 1. Primary ID patterns per group
47
+ * 2. Cross-group patterns (e.g. ^cf_ → detect from ID suffix)
48
+ * 3. Edge defaults (e.g. ^edge_ → compact)
49
+ * 4. Prompt-based fallback patterns
50
+ * 5. Null-faction patterns (generic rejects with no faction)
51
+ * 6. ID fallback patterns (specific reject/borderline IDs)
52
+ */
53
+ function detectGroup(id, prompt, termConfig) {
54
+ // Explicit ordering for ID-based and prompt-based detection phases.
55
+ // These can differ — e.g. ID checks reach before keth, but prompt
56
+ // checks veshan before keth before orryn before reach.
57
+ const idOrder = termConfig.id_detection_order || Object.keys(termConfig.groups);
58
+ const promptOrder = termConfig.prompt_detection_order || Object.keys(termConfig.groups);
59
+
60
+ // 1. Primary ID patterns per group (id_detection_order)
61
+ for (const groupName of idOrder) {
62
+ const group = termConfig.groups[groupName];
63
+ if (!group?.id_patterns) continue;
64
+ for (const pattern of group.id_patterns) {
65
+ if (new RegExp(pattern).test(id)) return groupName;
66
+ }
67
+ }
68
+
69
+ // 2. Cross-group patterns
70
+ for (const [pattern, behavior] of Object.entries(termConfig.cross_group_patterns || {})) {
71
+ if (new RegExp(pattern).test(id)) {
72
+ if (behavior === 'detect_from_id_suffix') {
73
+ for (const groupName of idOrder) {
74
+ if (id.includes(groupName)) return groupName;
75
+ }
76
+ }
77
+ return null;
78
+ }
159
79
  }
160
80
 
161
- // Edge cases: detect from prompt
162
- if (/^edge_/.test(id)) return 'compact'; // edge cases are human subjects
81
+ // 3. Edge defaults
82
+ for (const [pattern, groupName] of Object.entries(termConfig.edge_defaults || {})) {
83
+ if (new RegExp(pattern).test(id)) return groupName;
84
+ }
163
85
 
164
- // Reject/borderline: detect from prompt content
86
+ // 4. Prompt-based fallback (prompt_detection_order)
165
87
  if (prompt) {
166
88
  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';
89
+ for (const groupName of promptOrder) {
90
+ const group = termConfig.groups[groupName];
91
+ if (!group?.prompt_patterns || group.prompt_patterns.length === 0) continue;
92
+ for (const keyword of group.prompt_patterns) {
93
+ if (new RegExp(keyword).test(p)) return groupName;
94
+ }
95
+ }
96
+ }
97
+
98
+ // 5. Null-faction patterns (generic rejects — no faction)
99
+ for (const pattern of termConfig.null_faction_patterns || []) {
100
+ if (new RegExp(pattern).test(id)) return null;
171
101
  }
172
102
 
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
103
+ // 6. ID fallback patterns
104
+ for (const [pattern, groupName] of Object.entries(termConfig.id_fallbacks || {})) {
105
+ if (new RegExp(pattern).test(id)) return groupName;
106
+ }
107
+
108
+ return null;
109
+ }
176
110
 
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';
111
+ /**
112
+ * Build rationale string from rule's template, interpolating variables.
113
+ */
114
+ function buildRationale(rule, verdict, score, groupName, termConfig) {
115
+ const scorePct = Math.round(score * 100);
116
+ const group = groupName ? termConfig.groups[groupName] : null;
117
+ const groupMaterial = group?.material || 'faction material';
118
+ const groupShapes = group?.shapes || 'faction shapes';
119
+ const groupWear = group?.wear || 'faction-correct aging';
120
+
121
+ let template;
122
+ if (verdict === 'pass') {
123
+ template = rule.rationale_pass;
124
+ } else if (verdict === 'fail') {
125
+ template = rule.rationale_fail;
126
+ } else {
127
+ // partial — use generic template
128
+ return `Partially meets ${rule.desc.toLowerCase()} (${scorePct}%)`;
129
+ }
180
130
 
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';
131
+ if (!template) {
132
+ // Fallback for rules without templates
133
+ return verdict === 'pass'
134
+ ? `Passes ${rule.id} (${scorePct}%)`
135
+ : `Fails ${rule.id} (${scorePct}%)`;
136
+ }
185
137
 
186
- return null; // unknown faction
138
+ // Interpolate template variables
139
+ return template
140
+ .replace(/\$\{scorePct\}/g, String(scorePct))
141
+ .replace(/\$\{groupMaterial\}/g, groupMaterial)
142
+ .replace(/\$\{groupShapes\}/g, groupShapes)
143
+ .replace(/\$\{groupWear\}/g, groupWear);
187
144
  }
188
145
 
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
- };
146
+ /**
147
+ * Get faction context string for a rule from terminology config.
148
+ */
149
+ function getGroupContext(rule, groupName, termConfig) {
150
+ // Check rule-specific overrides in terminology
151
+ const contextOverride = termConfig.faction_context?.[rule.id];
152
+ if (contextOverride) return contextOverride;
153
+
154
+ // Check group-specific construction context
155
+ const group = groupName ? termConfig.groups[groupName] : null;
156
+ if (group?.construction?.[rule.id]) return group.construction[rule.id];
157
+
158
+ // Fallback by rule category
159
+ switch (rule.id) {
160
+ case 'MAT-001': return group?.material || null;
161
+ case 'MAT-002': return group?.wear || null;
162
+ case 'SHP-001': return group?.shapes || null;
163
+ default: return null;
164
+ }
165
+ }
205
166
 
206
167
  // ─── Assertion Generation ────────────────────────────────────────────
207
168
 
208
- function generateAssertions(record) {
169
+ function generateAssertions(record, constitutionRules, lanesConfig, rubricConfig, termConfig) {
209
170
  const { id, judgment, provenance } = record;
210
171
  if (!judgment || !judgment.criteria_scores) return null;
211
172
 
212
173
  const { status, criteria_scores, failure_modes = [], explanation = '' } = judgment;
213
174
  const prompt = provenance?.prompt || '';
214
- const faction = detectFaction(id, prompt);
215
- const lane = detectLane(id, prompt);
175
+ const group = detectGroup(id, prompt, termConfig);
176
+ const lane = detectLane(id, prompt, lanesConfig);
216
177
  const assertions = [];
217
178
 
218
- for (const rule of CONSTITUTION_RULES) {
219
- // Skip faction-specific rules for unknown-faction rejects
220
- if (rule.faction_specific && !faction) continue;
179
+ const failureToRules = rubricConfig.failure_to_rules || {};
180
+ const thresholds = rubricConfig.thresholds || {};
181
+
182
+ for (const rule of constitutionRules) {
183
+ // Skip group-specific rules for unknown-group rejects
184
+ if (rule.faction_specific && !group) continue;
221
185
 
222
186
  // Skip lane-specific rules that don't apply to this asset's lane
223
187
  if (rule.lanes && !rule.lanes.includes(lane)) continue;
@@ -231,31 +195,33 @@ function generateAssertions(record) {
231
195
 
232
196
  // Check if any failure mode maps to this rule
233
197
  const failedByMode = failure_modes.some(fm =>
234
- (FAILURE_TO_RULES[fm] || []).includes(rule.id)
198
+ (failureToRules[fm] || []).includes(rule.id)
235
199
  );
236
200
 
237
- // Determine verdict
201
+ // Determine verdict using rubric thresholds
238
202
  let verdict;
239
203
  if (status === 'rejected') {
240
- if (failedByMode || avgScore < 0.4) {
204
+ const t = thresholds.rejected || { fail_ceiling: 0.4, partial_ceiling: 0.6 };
205
+ if (failedByMode || avgScore < t.fail_ceiling) {
241
206
  verdict = 'fail';
242
- } else if (avgScore < 0.6) {
207
+ } else if (avgScore < t.partial_ceiling) {
243
208
  verdict = 'partial';
244
209
  } else {
245
210
  verdict = 'pass'; // rejected asset can still pass some rules
246
211
  }
247
212
  } else if (status === 'borderline') {
248
- if (avgScore >= 0.7) verdict = 'pass';
249
- else if (avgScore >= 0.5) verdict = 'partial';
213
+ const t = thresholds.borderline || { pass: 0.7, partial: 0.5 };
214
+ if (avgScore >= t.pass) verdict = 'pass';
215
+ else if (avgScore >= t.partial) verdict = 'partial';
250
216
  else verdict = 'fail';
251
217
  } else { // approved
252
- if (avgScore >= 0.7) verdict = 'pass';
253
- else if (avgScore >= 0.5) verdict = 'partial';
218
+ const t = thresholds.approved || { pass: 0.7, partial: 0.5 };
219
+ if (avgScore >= t.pass) verdict = 'pass';
220
+ else if (avgScore >= t.partial) verdict = 'partial';
254
221
  else verdict = 'fail'; // unlikely for approved
255
222
  }
256
223
 
257
- // Build rationale
258
- let rationale = buildRationale(rule, verdict, avgScore, faction, explanation);
224
+ const rationale = buildRationale(rule, verdict, avgScore, group, termConfig);
259
225
 
260
226
  assertions.push({
261
227
  rule_id: rule.id,
@@ -263,175 +229,145 @@ function generateAssertions(record) {
263
229
  verdict,
264
230
  score: Math.round(avgScore * 100) / 100,
265
231
  rationale,
266
- ...(faction && rule.faction_specific ? { faction, faction_context: getFactionContext(rule, faction) } : {}),
232
+ ...(group && rule.faction_specific ? { faction: group, faction_context: getGroupContext(rule, group, termConfig) } : {}),
267
233
  });
268
234
  }
269
235
 
270
236
  return assertions;
271
237
  }
272
238
 
273
- function buildRationale(rule, verdict, score, faction, explanation) {
274
- const scorePct = Math.round(score * 100);
239
+ // ─── Main ────────────────────────────────────────────────────────────
275
240
 
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
- }
241
+ export async function run(argv = process.argv.slice(2)) {
242
+ const projectName = getProjectName(argv);
243
+ const PROJECT_ROOT = join(REPO_ROOT, 'projects', projectName);
244
+ const RECORDS_DIR = join(PROJECT_ROOT, 'records');
245
+ const DRY_RUN = argv.includes('--dry-run');
246
+ const STATS_ONLY = argv.includes('--stats');
247
+ const FORCE = argv.includes('--force');
248
+
249
+ // Load project config — fail loudly if missing
250
+ if (!existsSync(join(PROJECT_ROOT, 'constitution.json'))) {
251
+ throw new Error(
252
+ `No constitution.json found in ${PROJECT_ROOT}.\n` +
253
+ `Canon binding requires per-project config files.\n` +
254
+ `Run: sdlab init ${projectName} --domain <domain>`
255
+ );
294
256
  }
295
257
 
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
- }
258
+ const constitutionConfig = loadConstitution(PROJECT_ROOT);
259
+ const lanesConfig = loadLanes(PROJECT_ROOT);
260
+ const rubricConfig = loadRubric(PROJECT_ROOT);
261
+ const termConfig = loadTerminology(PROJECT_ROOT);
262
+
263
+ if (!constitutionConfig.rules || constitutionConfig.rules.length === 0) {
264
+ throw new Error(`constitution.json has no rules — cannot bind.`);
314
265
  }
315
266
 
316
- // partial
317
- return `Partially meets ${rule.desc.toLowerCase()} (${scorePct}%)`;
318
- }
267
+ const constitutionRules = constitutionConfig.rules;
268
+ const constitutionVersion = constitutionConfig.version || '1.0.0';
319
269
 
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
- }
270
+ const files = readdirSync(RECORDS_DIR).filter(f => f.endsWith('.json'));
271
+ let bound = 0, skipped = 0, alreadyBound = 0;
272
+ const groupCounts = {};
273
+ const verdictCounts = { pass: 0, fail: 0, partial: 0 };
340
274
 
341
- // ─── Main ────────────────────────────────────────────────────────────
275
+ for (const file of files) {
276
+ const path = join(RECORDS_DIR, file);
277
+ const record = JSON.parse(readFileSync(path, 'utf-8'));
342
278
 
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 };
279
+ // Skip records without judgment
280
+ if (!record.judgment || !record.judgment.criteria_scores) {
281
+ skipped++;
282
+ continue;
283
+ }
347
284
 
348
- for (const file of files) {
349
- const path = join(RECORDS_DIR, file);
350
- const record = JSON.parse(readFileSync(path, 'utf-8'));
285
+ // Check if already bound
286
+ if (record.canon?.assertions?.length > 0 && !FORCE) {
287
+ alreadyBound++;
288
+ continue;
289
+ }
351
290
 
352
- // Skip records without judgment
353
- if (!record.judgment || !record.judgment.criteria_scores) {
354
- skipped++;
355
- continue;
356
- }
291
+ const assertions = generateAssertions(record, constitutionRules, lanesConfig, rubricConfig, termConfig);
292
+ if (!assertions || assertions.length === 0) {
293
+ skipped++;
294
+ continue;
295
+ }
357
296
 
358
- // Check if already bound
359
- if (record.canon?.assertions?.length > 0 && !process.argv.includes('--force')) {
360
- alreadyBound++;
361
- continue;
362
- }
297
+ // Track stats
298
+ const group = detectGroup(record.id, record.provenance?.prompt, termConfig);
299
+ groupCounts[group || 'unknown'] = (groupCounts[group || 'unknown'] || 0) + 1;
300
+ for (const a of assertions) {
301
+ verdictCounts[a.verdict] = (verdictCounts[a.verdict] || 0) + 1;
302
+ }
363
303
 
364
- const assertions = generateAssertions(record);
365
- if (!assertions || assertions.length === 0) {
366
- skipped++;
367
- continue;
368
- }
304
+ if (STATS_ONLY) {
305
+ bound++;
306
+ continue;
307
+ }
369
308
 
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
- }
309
+ // Build canon object
310
+ record.canon = {
311
+ constitution_version: constitutionVersion,
312
+ bound_at: new Date().toISOString(),
313
+ bound_by: 'canon-bind-v1',
314
+ faction: group || null,
315
+ assertions,
316
+ assertion_count: assertions.length,
317
+ pass_count: assertions.filter(a => a.verdict === 'pass').length,
318
+ fail_count: assertions.filter(a => a.verdict === 'fail').length,
319
+ partial_count: assertions.filter(a => a.verdict === 'partial').length,
320
+ };
321
+
322
+ // Flat canon_assertions for repo-dataset
323
+ record.canon_assertions = assertions;
324
+
325
+ // Canon explanation
326
+ const passCount = assertions.filter(a => a.verdict === 'pass').length;
327
+ const failCount = assertions.filter(a => a.verdict === 'fail').length;
328
+ const status = record.judgment.status;
329
+ if (status === 'approved') {
330
+ record.canon_explanation = `Approved: passes ${passCount}/${assertions.length} constitution rules. ${group ? `Faction: ${group}.` : ''} ${record.judgment.explanation || ''}`.trim();
331
+ } else if (status === 'rejected') {
332
+ const failedRules = assertions.filter(a => a.verdict === 'fail').map(a => a.rule_id).join(', ');
333
+ record.canon_explanation = `Rejected: fails ${failedRules}. ${record.judgment.explanation || ''}`.trim();
334
+ } else {
335
+ record.canon_explanation = `Borderline: ${passCount} pass, ${failCount} fail out of ${assertions.length} rules. ${record.judgment.explanation || ''}`.trim();
336
+ }
376
337
 
377
- if (STATS_ONLY) {
338
+ if (!DRY_RUN) {
339
+ writeFileSync(path, JSON.stringify(record, null, 2) + '\n');
340
+ }
378
341
  bound++;
379
- continue;
380
- }
381
342
 
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();
343
+ if (DRY_RUN && bound <= 3) {
344
+ console.log(`\n─── ${record.id} (${record.judgment.status}) ───`);
345
+ console.log(`Faction: ${group || 'unknown'}`);
346
+ for (const a of assertions) {
347
+ console.log(` ${a.verdict.toUpperCase().padEnd(7)} ${a.rule_id} — ${a.rationale}`);
348
+ }
349
+ }
409
350
  }
410
351
 
411
- if (!DRY_RUN) {
412
- writeFileSync(path, JSON.stringify(record, null, 2) + '\n');
352
+ console.log(`\n═══ Canon Binding ${DRY_RUN ? '(DRY RUN) ' : STATS_ONLY ? '(STATS) ' : ''}Summary ═══`);
353
+ console.log(`Records: ${files.length}`);
354
+ console.log(`Bound: ${bound}`);
355
+ console.log(`Already bound: ${alreadyBound}`);
356
+ console.log(`Skipped (no judgment): ${skipped}`);
357
+ console.log(`\nFaction distribution:`);
358
+ for (const [f, c] of Object.entries(groupCounts).sort((a, b) => b[1] - a[1])) {
359
+ console.log(` ${f}: ${c}`);
413
360
  }
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
- }
361
+ console.log(`\nVerdict totals (across all assertions):`);
362
+ for (const [v, c] of Object.entries(verdictCounts)) {
363
+ console.log(` ${v}: ${c}`);
422
364
  }
423
365
  }
424
366
 
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}`);
367
+ // Direct execution guard
368
+ if (process.argv[1] && (process.argv[1].endsWith('canon-bind.js') || process.argv[1].endsWith('canon-bind'))) {
369
+ run().catch((err) => {
370
+ console.error(err.message || err);
371
+ process.exit(1);
372
+ });
437
373
  }