@mcptoolshop/style-dataset-lab 1.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 +37 -0
- package/LICENSE +21 -0
- package/README.es.md +142 -0
- package/README.fr.md +142 -0
- package/README.hi.md +142 -0
- package/README.it.md +142 -0
- package/README.ja.md +142 -0
- package/README.md +178 -0
- package/README.pt-BR.md +142 -0
- package/README.zh.md +142 -0
- package/SECURITY.md +28 -0
- package/package.json +45 -0
- package/scripts/bulk-curate-wave2-5.js +138 -0
- package/scripts/bulk-curate-waves11-18.js +413 -0
- package/scripts/canon-bind.js +437 -0
- package/scripts/compare.js +116 -0
- package/scripts/curate-wave25.js +209 -0
- package/scripts/curate.js +136 -0
- package/scripts/generate-controlnet.js +357 -0
- package/scripts/generate-identity.js +633 -0
- package/scripts/generate-ipadapter.js +339 -0
- package/scripts/generate.js +351 -0
- package/scripts/migrate-records.js +147 -0
- package/scripts/painterly-test.js +149 -0
- package/scripts/painterly.js +349 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Canon Binding Pass — populate canon_assertions in all records.
|
|
4
|
+
*
|
|
5
|
+
* Each assertion links an asset to a specific constitution rule with
|
|
6
|
+
* a verdict (pass/fail/partial) and a one-line rationale derived from
|
|
7
|
+
* the judgment scores and failure modes.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node scripts/canon-bind.js # bind all records
|
|
11
|
+
* node scripts/canon-bind.js --dry-run # preview without writing
|
|
12
|
+
* node scripts/canon-bind.js --stats # print coverage stats
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
|
|
19
|
+
const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
|
|
20
|
+
const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
|
|
21
|
+
|
|
22
|
+
const RECORDS_DIR = join(GAME_ROOT, 'records');
|
|
23
|
+
const DRY_RUN = process.argv.includes('--dry-run');
|
|
24
|
+
const STATS_ONLY = process.argv.includes('--stats');
|
|
25
|
+
|
|
26
|
+
// ─── Constitution Rules ──────────────────────────────────────────────
|
|
27
|
+
// Each rule has: id, category, description, scoring_dimensions (which
|
|
28
|
+
// judgment scores map to this rule), and faction_specific flag.
|
|
29
|
+
|
|
30
|
+
const CONSTITUTION_RULES = [
|
|
31
|
+
// Rendering rules (universal)
|
|
32
|
+
{ id: 'RND-001', category: 'rendering', desc: 'Semi-realistic painterly style',
|
|
33
|
+
dims: ['style_consistency'], faction_specific: false },
|
|
34
|
+
{ id: 'RND-002', category: 'rendering', desc: 'Single upper-left warm lighting with soft AO shadows',
|
|
35
|
+
dims: ['composition'], faction_specific: false },
|
|
36
|
+
{ id: 'RND-003', category: 'rendering', desc: 'Muted dusty color saturation',
|
|
37
|
+
dims: ['palette_adherence'], faction_specific: false },
|
|
38
|
+
{ id: 'RND-004', category: 'rendering', desc: 'Full body front-facing on plain background, 70-85% fill',
|
|
39
|
+
dims: ['composition'], faction_specific: false },
|
|
40
|
+
{ id: 'RND-005', category: 'rendering', desc: 'Readable at 128px, identifiable at 64px',
|
|
41
|
+
dims: ['silhouette_clarity'], faction_specific: false },
|
|
42
|
+
|
|
43
|
+
// Material rules (faction-specific)
|
|
44
|
+
{ id: 'MAT-001', category: 'material', desc: 'Every surface reads as faction material vocabulary',
|
|
45
|
+
dims: ['material_fidelity', 'faction_read'], faction_specific: true },
|
|
46
|
+
{ id: 'MAT-002', category: 'material', desc: 'Faction-appropriate aging and wear',
|
|
47
|
+
dims: ['wear_level'], faction_specific: true },
|
|
48
|
+
|
|
49
|
+
// Shape language (faction-specific)
|
|
50
|
+
{ id: 'SHP-001', category: 'shape', desc: 'Faction shape language compliance',
|
|
51
|
+
dims: ['silhouette_clarity', 'faction_read'], faction_specific: true },
|
|
52
|
+
|
|
53
|
+
// Color rules (faction-specific)
|
|
54
|
+
{ id: 'COL-001', category: 'color', desc: 'Correct faction palette colors',
|
|
55
|
+
dims: ['palette_adherence', 'faction_read'], faction_specific: true },
|
|
56
|
+
{ id: 'COL-002', category: 'color', desc: 'Palette ratio ~60/30/10',
|
|
57
|
+
dims: ['palette_adherence'], faction_specific: true },
|
|
58
|
+
{ id: 'COL-003', category: 'color', desc: 'No bright saturated colors — everything muted/dusty',
|
|
59
|
+
dims: ['palette_adherence'], faction_specific: false },
|
|
60
|
+
|
|
61
|
+
// Clothing rules (faction-specific — costume subjects only)
|
|
62
|
+
{ id: 'CLO-001', category: 'clothing', desc: 'Layer logic: base → functional → identity → accessory',
|
|
63
|
+
dims: ['clothing_logic'], faction_specific: true, lanes: ['costume'] },
|
|
64
|
+
{ id: 'CLO-002', category: 'clothing', desc: 'Construction matches faction building method',
|
|
65
|
+
dims: ['clothing_logic', 'material_fidelity'], faction_specific: true, lanes: ['costume'] },
|
|
66
|
+
{ id: 'CLO-003', category: 'clothing', desc: 'Not generic sci-fi (no spandex/Star Trek/Marvel/asset store)',
|
|
67
|
+
dims: ['faction_read', 'clothing_logic'], faction_specific: false },
|
|
68
|
+
|
|
69
|
+
// Ship rules (ship subjects only)
|
|
70
|
+
{ id: 'SHP-EXT-001', category: 'ship', desc: 'Faction hull language — construction reads as faction-built',
|
|
71
|
+
dims: ['material_fidelity', 'faction_read'], faction_specific: true, lanes: ['ship'] },
|
|
72
|
+
{ id: 'SHP-EXT-002', category: 'ship', desc: 'Scale and grit — working vehicle not showpiece',
|
|
73
|
+
dims: ['wear_level', 'material_fidelity'], faction_specific: false, lanes: ['ship'] },
|
|
74
|
+
{ id: 'SHP-INT-001', category: 'interior', desc: 'Interior faction character — environmental storytelling',
|
|
75
|
+
dims: ['faction_read', 'material_fidelity'], faction_specific: true, lanes: ['interior'] },
|
|
76
|
+
{ id: 'SHP-INT-002', category: 'interior', desc: 'Lived-in detail — coffee stains, tool marks, wear',
|
|
77
|
+
dims: ['wear_level'], faction_specific: false, lanes: ['interior'] },
|
|
78
|
+
|
|
79
|
+
// Equipment rules (equipment/prop subjects only)
|
|
80
|
+
{ id: 'EQP-001', category: 'equipment', desc: 'Faction design language for tools and weapons',
|
|
81
|
+
dims: ['material_fidelity', 'faction_read'], faction_specific: true, lanes: ['equipment', 'prop'] },
|
|
82
|
+
{ id: 'EQP-002', category: 'equipment', desc: 'Tool not toy — used, worn, functional',
|
|
83
|
+
dims: ['wear_level'], faction_specific: false, lanes: ['equipment', 'prop'] },
|
|
84
|
+
{ id: 'EQP-003', category: 'equipment', desc: 'Props carry faction identity or universal grit',
|
|
85
|
+
dims: ['faction_read', 'wear_level'], faction_specific: false, lanes: ['prop'] },
|
|
86
|
+
|
|
87
|
+
// Environment rules (environment subjects only)
|
|
88
|
+
{ id: 'ENV-001', category: 'environment', desc: 'Atmosphere over beauty — industrial, tense, functional',
|
|
89
|
+
dims: ['style_consistency', 'wear_level'], faction_specific: false, lanes: ['environment', 'station'] },
|
|
90
|
+
{ id: 'ENV-002', category: 'environment', desc: 'Faction footprint in architecture and signage',
|
|
91
|
+
dims: ['faction_read', 'material_fidelity'], faction_specific: true, lanes: ['environment', 'station'] },
|
|
92
|
+
{ id: 'ENV-003', category: 'environment', desc: 'Lighting carries mood — faction-specific lighting character',
|
|
93
|
+
dims: ['palette_adherence'], faction_specific: true, lanes: ['environment', 'station', 'interior'] },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// ─── Lane Detection ──────────────────────────────────────────────────
|
|
97
|
+
// Determines which subject category an asset belongs to, so lane-specific
|
|
98
|
+
// rules (CLO for costumes, SHP for ships, etc.) are applied correctly.
|
|
99
|
+
|
|
100
|
+
function detectLane(id, prompt) {
|
|
101
|
+
const lower = id.toLowerCase();
|
|
102
|
+
// Ship exterior prefixes
|
|
103
|
+
if (/^ship_ext_|^se_|^ship_/.test(lower)) return 'ship';
|
|
104
|
+
// Ship interior prefixes
|
|
105
|
+
if (/^ship_int_|^si_|^int_|^interior_/.test(lower)) return 'interior';
|
|
106
|
+
// Station/port prefixes
|
|
107
|
+
if (/^station_|^port_|^stn_/.test(lower)) return 'station';
|
|
108
|
+
// Equipment/weapon prefixes
|
|
109
|
+
if (/^eqp_|^wpn_|^weapon_|^tool_|^equip_/.test(lower)) return 'equipment';
|
|
110
|
+
// Prop prefixes
|
|
111
|
+
if (/^prop_|^food_|^sign_|^furn_/.test(lower)) return 'prop';
|
|
112
|
+
// Environment prefixes
|
|
113
|
+
if (/^env_|^planet_|^landing_|^mine_/.test(lower)) return 'environment';
|
|
114
|
+
// Default: costume (all existing assets are costumes)
|
|
115
|
+
return 'costume';
|
|
116
|
+
}
|
|
117
|
+
|
|
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';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Edge cases: detect from prompt
|
|
162
|
+
if (/^edge_/.test(id)) return 'compact'; // edge cases are human subjects
|
|
163
|
+
|
|
164
|
+
// Reject/borderline: detect from prompt content
|
|
165
|
+
if (prompt) {
|
|
166
|
+
const p = prompt.toLowerCase();
|
|
167
|
+
if (/reptilian|veshan|scales|crest/.test(p)) return 'veshan';
|
|
168
|
+
if (/arthropod|keth|chitin|antennae/.test(p)) return 'keth';
|
|
169
|
+
if (/cephalopod|orryn|tentacle|chromatophore/.test(p)) return 'orryn';
|
|
170
|
+
if (/salvaged|scrap|jury.rigged|outlaw|pirate/.test(p)) return 'reach';
|
|
171
|
+
}
|
|
172
|
+
|
|
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
|
|
176
|
+
|
|
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';
|
|
180
|
+
|
|
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';
|
|
185
|
+
|
|
186
|
+
return null; // unknown faction
|
|
187
|
+
}
|
|
188
|
+
|
|
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
|
+
// ─── Assertion Generation ────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function generateAssertions(record) {
|
|
209
|
+
const { id, judgment, provenance } = record;
|
|
210
|
+
if (!judgment || !judgment.criteria_scores) return null;
|
|
211
|
+
|
|
212
|
+
const { status, criteria_scores, failure_modes = [], explanation = '' } = judgment;
|
|
213
|
+
const prompt = provenance?.prompt || '';
|
|
214
|
+
const faction = detectFaction(id, prompt);
|
|
215
|
+
const lane = detectLane(id, prompt);
|
|
216
|
+
const assertions = [];
|
|
217
|
+
|
|
218
|
+
for (const rule of CONSTITUTION_RULES) {
|
|
219
|
+
// Skip faction-specific rules for unknown-faction rejects
|
|
220
|
+
if (rule.faction_specific && !faction) continue;
|
|
221
|
+
|
|
222
|
+
// Skip lane-specific rules that don't apply to this asset's lane
|
|
223
|
+
if (rule.lanes && !rule.lanes.includes(lane)) continue;
|
|
224
|
+
|
|
225
|
+
// Calculate average score from mapped dimensions
|
|
226
|
+
const dimScores = rule.dims
|
|
227
|
+
.map(d => criteria_scores[d])
|
|
228
|
+
.filter(s => s !== undefined && s !== null);
|
|
229
|
+
if (dimScores.length === 0) continue;
|
|
230
|
+
const avgScore = dimScores.reduce((a, b) => a + b, 0) / dimScores.length;
|
|
231
|
+
|
|
232
|
+
// Check if any failure mode maps to this rule
|
|
233
|
+
const failedByMode = failure_modes.some(fm =>
|
|
234
|
+
(FAILURE_TO_RULES[fm] || []).includes(rule.id)
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Determine verdict
|
|
238
|
+
let verdict;
|
|
239
|
+
if (status === 'rejected') {
|
|
240
|
+
if (failedByMode || avgScore < 0.4) {
|
|
241
|
+
verdict = 'fail';
|
|
242
|
+
} else if (avgScore < 0.6) {
|
|
243
|
+
verdict = 'partial';
|
|
244
|
+
} else {
|
|
245
|
+
verdict = 'pass'; // rejected asset can still pass some rules
|
|
246
|
+
}
|
|
247
|
+
} else if (status === 'borderline') {
|
|
248
|
+
if (avgScore >= 0.7) verdict = 'pass';
|
|
249
|
+
else if (avgScore >= 0.5) verdict = 'partial';
|
|
250
|
+
else verdict = 'fail';
|
|
251
|
+
} else { // approved
|
|
252
|
+
if (avgScore >= 0.7) verdict = 'pass';
|
|
253
|
+
else if (avgScore >= 0.5) verdict = 'partial';
|
|
254
|
+
else verdict = 'fail'; // unlikely for approved
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Build rationale
|
|
258
|
+
let rationale = buildRationale(rule, verdict, avgScore, faction, explanation);
|
|
259
|
+
|
|
260
|
+
assertions.push({
|
|
261
|
+
rule_id: rule.id,
|
|
262
|
+
category: rule.category,
|
|
263
|
+
verdict,
|
|
264
|
+
score: Math.round(avgScore * 100) / 100,
|
|
265
|
+
rationale,
|
|
266
|
+
...(faction && rule.faction_specific ? { faction, faction_context: getFactionContext(rule, faction) } : {}),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return assertions;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildRationale(rule, verdict, score, faction, explanation) {
|
|
274
|
+
const scorePct = Math.round(score * 100);
|
|
275
|
+
|
|
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
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
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
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// partial
|
|
317
|
+
return `Partially meets ${rule.desc.toLowerCase()} (${scorePct}%)`;
|
|
318
|
+
}
|
|
319
|
+
|
|
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
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Main ────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
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 };
|
|
347
|
+
|
|
348
|
+
for (const file of files) {
|
|
349
|
+
const path = join(RECORDS_DIR, file);
|
|
350
|
+
const record = JSON.parse(readFileSync(path, 'utf-8'));
|
|
351
|
+
|
|
352
|
+
// Skip records without judgment
|
|
353
|
+
if (!record.judgment || !record.judgment.criteria_scores) {
|
|
354
|
+
skipped++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Check if already bound
|
|
359
|
+
if (record.canon?.assertions?.length > 0 && !process.argv.includes('--force')) {
|
|
360
|
+
alreadyBound++;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const assertions = generateAssertions(record);
|
|
365
|
+
if (!assertions || assertions.length === 0) {
|
|
366
|
+
skipped++;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
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
|
+
}
|
|
376
|
+
|
|
377
|
+
if (STATS_ONLY) {
|
|
378
|
+
bound++;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
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();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!DRY_RUN) {
|
|
412
|
+
writeFileSync(path, JSON.stringify(record, null, 2) + '\n');
|
|
413
|
+
}
|
|
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
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
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}`);
|
|
437
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* compare.js — Record a pairwise A-vs-B style comparison.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/compare.js <asset_a_id> <asset_b_id> <winner: a|b|tie> <reasoning>
|
|
8
|
+
* node scripts/compare.js iron_sword_base iron_sword_icon_mid a "Better silhouette, painterly execution"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
|
|
15
|
+
const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
|
|
16
|
+
const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const [assetAId, assetBId, winner, ...reasonParts] = args;
|
|
21
|
+
|
|
22
|
+
if (!assetAId || !assetBId || !winner) {
|
|
23
|
+
console.log("Usage: node scripts/compare.js <asset_a_id> <asset_b_id> <a|b|tie> <reasoning>");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!["a", "b", "tie"].includes(winner)) {
|
|
28
|
+
console.error(`Invalid winner: ${winner}. Must be a, b, or tie.`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const reasoning = reasonParts.join(" ") || null;
|
|
33
|
+
|
|
34
|
+
// Load records to get paths
|
|
35
|
+
const recordA = await loadRecord(assetAId);
|
|
36
|
+
const recordB = await loadRecord(assetBId);
|
|
37
|
+
|
|
38
|
+
if (!recordA || !recordB) process.exit(1);
|
|
39
|
+
|
|
40
|
+
// Parse optional scores
|
|
41
|
+
const scoresStr = getFlagValue(args, "scores");
|
|
42
|
+
const criteria_scores = {};
|
|
43
|
+
if (scoresStr) {
|
|
44
|
+
for (const pair of scoresStr.split(",")) {
|
|
45
|
+
const [k, v] = pair.split(":");
|
|
46
|
+
// Format: "silhouette:0.9/0.6" → { silhouette: { a: 0.9, b: 0.6 } }
|
|
47
|
+
if (k && v) {
|
|
48
|
+
const [aScore, bScore] = v.split("/");
|
|
49
|
+
criteria_scores[k.trim()] = { a: parseFloat(aScore), b: parseFloat(bScore) };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Build comparison ID
|
|
55
|
+
const existingComps = await countComparisons();
|
|
56
|
+
const cmpId = `cmp_${String(existingComps + 1).padStart(3, "0")}`;
|
|
57
|
+
|
|
58
|
+
const comparison = {
|
|
59
|
+
id: cmpId,
|
|
60
|
+
asset_a_id: assetAId,
|
|
61
|
+
asset_b_id: assetBId,
|
|
62
|
+
asset_a_path: recordA.asset_path,
|
|
63
|
+
asset_b_path: recordB.asset_path,
|
|
64
|
+
chosen: winner,
|
|
65
|
+
source: "human",
|
|
66
|
+
reasoning,
|
|
67
|
+
criteria_scores,
|
|
68
|
+
rubric_citations: [],
|
|
69
|
+
reviewer: "human:mike",
|
|
70
|
+
reviewed_at: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await mkdir(join(GAME_ROOT, "comparisons"), { recursive: true });
|
|
74
|
+
await writeFile(
|
|
75
|
+
join(GAME_ROOT, `comparisons/${cmpId}.json`),
|
|
76
|
+
JSON.stringify(comparison, null, 2),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const winnerLabel = winner === "tie" ? "TIE" : winner === "a" ? assetAId : assetBId;
|
|
80
|
+
console.log(`\x1b[32m✓\x1b[0m ${cmpId}: ${assetAId} vs ${assetBId} → ${winnerLabel}`);
|
|
81
|
+
if (reasoning) console.log(` ${reasoning}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadRecord(id) {
|
|
85
|
+
try {
|
|
86
|
+
const path = join(GAME_ROOT, `records/${id}.json`);
|
|
87
|
+
return JSON.parse(await readFile(path, "utf-8"));
|
|
88
|
+
} catch {
|
|
89
|
+
console.error(`Record not found: records/${id}.json`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function countComparisons() {
|
|
95
|
+
try {
|
|
96
|
+
const files = await readdir(join(GAME_ROOT, "comparisons"));
|
|
97
|
+
return files.filter((f) => f.endsWith(".json")).length;
|
|
98
|
+
} catch {
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getFlagValue(args, flag) {
|
|
104
|
+
const prefix = `--${flag}=`;
|
|
105
|
+
for (const a of args) {
|
|
106
|
+
if (a.startsWith(prefix)) return a.slice(prefix.length);
|
|
107
|
+
}
|
|
108
|
+
const idx = args.indexOf(`--${flag}`);
|
|
109
|
+
if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
main().catch((err) => {
|
|
114
|
+
console.error(err);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
});
|