@mcptoolshop/style-dataset-lab 1.2.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +138 -113
  3. package/bin/sdlab.js +126 -0
  4. package/lib/args.js +106 -0
  5. package/lib/card.js +145 -0
  6. package/lib/comfyui.js +109 -0
  7. package/lib/config.js +248 -0
  8. package/lib/deprecation.js +14 -0
  9. package/lib/eligibility.js +110 -0
  10. package/lib/eval-pack.js +283 -0
  11. package/lib/export.js +259 -0
  12. package/lib/paths.js +38 -0
  13. package/lib/records.js +69 -0
  14. package/lib/snapshot.js +211 -0
  15. package/lib/split.js +409 -0
  16. package/package.json +20 -9
  17. package/scripts/bulk-curate-wave2-5.js +1 -1
  18. package/scripts/bulk-curate-waves11-18.js +1 -1
  19. package/scripts/canon-bind.js +188 -338
  20. package/scripts/card.js +73 -0
  21. package/scripts/compare.js +40 -30
  22. package/scripts/curate-wave25.js +1 -1
  23. package/scripts/curate.js +34 -28
  24. package/scripts/doctor.js +324 -0
  25. package/scripts/eligibility.js +91 -0
  26. package/scripts/eval-pack.js +92 -0
  27. package/scripts/export.js +85 -0
  28. package/scripts/generate-controlnet.js +36 -121
  29. package/scripts/generate-identity.js +38 -106
  30. package/scripts/generate-ipadapter.js +42 -119
  31. package/scripts/generate.js +22 -81
  32. package/scripts/init.js +229 -0
  33. package/scripts/migrate-records.js +15 -21
  34. package/scripts/painterly-test.js +20 -47
  35. package/scripts/painterly.js +27 -106
  36. package/scripts/snapshot.js +132 -0
  37. package/scripts/split.js +154 -0
  38. package/templates/domains/architecture/constitution.json +97 -0
  39. package/templates/domains/architecture/lanes.json +41 -0
  40. package/templates/domains/architecture/project.json +20 -0
  41. package/templates/domains/architecture/rubric.json +40 -0
  42. package/templates/domains/architecture/terminology.json +19 -0
  43. package/templates/domains/character-design/constitution.json +88 -0
  44. package/templates/domains/character-design/lanes.json +35 -0
  45. package/templates/domains/character-design/project.json +17 -0
  46. package/templates/domains/character-design/rubric.json +38 -0
  47. package/templates/domains/character-design/terminology.json +21 -0
  48. package/templates/domains/creature-design/constitution.json +87 -0
  49. package/templates/domains/creature-design/lanes.json +41 -0
  50. package/templates/domains/creature-design/project.json +18 -0
  51. package/templates/domains/creature-design/rubric.json +39 -0
  52. package/templates/domains/creature-design/terminology.json +20 -0
  53. package/templates/domains/game-art/constitution.json +113 -0
  54. package/templates/domains/game-art/lanes.json +47 -0
  55. package/templates/domains/game-art/project.json +17 -0
  56. package/templates/domains/game-art/rubric.json +36 -0
  57. package/templates/domains/game-art/terminology.json +30 -0
  58. package/templates/domains/vehicle-mech/constitution.json +87 -0
  59. package/templates/domains/vehicle-mech/lanes.json +41 -0
  60. package/templates/domains/vehicle-mech/project.json +17 -0
  61. package/templates/domains/vehicle-mech/rubric.json +40 -0
  62. package/templates/domains/vehicle-mech/terminology.json +21 -0
@@ -10,18 +10,17 @@
10
10
  * Usage:
11
11
  * node scripts/painterly.js [--source outputs/approved] [--limit 100] [--offset 0]
12
12
  * node scripts/painterly.js --dry-run
13
+ * sdlab painterly --project star-freight --limit 50
13
14
  *
14
15
  * Output: outputs/painterly/<original_filename>
15
16
  */
16
17
 
17
- import { readFile, writeFile, mkdir, readdir, copyFile, stat } from "node:fs/promises";
18
- import { join, basename, extname } from "node:path";
18
+ import { writeFile, mkdir, readdir } from "node:fs/promises";
19
+ import { join } from "node:path";
19
20
  import { existsSync } from "node:fs";
20
-
21
- const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
22
- const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
23
- const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
24
- const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
21
+ import { getProjectName } from "../lib/args.js";
22
+ import { REPO_ROOT } from "../lib/paths.js";
23
+ import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
25
24
 
26
25
  // ── Config ──
27
26
 
