@mcptoolshop/style-dataset-lab 1.2.1 → 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.
- package/CHANGELOG.md +45 -0
- package/README.md +95 -116
- package/bin/sdlab.js +103 -0
- package/lib/args.js +106 -0
- package/lib/comfyui.js +109 -0
- package/lib/config.js +104 -0
- package/lib/deprecation.js +14 -0
- package/lib/paths.js +38 -0
- package/lib/records.js +69 -0
- package/package.json +13 -4
- package/scripts/bulk-curate-wave2-5.js +1 -1
- package/scripts/bulk-curate-waves11-18.js +1 -1
- package/scripts/canon-bind.js +268 -332
- package/scripts/compare.js +40 -30
- package/scripts/curate-wave25.js +1 -1
- package/scripts/curate.js +34 -28
- package/scripts/doctor.js +321 -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 +226 -0
- package/scripts/migrate-records.js +15 -21
- package/scripts/painterly-test.js +20 -47
- package/scripts/painterly.js +27 -106
- 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,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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
veshan
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
162
|
-
|
|
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
|
-
//
|
|
86
|
+
// 4. Prompt-based fallback (prompt_detection_order)
|
|
165
87
|
if (prompt) {
|
|
166
88
|
const p = prompt.toLowerCase();
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
274
|
-
const scorePct = Math.round(score * 100);
|
|
239
|
+
// ─── Main ────────────────────────────────────────────────────────────
|
|
275
240
|
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
267
|
+
const constitutionRules = constitutionConfig.rules;
|
|
268
|
+
const constitutionVersion = constitutionConfig.version || '1.0.0';
|
|
319
269
|
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
279
|
+
// Skip records without judgment
|
|
280
|
+
if (!record.judgment || !record.judgment.criteria_scores) {
|
|
281
|
+
skipped++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
347
284
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
285
|
+
// Check if already bound
|
|
286
|
+
if (record.canon?.assertions?.length > 0 && !FORCE) {
|
|
287
|
+
alreadyBound++;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
351
290
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
291
|
+
const assertions = generateAssertions(record, constitutionRules, lanesConfig, rubricConfig, termConfig);
|
|
292
|
+
if (!assertions || assertions.length === 0) {
|
|
293
|
+
skipped++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
357
296
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
304
|
+
if (STATS_ONLY) {
|
|
305
|
+
bound++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
369
308
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
338
|
+
if (!DRY_RUN) {
|
|
339
|
+
writeFileSync(path, JSON.stringify(record, null, 2) + '\n');
|
|
340
|
+
}
|
|
378
341
|
bound++;
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
342
|
|
|
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();
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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}`);
|
|
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
|
}
|