@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.
- package/CHANGELOG.md +73 -0
- package/README.md +138 -113
- package/bin/sdlab.js +126 -0
- package/lib/args.js +106 -0
- package/lib/card.js +145 -0
- package/lib/comfyui.js +109 -0
- package/lib/config.js +248 -0
- package/lib/deprecation.js +14 -0
- package/lib/eligibility.js +110 -0
- package/lib/eval-pack.js +283 -0
- package/lib/export.js +259 -0
- package/lib/paths.js +38 -0
- package/lib/records.js +69 -0
- package/lib/snapshot.js +211 -0
- package/lib/split.js +409 -0
- package/package.json +20 -9
- package/scripts/bulk-curate-wave2-5.js +1 -1
- package/scripts/bulk-curate-waves11-18.js +1 -1
- package/scripts/canon-bind.js +188 -338
- package/scripts/card.js +73 -0
- package/scripts/compare.js +40 -30
- package/scripts/curate-wave25.js +1 -1
- package/scripts/curate.js +34 -28
- package/scripts/doctor.js +324 -0
- package/scripts/eligibility.js +91 -0
- package/scripts/eval-pack.js +92 -0
- package/scripts/export.js +85 -0
- package/scripts/generate-controlnet.js +36 -121
- package/scripts/generate-identity.js +38 -106
- package/scripts/generate-ipadapter.js +42 -119
- package/scripts/generate.js +22 -81
- package/scripts/init.js +229 -0
- package/scripts/migrate-records.js +15 -21
- package/scripts/painterly-test.js +20 -47
- package/scripts/painterly.js +27 -106
- package/scripts/snapshot.js +132 -0
- package/scripts/split.js +154 -0
- package/templates/domains/architecture/constitution.json +97 -0
- package/templates/domains/architecture/lanes.json +41 -0
- package/templates/domains/architecture/project.json +20 -0
- package/templates/domains/architecture/rubric.json +40 -0
- package/templates/domains/architecture/terminology.json +19 -0
- package/templates/domains/character-design/constitution.json +88 -0
- package/templates/domains/character-design/lanes.json +35 -0
- package/templates/domains/character-design/project.json +17 -0
- package/templates/domains/character-design/rubric.json +38 -0
- package/templates/domains/character-design/terminology.json +21 -0
- package/templates/domains/creature-design/constitution.json +87 -0
- package/templates/domains/creature-design/lanes.json +41 -0
- package/templates/domains/creature-design/project.json +18 -0
- package/templates/domains/creature-design/rubric.json +39 -0
- package/templates/domains/creature-design/terminology.json +20 -0
- package/templates/domains/game-art/constitution.json +113 -0
- package/templates/domains/game-art/lanes.json +47 -0
- package/templates/domains/game-art/project.json +17 -0
- package/templates/domains/game-art/rubric.json +36 -0
- package/templates/domains/game-art/terminology.json +30 -0
- package/templates/domains/vehicle-mech/constitution.json +87 -0
- package/templates/domains/vehicle-mech/lanes.json +41 -0
- package/templates/domains/vehicle-mech/project.json +17 -0
- package/templates/domains/vehicle-mech/rubric.json +40 -0
- package/templates/domains/vehicle-mech/terminology.json +21 -0
package/scripts/canon-bind.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
//
|
|
182
|
-
|
|
183
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
274
|
-
const scorePct = Math.round(score * 100);
|
|
153
|
+
// ─── Main ────────────────────────────────────────────────────────────
|
|
275
154
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
181
|
+
const constitutionRules = constitutionConfig.rules;
|
|
182
|
+
const constitutionVersion = constitutionConfig.version || '1.0.0';
|
|
319
183
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
193
|
+
// Skip records without judgment
|
|
194
|
+
if (!record.judgment || !record.judgment.criteria_scores) {
|
|
195
|
+
skipped++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
347
198
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
199
|
+
// Check if already bound
|
|
200
|
+
if (record.canon?.assertions?.length > 0 && !FORCE) {
|
|
201
|
+
alreadyBound++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
351
204
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
205
|
+
const assertions = generateAssertions(record, constitutionRules, lanesConfig, rubricConfig, termConfig);
|
|
206
|
+
if (!assertions || assertions.length === 0) {
|
|
207
|
+
skipped++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
357
210
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
218
|
+
if (STATS_ONLY) {
|
|
219
|
+
bound++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
369
222
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
252
|
+
if (!DRY_RUN) {
|
|
253
|
+
writeFileSync(path, JSON.stringify(record, null, 2) + '\n');
|
|
254
|
+
}
|
|
378
255
|
bound++;
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
256
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
console.
|
|
429
|
-
|
|
430
|
-
|
|
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
|
}
|