@@ -51,20 +50,10 @@ const DEFAULTS = {
51
50
  seed: 42, // Fixed seed for reproducibility across batch
52
51
  };
53
52
 
54
- // ── ComfyUI helpers ──
55
-
56
- async function comfyHealth() {
57
- try {
58
- const res = await fetch(`${COMFY_URL}/system_stats`);
59
- return res.ok;
60
- } catch { return false; }
61
- }
62
-
63
53
  function buildImg2ImgWorkflow(imagePath, seed) {
64
54
  const nodes = {};
65
55
  let nextId = 1;
66
56
 
67
- // Checkpoint loader
68
57
  const ckptId = String(nextId++);
69
58
  nodes[ckptId] = {
70
59
  class_type: "CheckpointLoaderSimple",
@@ -74,7 +63,6 @@ function buildImg2ImgWorkflow(imagePath, seed) {
74
63
  let modelOut = [ckptId, 0];
75
64
  let clipOut = [ckptId, 1];
76
65
 
77
- // LoRA — ClassipeintXL at reduced weight for style shift
78
66
  const loraId = String(nextId++);
79
67
  nodes[loraId] = {
80
68
  class_type: "LoraLoader",
@@ -89,14 +77,12 @@ function buildImg2ImgWorkflow(imagePath, seed) {
89
77
  modelOut = [loraId, 0];
90
78
  clipOut = [loraId, 1];
91
79
 
92
- // Load source image
93
80
  const loadImgId = String(nextId++);
94
81
  nodes[loadImgId] = {
95
82
  class_type: "LoadImage",
96
83
  inputs: { image: imagePath },
97
84
  };
98
85
 
99
- // VAE Encode — source image to latent
100
86
  const vaeEncId = String(nextId++);
101
87
  nodes[vaeEncId] = {
102
88
  class_type: "VAEEncode",
@@ -106,21 +92,18 @@ function buildImg2ImgWorkflow(imagePath, seed) {
106
92
  },
107
93
  };
108
94
 
109
- // CLIP Text Encode — positive (painterly style)
110
95
  const posId = String(nextId++);
111
96
  nodes[posId] = {
112
97
  class_type: "CLIPTextEncode",
113
98
  inputs: { text: PAINTERLY_PROMPT, clip: clipOut },
114
99
  };
115
100
 
116
- // CLIP Text Encode — negative
117
101
  const negId = String(nextId++);
118
102
  nodes[negId] = {
119
103
  class_type: "CLIPTextEncode",
120
104
  inputs: { text: PAINTERLY_NEGATIVE, clip: clipOut },
121
105
  };
122
106
 
123
- // KSampler — img2img with low denoise
124
107
  const samplerId = String(nextId++);
125
108
  nodes[samplerId] = {
126
109
  class_type: "KSampler",
@@ -138,7 +121,6 @@ function buildImg2ImgWorkflow(imagePath, seed) {
138
121
  },
139
122
  };
140
123
 
141
- // VAE Decode
142
124
  const vaeDecId = String(nextId++);
143
125
  nodes[vaeDecId] = {
144
126
  class_type: "VAEDecode",
@@ -148,7 +130,6 @@ function buildImg2ImgWorkflow(imagePath, seed) {
148
130
  },
149
131
  };
150
132
 
151
- // Save Image
152
133
  const saveId = String(nextId++);
153
134
  nodes[saveId] = {
154
135
  class_type: "SaveImage",
@@ -161,84 +142,22 @@ function buildImg2ImgWorkflow(imagePath, seed) {
161
142
  return { nodes, saveNodeId: saveId };
162
143
  }
163
144
 
164
- const MAX_POLL_MS = 600_000; // 10 minutes
145
+ export async function run(argv = process.argv.slice(2)) {
146
+ const projectName = getProjectName(argv);
147
+ const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
148
+ const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
165
149
 
166
- async function submitAndWait(workflow) {
167
- const clientId = `sdl-paint-${Date.now()}`;
168
- const res = await fetch(`${COMFY_URL}/prompt`, {
169
- method: "POST",
170
- headers: { "Content-Type": "application/json" },
171
- body: JSON.stringify({ prompt: workflow, client_id: clientId }),
172
- });
173
-
174
- if (!res.ok) {
175
- const text = await res.text();
176
- throw new Error(`ComfyUI submit failed: ${res.status} ${text}`);
177
- }
178
-
179
- const { prompt_id: promptId } = await res.json();
180
-
181
- const start = Date.now();
182
- while (true) {
183
- if (Date.now() - start > MAX_POLL_MS) throw new Error(`ComfyUI poll timeout after ${MAX_POLL_MS/1000}s for prompt ${promptId}`);
184
- await new Promise((r) => setTimeout(r, 1000));
185
- const histRes = await fetch(`${COMFY_URL}/history/${promptId}`);
186
- if (!histRes.ok) continue;
187
- const history = await histRes.json();
188
- const entry = history[promptId];
189
- if (!entry) continue;
190
- if (entry.status?.completed) return entry;
191
- if (entry.status?.status_str === "error") {
192
- throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status)}`);
193
- }
194
- }
195
- }
196
-
197
- async function downloadImage(filename, subfolder, type = "output") {
198
- const url = `${COMFY_URL}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || "")}&type=${type}`;
199
- const res = await fetch(url);
200
- if (!res.ok) throw new Error(`Download failed: ${res.status}`);
201
- return Buffer.from(await res.arrayBuffer());
202
- }
203
-
204
- /**
205
- * Upload an image to ComfyUI's input folder so LoadImage can find it.
206
- */
207
- async function uploadImage(filePath, filename) {
208
- const data = await readFile(filePath);
209
- const formData = new FormData();
210
- formData.append("image", new Blob([data], { type: "image/png" }), filename);
211
- formData.append("overwrite", "true");
212
-
213
- const res = await fetch(`${COMFY_URL}/upload/image`, {
214
- method: "POST",
215
- body: formData,
216
- });
217
-
218
- if (!res.ok) {
219
- const text = await res.text();
220
- throw new Error(`Upload failed: ${res.status} ${text}`);
221
- }
222
-
223
- const result = await res.json();
224
- return result.name; // The name ComfyUI assigned to the uploaded image
225
- }
226
-
227
- // ── Main ──
228
-
229
- async function main() {
230
- const args = process.argv.slice(2);
231
- const dryRun = args.includes("--dry-run");
150
+ const dryRun = argv.includes("--dry-run");
232
151
 
233
152
  // Parse args
234
153
  let sourceDir = "outputs/approved";
235
154
  let limit = 100;
236
155
  let offset = 0;
237
156
 
238
- for (let i = 0; i < args.length; i++) {
239
- if (args[i] === "--source" && args[i + 1]) sourceDir = args[++i];
240
- if (args[i] === "--limit" && args[i + 1]) limit = parseInt(args[++i]);
241
- if (args[i] === "--offset" && args[i + 1]) offset = parseInt(args[++i]);
157
+ for (let i = 0; i < argv.length; i++) {
158
+ if (argv[i] === "--source" && argv[i + 1]) sourceDir = argv[++i];
159
+ if (argv[i] === "--limit" && argv[i + 1]) limit = parseInt(argv[++i]);
160
+ if (argv[i] === "--offset" && argv[i + 1]) offset = parseInt(argv[++i]);
242
161
  }
243
162
 
244
163
  const fullSourceDir = join(GAME_ROOT, sourceDir);
@@ -274,10 +193,9 @@ async function main() {
274
193
  }
275
194
 
276
195
  if (!dryRun) {
277
- const online = await comfyHealth();
196
+ const online = await comfyHealth(COMFY_URL);
278
197
  if (!online) {
279
- console.error("\x1b[31mError:\x1b[0m ComfyUI not reachable at " + COMFY_URL);
280
- process.exit(1);
198
+ throw new Error("ComfyUI not reachable at " + COMFY_URL);
281
199
  }
282
200
  console.log("\x1b[32m✓\x1b[0m ComfyUI online");
283
201
  }
@@ -298,11 +216,11 @@ async function main() {
298
216
  const startMs = Date.now();
299
217
 
300
218
  // Upload source image to ComfyUI
301
- const uploadedName = await uploadImage(srcPath, file);
219
+ const uploadedName = await uploadImage(srcPath, file, COMFY_URL);
302
220
 
303
221
  // Build and submit img2img workflow
304
222
  const { nodes } = buildImg2ImgWorkflow(uploadedName, DEFAULTS.seed);
305
- const result = await submitAndWait(nodes);
223
+ const result = await submitAndWait(nodes, COMFY_URL, { clientPrefix: 'sdl-paint' });
306
224
  const elapsed = Date.now() - startMs;
307
225
 
308
226
  // Find output image
@@ -325,7 +243,7 @@ async function main() {
325
243
  }
326
244
 
327
245
  // Download and save with original filename
328
- const imgData = await downloadImage(imageFile, imageSubfolder);
246
+ const imgData = await downloadImage(imageFile, imageSubfolder, COMFY_URL);
329
247
  await writeFile(join(outDir, file), imgData);
330
248
 
331
249
  const kb = Math.round(imgData.length / 1024);
@@ -343,7 +261,10 @@ async function main() {
343
261
  if (dryRun) console.log(" (dry run — no images processed)");
344
262
  }
345
263
 
346
- main().catch((err) => {
347
- console.error(err);
348
- process.exit(1);
349
- });
264
+ // Direct execution guard
265
+ if (process.argv[1] && (process.argv[1].endsWith('painterly.js') || process.argv[1].endsWith('painterly'))) {
266
+ run().catch((err) => {
267
+ console.error(err.message || err);
268
+ process.exit(1);
269
+ });
270
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * snapshot.js — Create, list, show, and diff dataset snapshots.
5
+ *
6
+ * Usage:
7
+ * sdlab snapshot create [--profile <name>] [--project <name>]
8
+ * sdlab snapshot list [--project <name>]
9
+ * sdlab snapshot show <snapshot-id> [--project <name>]
10
+ * sdlab snapshot diff <id-a> <id-b> [--project <name>]
11
+ */
12
+
13
+ import { readFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { existsSync } from 'node:fs';
16
+ import { getProjectName } from '../lib/args.js';
17
+ import { REPO_ROOT } from '../lib/paths.js';
18
+ import { loadSelectionProfile } from '../lib/config.js';
19
+ import { createSnapshot, loadSnapshot, listSnapshots, diffSnapshots } from '../lib/snapshot.js';
20
+ import { evaluateEligibility } from '../lib/eligibility.js';
21
+ import { categorizeExclusions } from '../lib/eligibility.js';
22
+
23
+ export async function run(argv = process.argv.slice(2)) {
24
+ const projectName = getProjectName(argv);
25
+ const projectRoot = join(REPO_ROOT, 'projects', projectName);
26
+ const subcommand = argv.find(a => !a.startsWith('--')) || 'list';
27
+
28
+ // Strip subcommand from argv for flag parsing
29
+ const args = argv.filter(a => a !== subcommand);
30
+
31
+ if (subcommand === 'create') {
32
+ const profileIdx = args.indexOf('--profile');
33
+ const profileId = profileIdx >= 0 ? args[profileIdx + 1] : null;
34
+ const profile = loadSelectionProfile(projectRoot, profileId);
35
+
36
+ console.log(`\x1b[1msdlab snapshot create\x1b[0m — ${projectName}`);
37
+ console.log(` Profile: ${profileId || 'training-default (built-in)'}`);
38
+ console.log('');
39
+
40
+ const result = await createSnapshot(projectRoot, profile);
41
+
42
+ console.log(` \x1b[32m✓\x1b[0m Snapshot: ${result.snapshotId}`);
43
+ console.log(` Included: ${result.included}`);
44
+ console.log(` Excluded: ${result.excluded}`);
45
+
46
+ // Read and display summary
47
+ const summary = JSON.parse(
48
+ await readFile(join(projectRoot, 'snapshots', result.snapshotId, 'summary.json'), 'utf-8')
49
+ );
50
+ console.log('\n Lane distribution:');
51
+ for (const [lane, count] of Object.entries(summary.lane_distribution).sort((a, b) => b[1] - a[1])) {
52
+ console.log(` ${lane}: ${count}`);
53
+ }
54
+ console.log('\n Faction distribution:');
55
+ for (const [f, c] of Object.entries(summary.faction_distribution).sort((a, b) => b[1] - a[1])) {
56
+ console.log(` ${f}: ${c}`);
57
+ }
58
+
59
+ // Read and categorize exclusions
60
+ const excludedRaw = await readFile(
61
+ join(projectRoot, 'snapshots', result.snapshotId, 'excluded.jsonl'), 'utf-8'
62
+ );
63
+ const excluded = excludedRaw.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
64
+ const categories = categorizeExclusions(excluded);
65
+ console.log('\n Exclusion reasons:');
66
+ for (const [cat, count] of Object.entries(categories)) {
67
+ if (count > 0) console.log(` ${cat}: ${count}`);
68
+ }
69
+
70
+ } else if (subcommand === 'list') {
71
+ const snapshots = await listSnapshots(projectRoot);
72
+ if (snapshots.length === 0) {
73
+ console.log('No snapshots found. Run: sdlab snapshot create');
74
+ return;
75
+ }
76
+ console.log(`\x1b[1mSnapshots\x1b[0m — ${projectName}\n`);
77
+ for (const s of snapshots) {
78
+ console.log(` ${s.id} included=${s.included} excluded=${s.excluded} fp=${s.fingerprint} ${s.created_at}`);
79
+ }
80
+
81
+ } else if (subcommand === 'show') {
82
+ const snapshotId = args.find(a => a.startsWith('snap-'));
83
+ if (!snapshotId) throw new Error('Usage: sdlab snapshot show <snapshot-id>');
84
+
85
+ const manifest = await loadSnapshot(projectRoot, snapshotId);
86
+ console.log(`\x1b[1mSnapshot\x1b[0m — ${snapshotId}\n`);
87
+ console.log(` Created: ${manifest.created_at}`);
88
+ console.log(` Project: ${manifest.project_name}`);
89
+ console.log(` Frozen: ${manifest.frozen}`);
90
+ console.log(` Records: ${manifest.counts.total_records}`);
91
+ console.log(` Included: ${manifest.counts.included}`);
92
+ console.log(` Excluded: ${manifest.counts.excluded}`);
93
+ console.log(` Config fingerprint: ${manifest.config_fingerprint}`);
94
+ console.log('');
95
+ console.log(' Selection profile:');
96
+ for (const [k, v] of Object.entries(manifest.selection_profile)) {
97
+ console.log(` ${k}: ${JSON.stringify(v)}`);
98
+ }
99
+
100
+ } else if (subcommand === 'diff') {
101
+ const snapIds = args.filter(a => a.startsWith('snap-'));
102
+ if (snapIds.length < 2) throw new Error('Usage: sdlab snapshot diff <id-a> <id-b>');
103
+
104
+ const diff = await diffSnapshots(projectRoot, snapIds[0], snapIds[1]);
105
+ console.log(`\x1b[1mSnapshot diff\x1b[0m — ${snapIds[0]} vs ${snapIds[1]}\n`);
106
+ console.log(` ${snapIds[0]}: ${diff.countA} records`);
107
+ console.log(` ${snapIds[1]}: ${diff.countB} records`);
108
+ console.log(` Shared: ${diff.shared.length}`);
109
+ console.log(` Added in ${snapIds[1]}: ${diff.added.length}`);
110
+ console.log(` Removed from ${snapIds[0]}: ${diff.removed.length}`);
111
+
112
+ if (diff.added.length > 0 && diff.added.length <= 20) {
113
+ console.log('\n Added:');
114
+ for (const id of diff.added) console.log(` + ${id}`);
115
+ }
116
+ if (diff.removed.length > 0 && diff.removed.length <= 20) {
117
+ console.log('\n Removed:');
118
+ for (const id of diff.removed) console.log(` - ${id}`);
119
+ }
120
+
121
+ } else {
122
+ throw new Error(`Unknown subcommand: ${subcommand}. Use: create, list, show, diff`);
123
+ }
124
+ }
125
+
126
+ // Direct execution guard
127
+ if (process.argv[1] && (process.argv[1].endsWith('snapshot.js') || process.argv[1].endsWith('snapshot'))) {
128
+ run().catch((err) => {
129
+ console.error(err.message || err);
130
+ process.exit(1);
131
+ });
132
+ }
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * split.js — Build, show, and audit dataset splits.
5
+ *
6
+ * Usage:
7
+ * sdlab split build [--snapshot <id>] [--profile <name>] [--project <name>]
8
+ * sdlab split list [--project <name>]
9
+ * sdlab split show <split-id> [--project <name>]
10
+ * sdlab split audit <split-id> [--project <name>]
11
+ */
12
+
13
+ import { readFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { getProjectName } from '../lib/args.js';
16
+ import { REPO_ROOT } from '../lib/paths.js';
17
+ import { loadSplitProfile } from '../lib/config.js';
18
+ import { listSnapshots } from '../lib/snapshot.js';
19
+ import { createSplit, loadSplit, loadSplitAudit, listSplits } from '../lib/split.js';
20
+
21
+ export async function run(argv = process.argv.slice(2)) {
22
+ const projectName = getProjectName(argv);
23
+ const projectRoot = join(REPO_ROOT, 'projects', projectName);
24
+ const subcommand = argv.find(a => !a.startsWith('--')) || 'list';
25
+ const args = argv.filter(a => a !== subcommand);
26
+
27
+ if (subcommand === 'build') {
28
+ // Find snapshot ID
29
+ let snapshotId;
30
+ const snapIdx = args.indexOf('--snapshot');
31
+ if (snapIdx >= 0) {
32
+ snapshotId = args[snapIdx + 1];
33
+ } else {
34
+ // Use latest snapshot
35
+ const snapshots = await listSnapshots(projectRoot);
36
+ if (snapshots.length === 0) {
37
+ throw new Error('No snapshots found. Run: sdlab snapshot create');
38
+ }
39
+ snapshotId = snapshots[snapshots.length - 1].id;
40
+ }
41
+
42
+ const profileIdx = args.indexOf('--profile');
43
+ const profileId = profileIdx >= 0 ? args[profileIdx + 1] : null;
44
+ const profile = loadSplitProfile(projectRoot, profileId);
45
+
46
+ console.log(`\x1b[1msdlab split build\x1b[0m — ${projectName}`);
47
+ console.log(` Snapshot: ${snapshotId}`);
48
+ console.log(` Profile: ${profileId || 'balanced-default (built-in)'}`);
49
+ console.log(` Strategy: ${profile.strategy}`);
50
+ console.log(` Ratios: ${profile.train_ratio}/${profile.val_ratio}/${profile.test_ratio}`);
51
+ console.log(` Seed: ${profile.seed}`);
52
+ console.log('');
53
+
54
+ const result = await createSplit(projectRoot, snapshotId, profile);
55
+
56
+ console.log(` \x1b[32m✓\x1b[0m Split: ${result.splitId}`);
57
+ console.log(` Train: ${result.train}`);
58
+ console.log(` Val: ${result.val}`);
59
+ console.log(` Test: ${result.test}`);
60
+
61
+ // Show audit summary
62
+ const audit = await loadSplitAudit(projectRoot, result.splitId);
63
+ console.log('');
64
+ if (audit.leakage_check.passed) {
65
+ console.log(` \x1b[32m✓\x1b[0m No subject leakage detected`);
66
+ } else {
67
+ console.log(` \x1b[31m✗\x1b[0m Subject leakage detected:`);
68
+ for (const issue of audit.leakage_check.issues) {
69
+ console.log(` family="${issue.family}" (${issue.record_count} records) → ${issue.leaked_to.join(', ')}`);
70
+ }
71
+ }
72
+
73
+ console.log(` Families: ${audit.family_count} (train=${audit.families_per_split.train}, val=${audit.families_per_split.val}, test=${audit.families_per_split.test})`);
74
+
75
+ } else if (subcommand === 'list') {
76
+ const splits = await listSplits(projectRoot);
77
+ if (splits.length === 0) {
78
+ console.log('No splits found. Run: sdlab split build');
79
+ return;
80
+ }
81
+ console.log(`\x1b[1mSplits\x1b[0m — ${projectName}\n`);
82
+ for (const s of splits) {
83
+ console.log(` ${s.id} snap=${s.snapshot_id} train=${s.train} val=${s.val} test=${s.test} families=${s.families} ${s.created_at}`);
84
+ }
85
+
86
+ } else if (subcommand === 'show') {
87
+ const splitId = args.find(a => a.startsWith('split-'));
88
+ if (!splitId) throw new Error('Usage: sdlab split show <split-id>');
89
+
90
+ const manifest = await loadSplit(projectRoot, splitId);
91
+ console.log(`\x1b[1mSplit\x1b[0m — ${splitId}\n`);
92
+ console.log(` Created: ${manifest.created_at}`);
93
+ console.log(` Snapshot: ${manifest.snapshot_id}`);
94
+ console.log(` Records: ${manifest.counts.total_records}`);
95
+ console.log(` Families: ${manifest.counts.total_families}`);
96
+ console.log(` Train: ${manifest.counts.train}`);
97
+ console.log(` Val: ${manifest.counts.val}`);
98
+ console.log(` Test: ${manifest.counts.test}`);
99
+ console.log('');
100
+ console.log(' Profile:');
101
+ for (const [k, v] of Object.entries(manifest.profile)) {
102
+ console.log(` ${k}: ${JSON.stringify(v)}`);
103
+ }
104
+ if (manifest.warnings.length > 0) {
105
+ console.log('');
106
+ console.log(' Warnings:');
107
+ for (const w of manifest.warnings) {
108
+ console.log(` ⚠ ${w}`);
109
+ }
110
+ }
111
+
112
+ } else if (subcommand === 'audit') {
113
+ const splitId = args.find(a => a.startsWith('split-'));
114
+ if (!splitId) throw new Error('Usage: sdlab split audit <split-id>');
115
+
116
+ const audit = await loadSplitAudit(projectRoot, splitId);
117
+ console.log(`\x1b[1mSplit audit\x1b[0m — ${splitId}\n`);
118
+
119
+ // Leakage check
120
+ if (audit.leakage_check.passed) {
121
+ console.log(` \x1b[32m✓\x1b[0m Leakage check: PASSED`);
122
+ } else {
123
+ console.log(` \x1b[31m✗\x1b[0m Leakage check: FAILED`);
124
+ for (const issue of audit.leakage_check.issues) {
125
+ console.log(` family="${issue.family}" (${issue.record_count} records) leaked to: ${issue.leaked_to.join(', ')}`);
126
+ }
127
+ }
128
+
129
+ // Family distribution
130
+ console.log(`\n Families: ${audit.family_count}`);
131
+ console.log(` Train: ${audit.families_per_split.train}`);
132
+ console.log(` Val: ${audit.families_per_split.val}`);
133
+ console.log(` Test: ${audit.families_per_split.test}`);
134
+
135
+ // Lane balance
136
+ console.log('\n Lane balance:');
137
+ console.log(` ${'Lane'.padEnd(20)} ${'Total'.padStart(6)} ${'Train%'.padStart(8)} ${'Val%'.padStart(8)} ${'Test%'.padStart(8)}`);
138
+ console.log(` ${'─'.repeat(52)}`);
139
+ for (const [lane, b] of Object.entries(audit.lane_balance).sort((a, b) => b[1].total - a[1].total)) {
140
+ console.log(` ${lane.padEnd(20)} ${String(b.total).padStart(6)} ${String(b.train_pct + '%').padStart(8)} ${String(b.val_pct + '%').padStart(8)} ${String(b.test_pct + '%').padStart(8)}`);
141
+ }
142
+
143
+ } else {
144
+ throw new Error(`Unknown subcommand: ${subcommand}. Use: build, list, show, audit`);
145
+ }
146
+ }
147
+
148
+ // Direct execution guard
149
+ if (process.argv[1] && (process.argv[1].endsWith('split.js') || process.argv[1].endsWith('split'))) {
150
+ run().catch((err) => {
151
+ console.error(err.message || err);
152
+ process.exit(1);
153
+ });
154
+ }
@@ -0,0 +1,97 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "rules": [
4
+ {
5
+ "id": "STR-001",
6
+ "category": "structure",
7
+ "desc": "Structural plausibility — buildings look like they could stand",
8
+ "dims": ["structural_plausibility"],
9
+ "faction_specific": false,
10
+ "rationale_pass": "Structure reads as plausible — load paths and massing are believable (${scorePct}%)",
11
+ "rationale_fail": "Structure implausible — walls, spans, or cantilevers defy credible engineering (${scorePct}%)"
12
+ },
13
+ {
14
+ "id": "MAT-001",
15
+ "category": "material",
16
+ "desc": "Material consistency — stone reads as stone, wood as wood, metal as metal",
17
+ "dims": ["material_consistency"],
18
+ "faction_specific": false,
19
+ "rationale_pass": "Materials read correctly — surfaces have appropriate weight, texture, and joinery (${scorePct}%)",
20
+ "rationale_fail": "Material reads wrong — surface lacks expected weight, grain, or construction logic (${scorePct}%)"
21
+ },
22
+ {
23
+ "id": "PER-001",
24
+ "category": "perspective",
25
+ "desc": "Perspective accuracy — no impossible geometry or broken vanishing points",
26
+ "dims": ["perspective_accuracy"],
27
+ "faction_specific": false,
28
+ "rationale_pass": "Perspective is accurate — vanishing points consistent, no spatial contradictions (${scorePct}%)",
29
+ "rationale_fail": "Perspective broken — impossible geometry, misaligned vanishing points, or spatial contradictions (${scorePct}%)"
30
+ },
31
+ {
32
+ "id": "LGT-001",
33
+ "category": "lighting",
34
+ "desc": "Lighting and mood — atmosphere serves narrative intent",
35
+ "dims": ["lighting_mood"],
36
+ "faction_specific": false,
37
+ "rationale_pass": "Lighting carries mood — shadow, bounce, and atmosphere serve the narrative intent (${scorePct}%)",
38
+ "rationale_fail": "Lighting flat or contradictory — no mood, multiple shadow sources, or atmosphere works against intent (${scorePct}%)"
39
+ },
40
+ {
41
+ "id": "SCL-001",
42
+ "category": "scale",
43
+ "desc": "Scale consistency — human-scale reference readable",
44
+ "dims": ["scale_consistency"],
45
+ "faction_specific": false,
46
+ "rationale_pass": "Scale reads correctly — doors, windows, and elements imply consistent human proportion (${scorePct}%)",
47
+ "rationale_fail": "Scale inconsistent — elements contradict each other or lack human-scale reference (${scorePct}%)"
48
+ },
49
+ {
50
+ "id": "ERA-001",
51
+ "category": "era",
52
+ "desc": "Era and tech-level coherence — no anachronisms within the depicted period",
53
+ "dims": ["era_coherence"],
54
+ "faction_specific": false,
55
+ "rationale_pass": "Era coherent — construction methods, ornament, and technology match the depicted period (${scorePct}%)",
56
+ "rationale_fail": "Anachronism present — elements from incompatible time periods or tech levels mixed (${scorePct}%)"
57
+ },
58
+ {
59
+ "id": "WEA-001",
60
+ "category": "weathering",
61
+ "desc": "Weathering and aging appropriate to era, climate, and material",
62
+ "dims": ["weathering_logic"],
63
+ "faction_specific": false,
64
+ "rationale_pass": "Weathering reads correctly — aging pattern matches material, climate, and implied age (${scorePct}%)",
65
+ "rationale_fail": "Weathering illogical — wear doesn't match material behavior, climate, or timeframe (${scorePct}%)"
66
+ },
67
+ {
68
+ "id": "STY-001",
69
+ "category": "style",
70
+ "desc": "Style consistency within the project — rendering and design language unified",
71
+ "dims": ["style_consistency"],
72
+ "faction_specific": false,
73
+ "rationale_pass": "Style consistent — rendering approach and design language match the project standard (${scorePct}%)",
74
+ "rationale_fail": "Style inconsistent — rendering or design language breaks from project standard (${scorePct}%)"
75
+ },
76
+ {
77
+ "id": "DET-001",
78
+ "category": "detail",
79
+ "desc": "Detail density appropriate to distance and scale of view",
80
+ "dims": ["detail_density"],
81
+ "faction_specific": false,
82
+ "lanes": ["structural_detail"],
83
+ "rationale_pass": "Detail density appropriate — close-ups show construction logic, wide shots stay readable (${scorePct}%)",
84
+ "rationale_fail": "Detail density wrong — close-up lacks construction detail or wide shot is noisy and unreadable (${scorePct}%)"
85
+ },
86
+ {
87
+ "id": "RUI-001",
88
+ "category": "ruin",
89
+ "desc": "Ruin logic — decay pattern follows structural weaknesses and material behavior",
90
+ "dims": ["ruin_logic"],
91
+ "faction_specific": false,
92
+ "lanes": ["ruin"],
93
+ "rationale_pass": "Ruin logic holds — collapse follows structural weakness, material decay is physically plausible (${scorePct}%)",
94
+ "rationale_fail": "Ruin logic fails — decay is arbitrary, structure collapses where it shouldn't or survives where it can't (${scorePct}%)"
95
+ }
96
+ ]
97
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "default_lane": "exterior",
3
+ "lanes": [
4
+ {
5
+ "id": "exterior",
6
+ "label": "Building Exterior",
7
+ "description": "Building facades, elevations, full exterior views, architectural massing",
8
+ "id_patterns": ["^ext_", "^building_", "^facade_"]
9
+ },
10
+ {
11
+ "id": "interior",
12
+ "label": "Interior Space",
13
+ "description": "Rooms, halls, chambers, functional interiors with spatial atmosphere",
14
+ "id_patterns": ["^int_", "^room_", "^hall_"]
15
+ },
16
+ {
17
+ "id": "streetscape",
18
+ "label": "Streetscape / Urban",
19
+ "description": "Street-level views, district overviews, urban fabric and city scenes",
20
+ "id_patterns": ["^street_", "^district_", "^urban_"]
21
+ },
22
+ {
23
+ "id": "structural_detail",
24
+ "label": "Structural Detail",
25
+ "description": "Ornamental elements, material studies, construction details, decorative motifs",
26
+ "id_patterns": ["^detail_", "^ornament_", "^material_"]
27
+ },
28
+ {
29
+ "id": "ruin",
30
+ "label": "Ruin / Decay",
31
+ "description": "Ruined structures, abandoned buildings, post-collapse environments",
32
+ "id_patterns": ["^ruin_", "^decay_", "^abandoned_"]
33
+ },
34
+ {
35
+ "id": "landscape",
36
+ "label": "Landscape / Vista",
37
+ "description": "Wide establishing shots, panoramic views, architecture in landscape context",
38
+ "id_patterns": ["^landscape_", "^vista_", "^panorama_"]
39
+ }
40
+ ]
41
+ }