@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.1
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 +178 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +71 -0
- package/README.md +82 -9
- package/SECURITY.md +6 -5
- package/bin/sdlab.js +359 -11
- package/lib/adapters/comfyui-runner.js +170 -0
- package/lib/adapters/comfyui-workflows.js +163 -0
- package/lib/adapters/diffusers-lora.js +7 -2
- package/lib/adapters/generic-image-caption.js +6 -1
- package/lib/args.js +200 -13
- package/lib/batch-compiler.js +122 -0
- package/lib/batch-modes.js +111 -0
- package/lib/batch-runs.js +237 -0
- package/lib/batch-sheet-render.js +209 -0
- package/lib/brief-compiler.js +625 -0
- package/lib/brief-render.js +205 -0
- package/lib/card.js +11 -4
- package/lib/comfyui.js +100 -6
- package/lib/config.js +68 -3
- package/lib/critique-engine.js +413 -0
- package/lib/critique-render.js +171 -0
- package/lib/deprecation.js +1 -1
- package/lib/eval-pack.js +94 -4
- package/lib/eval-runs.js +42 -3
- package/lib/export.js +144 -34
- package/lib/generated-provenance.js +87 -0
- package/lib/implementation-packs.js +15 -1
- package/lib/log.js +41 -4
- package/lib/paths.js +72 -3
- package/lib/refine-briefs.js +336 -0
- package/lib/reingest-selected.js +184 -0
- package/lib/reingest.js +15 -1
- package/lib/run-summary.js +132 -0
- package/lib/runtime-runs.js +218 -0
- package/lib/selections.js +375 -0
- package/lib/snapshot.js +100 -7
- package/lib/split.js +175 -17
- package/lib/training-manifests.js +17 -3
- package/lib/training-packages.js +28 -14
- package/lib/workflow-profiles.js +161 -0
- package/package.json +13 -4
- package/scripts/batch-generate.js +176 -0
- package/scripts/batch-sheet.js +46 -0
- package/scripts/batch-show.js +86 -0
- package/scripts/brief-compile.js +134 -0
- package/scripts/brief-show.js +53 -0
- package/scripts/canon-bind.js +7 -9
- package/scripts/card.js +22 -25
- package/scripts/compare.js +38 -33
- package/scripts/critique-show.js +50 -0
- package/scripts/critique.js +70 -0
- package/scripts/curate.js +91 -46
- package/scripts/doctor.js +172 -6
- package/scripts/eligibility.js +17 -14
- package/scripts/eval-pack.js +13 -11
- package/scripts/eval-run.js +30 -29
- package/scripts/export.js +27 -31
- package/scripts/generate-controlnet.js +31 -17
- package/scripts/generate-identity.js +37 -20
- package/scripts/generate-ipadapter.js +22 -16
- package/scripts/generate.js +68 -13
- package/scripts/implementation-pack.js +19 -14
- package/scripts/init.js +137 -17
- package/scripts/painterly.js +8 -10
- package/scripts/refine.js +119 -0
- package/scripts/reingest-selected.js +78 -0
- package/scripts/reingest.js +37 -18
- package/scripts/run-generate.js +76 -0
- package/scripts/run-list.js +58 -0
- package/scripts/run-show.js +63 -0
- package/scripts/select.js +119 -0
- package/scripts/selection-show.js +88 -0
- package/scripts/snapshot.js +22 -24
- package/scripts/split.js +26 -31
- package/scripts/training-manifest.js +29 -24
- package/scripts/training-package.js +24 -20
- package/scripts/training-profile.js +13 -8
- package/scripts/workflow-list.js +61 -0
- package/scripts/workflow-show.js +97 -0
- package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/architecture/terminology.json +10 -8
- package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
- package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
- package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/character-design/project.json +4 -2
- package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
- package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
- package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/creature-design/project.json +4 -2
- package/templates/domains/creature-design/terminology.json +1 -0
- package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
- package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
- package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/game-art/project.json +4 -2
- package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
- package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
- package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/vehicle-mech/project.json +4 -2
- package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
- package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
- package/scripts/bulk-curate-wave2-5.js +0 -138
- package/scripts/bulk-curate-waves11-18.js +0 -413
- package/scripts/curate-wave25.js +0 -209
- package/scripts/migrate-records.js +0 -141
- package/templates/inputs/prompts/example-wave.json +0 -27
package/lib/split.js
CHANGED
|
@@ -13,8 +13,9 @@ import { join } from 'node:path';
|
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
14
|
import { createHash, randomBytes } from 'node:crypto';
|
|
15
15
|
import { loadProjectConfig, loadSplitProfile, detectLane } from './config.js';
|
|
16
|
-
import { loadSnapshot, loadSnapshotIncluded } from './snapshot.js';
|
|
16
|
+
import { loadSnapshot, loadSnapshotIncluded, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
17
17
|
import { loadRecord } from './records.js';
|
|
18
|
+
import { inputError } from './errors.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Mulberry32 — deterministic 32-bit PRNG.
|
|
@@ -119,6 +120,74 @@ function buildAncestorMap(records) {
|
|
|
119
120
|
return ancestorMap;
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Allocate `total` items across train/val/test using largest-remainder rounding,
|
|
125
|
+
* guaranteeing each partition receives at least 1 when total >= 3.
|
|
126
|
+
*/
|
|
127
|
+
function allocateLargestRemainder(total, profile) {
|
|
128
|
+
if (total <= 0) return { train: 0, val: 0, test: 0 };
|
|
129
|
+
|
|
130
|
+
const ratios = {
|
|
131
|
+
train: profile.train_ratio,
|
|
132
|
+
val: profile.val_ratio,
|
|
133
|
+
test: profile.test_ratio,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Ideal fractional counts
|
|
137
|
+
const ideal = {
|
|
138
|
+
train: total * ratios.train,
|
|
139
|
+
val: total * ratios.val,
|
|
140
|
+
test: total * ratios.test,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Floor + sort by fractional remainder desc (tie-break by partition name)
|
|
144
|
+
const base = {
|
|
145
|
+
train: Math.floor(ideal.train),
|
|
146
|
+
val: Math.floor(ideal.val),
|
|
147
|
+
test: Math.floor(ideal.test),
|
|
148
|
+
};
|
|
149
|
+
let assigned = base.train + base.val + base.test;
|
|
150
|
+
|
|
151
|
+
const remainders = [
|
|
152
|
+
['train', ideal.train - base.train],
|
|
153
|
+
['val', ideal.val - base.val],
|
|
154
|
+
['test', ideal.test - base.test],
|
|
155
|
+
].sort((a, b) => {
|
|
156
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
157
|
+
return a[0].localeCompare(b[0]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
let ri = 0;
|
|
161
|
+
while (assigned < total) {
|
|
162
|
+
base[remainders[ri % remainders.length][0]]++;
|
|
163
|
+
assigned++;
|
|
164
|
+
ri++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Min-1 rebalance: if total >= 3 and any partition is 0, take from the
|
|
168
|
+
// largest partition. Deterministic — always steal from 'train' first when tied.
|
|
169
|
+
if (total >= 3) {
|
|
170
|
+
const partitionOrder = ['train', 'val', 'test'];
|
|
171
|
+
for (const part of partitionOrder) {
|
|
172
|
+
if (base[part] === 0) {
|
|
173
|
+
// find largest (ties broken by name asc to stay deterministic)
|
|
174
|
+
const donor = partitionOrder
|
|
175
|
+
.filter(p => p !== part && base[p] > 1)
|
|
176
|
+
.sort((a, b) => {
|
|
177
|
+
if (base[b] !== base[a]) return base[b] - base[a];
|
|
178
|
+
return a.localeCompare(b);
|
|
179
|
+
})[0];
|
|
180
|
+
if (donor) {
|
|
181
|
+
base[donor]--;
|
|
182
|
+
base[part]++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return base;
|
|
189
|
+
}
|
|
190
|
+
|
|
122
191
|
/**
|
|
123
192
|
* Create a split from a snapshot.
|
|
124
193
|
*
|
|
@@ -156,14 +225,34 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
|
|
|
156
225
|
familyMap.get(family).push(record);
|
|
157
226
|
}
|
|
158
227
|
|
|
159
|
-
//
|
|
228
|
+
// D-002: Deterministic primary lane per family = majority vote; ties
|
|
229
|
+
// broken by lexicographic lane name. Record the per-family decision
|
|
230
|
+
// for the audit so the chosen lane is explainable.
|
|
160
231
|
const familyLane = new Map();
|
|
232
|
+
const familyLaneDecisions = [];
|
|
161
233
|
for (const [family, recs] of familyMap) {
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
234
|
+
const laneCounts = new Map();
|
|
235
|
+
for (const rec of recs) {
|
|
236
|
+
const prompt = rec.provenance?.prompt || '';
|
|
237
|
+
const recLane = detectLane(rec.id, prompt, config.lanes);
|
|
238
|
+
laneCounts.set(recLane, (laneCounts.get(recLane) || 0) + 1);
|
|
239
|
+
}
|
|
240
|
+
// Sort: count desc, then lane name asc. Deterministic winner.
|
|
241
|
+
const ranked = [...laneCounts.entries()].sort((a, b) => {
|
|
242
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
243
|
+
return a[0].localeCompare(b[0]);
|
|
244
|
+
});
|
|
245
|
+
const [chosenLane, chosenCount] = ranked[0];
|
|
246
|
+
familyLane.set(family, chosenLane);
|
|
247
|
+
familyLaneDecisions.push({
|
|
248
|
+
family,
|
|
249
|
+
chosen_lane: chosenLane,
|
|
250
|
+
chosen_count: chosenCount,
|
|
251
|
+
record_count: recs.length,
|
|
252
|
+
lane_counts: Object.fromEntries(laneCounts),
|
|
253
|
+
});
|
|
166
254
|
}
|
|
255
|
+
familyLaneDecisions.sort((a, b) => a.family.localeCompare(b.family));
|
|
167
256
|
|
|
168
257
|
// Group families by lane
|
|
169
258
|
const laneGroups = new Map(); // lane → [family]
|
|
@@ -173,7 +262,8 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
|
|
|
173
262
|
}
|
|
174
263
|
|
|
175
264
|
// Split families per lane using seeded PRNG
|
|
176
|
-
|
|
265
|
+
// D-011: seed:0 is a valid seed; use nullish coalescing so 0 is honored.
|
|
266
|
+
const rng = mulberry32(profile.seed ?? 42);
|
|
177
267
|
const trainFamilies = new Set();
|
|
178
268
|
const valFamilies = new Set();
|
|
179
269
|
const testFamilies = new Set();
|
|
@@ -191,11 +281,17 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
|
|
|
191
281
|
continue;
|
|
192
282
|
}
|
|
193
283
|
|
|
194
|
-
//
|
|
284
|
+
// D-004: Largest-remainder allocation guarantees min-1 per partition
|
|
285
|
+
// when total >= 3, and emits a warning if any partition ends up at 0.
|
|
195
286
|
const total = shuffled.length;
|
|
196
|
-
const trainCount
|
|
197
|
-
|
|
198
|
-
|
|
287
|
+
const { train: trainCount, val: valCount, test: testCount } =
|
|
288
|
+
allocateLargestRemainder(total, profile);
|
|
289
|
+
|
|
290
|
+
if (total >= 3 && (trainCount === 0 || valCount === 0 || testCount === 0)) {
|
|
291
|
+
warnings.push(
|
|
292
|
+
`lane "${lane}": partition size hit 0 after allocation (train=${trainCount}, val=${valCount}, test=${testCount}) — ratios may be extreme`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
199
295
|
|
|
200
296
|
let idx = 0;
|
|
201
297
|
for (let i = 0; i < trainCount && idx < total; i++, idx++) {
|
|
@@ -226,7 +322,14 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
|
|
|
226
322
|
if (trainFamilies.has(family)) trainRecords.push(record.id);
|
|
227
323
|
else if (valFamilies.has(family)) valRecords.push(record.id);
|
|
228
324
|
else if (testFamilies.has(family)) testRecords.push(record.id);
|
|
229
|
-
else
|
|
325
|
+
else {
|
|
326
|
+
// D-003: unassigned family means the familyMap build pass disagreed
|
|
327
|
+
// with the assignment pass — never silently rescue; surface the bug.
|
|
328
|
+
throw new Error(
|
|
329
|
+
`unassigned family for record ${record.id} (family=${family}) — ` +
|
|
330
|
+
`this indicates a bug in family resolution; the record was not placed in train/val/test`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
230
333
|
}
|
|
231
334
|
|
|
232
335
|
// Sort for deterministic output
|
|
@@ -237,9 +340,9 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
|
|
|
237
340
|
// Leakage audit
|
|
238
341
|
const leakageIssues = auditLeakage(familyMap, trainRecords, valRecords, testRecords);
|
|
239
342
|
|
|
240
|
-
// Lane balance audit
|
|
343
|
+
// Lane balance audit (DB-005: includes deviation from profile ratios)
|
|
241
344
|
const laneBalance = auditLaneBalance(
|
|
242
|
-
records, trainRecords, valRecords, testRecords, config.lanes
|
|
345
|
+
records, trainRecords, valRecords, testRecords, config.lanes, profile
|
|
243
346
|
);
|
|
244
347
|
|
|
245
348
|
// Write output (skip if dry run)
|
|
@@ -248,9 +351,19 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
|
|
|
248
351
|
return { splitId, train: trainRecords.length, val: valRecords.length, test: testRecords.length };
|
|
249
352
|
}
|
|
250
353
|
const splitDir = join(projectRoot, 'splits', splitId);
|
|
354
|
+
// D-005: refuse to silently overwrite on ID collision.
|
|
355
|
+
if (existsSync(splitDir)) {
|
|
356
|
+
throw inputError(
|
|
357
|
+
'SPLIT_ID_COLLISION',
|
|
358
|
+
`Split directory already exists: ${splitDir}`,
|
|
359
|
+
'Retry to generate a fresh split ID.',
|
|
360
|
+
);
|
|
361
|
+
}
|
|
251
362
|
await mkdir(splitDir, { recursive: true });
|
|
252
363
|
|
|
253
364
|
const manifest = {
|
|
365
|
+
// DB-001 / DB-006: stamp manifest schema version
|
|
366
|
+
schema_version: SCHEMA_VERSION,
|
|
254
367
|
split_id: splitId,
|
|
255
368
|
created_at: new Date().toISOString(),
|
|
256
369
|
created_by: 'sdlab-split-v1',
|
|
@@ -280,6 +393,9 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
|
|
|
280
393
|
val: valFamilies.size,
|
|
281
394
|
test: testFamilies.size,
|
|
282
395
|
},
|
|
396
|
+
// D-002: record the deterministic per-family lane decision for replay.
|
|
397
|
+
family_lane_decisions: familyLaneDecisions,
|
|
398
|
+
warnings,
|
|
283
399
|
};
|
|
284
400
|
|
|
285
401
|
await writeFile(join(splitDir, 'split.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
@@ -322,8 +438,13 @@ function auditLeakage(familyMap, trainIds, valIds, testIds) {
|
|
|
322
438
|
|
|
323
439
|
/**
|
|
324
440
|
* Audit lane balance across splits.
|
|
441
|
+
*
|
|
442
|
+
* DB-005: includes deviation-from-profile metric per lane + overall score.
|
|
443
|
+
* A lane that shows train=100/val=0/test=0 and another at 33/33/33 look
|
|
444
|
+
* equally "balanced" by percentage alone — deviation makes the real gap
|
|
445
|
+
* between intent and reality visible at a glance.
|
|
325
446
|
*/
|
|
326
|
-
function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig) {
|
|
447
|
+
function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig, profile) {
|
|
327
448
|
const trainSet = new Set(trainIds);
|
|
328
449
|
const valSet = new Set(valIds);
|
|
329
450
|
const testSet = new Set(testIds);
|
|
@@ -339,14 +460,49 @@ function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig) {
|
|
|
339
460
|
else if (testSet.has(record.id)) balance[lane].test++;
|
|
340
461
|
}
|
|
341
462
|
|
|
342
|
-
// Compute percentages
|
|
463
|
+
// Compute percentages + deviation from profile ratios (DB-005)
|
|
464
|
+
const targetTrain = (profile?.train_ratio ?? 0) * 100;
|
|
465
|
+
const targetVal = (profile?.val_ratio ?? 0) * 100;
|
|
466
|
+
const targetTest = (profile?.test_ratio ?? 0) * 100;
|
|
467
|
+
|
|
468
|
+
let overallDeviation = 0;
|
|
469
|
+
const deviations = {};
|
|
470
|
+
|
|
343
471
|
for (const lane of Object.keys(balance)) {
|
|
344
472
|
const b = balance[lane];
|
|
345
473
|
b.train_pct = b.total > 0 ? +(b.train / b.total * 100).toFixed(1) : 0;
|
|
346
474
|
b.val_pct = b.total > 0 ? +(b.val / b.total * 100).toFixed(1) : 0;
|
|
347
475
|
b.test_pct = b.total > 0 ? +(b.test / b.total * 100).toFixed(1) : 0;
|
|
476
|
+
|
|
477
|
+
// DB-005: absolute deviation from profile target per partition
|
|
478
|
+
const devTrain = Math.abs(b.train_pct - targetTrain);
|
|
479
|
+
const devVal = Math.abs(b.val_pct - targetVal);
|
|
480
|
+
const devTest = Math.abs(b.test_pct - targetTest);
|
|
481
|
+
// Lane deviation: sum of per-partition absolute deviations (max ~200)
|
|
482
|
+
const laneDev = +(devTrain + devVal + devTest).toFixed(1);
|
|
483
|
+
b.deviation_from_target = {
|
|
484
|
+
train: +devTrain.toFixed(1),
|
|
485
|
+
val: +devVal.toFixed(1),
|
|
486
|
+
test: +devTest.toFixed(1),
|
|
487
|
+
total: laneDev,
|
|
488
|
+
};
|
|
489
|
+
deviations[lane] = laneDev;
|
|
490
|
+
overallDeviation += laneDev;
|
|
348
491
|
}
|
|
349
492
|
|
|
493
|
+
// Attach a summary so the operator sees the signal without scanning per-lane rows.
|
|
494
|
+
balance._summary = {
|
|
495
|
+
target_ratios_pct: {
|
|
496
|
+
train: +targetTrain.toFixed(1),
|
|
497
|
+
val: +targetVal.toFixed(1),
|
|
498
|
+
test: +targetTest.toFixed(1),
|
|
499
|
+
},
|
|
500
|
+
overall_deviation_score: +overallDeviation.toFixed(1),
|
|
501
|
+
worst_lane: Object.keys(deviations).length > 0
|
|
502
|
+
? Object.entries(deviations).sort((a, b) => b[1] - a[1])[0][0]
|
|
503
|
+
: null,
|
|
504
|
+
};
|
|
505
|
+
|
|
350
506
|
return balance;
|
|
351
507
|
}
|
|
352
508
|
|
|
@@ -358,7 +514,9 @@ export async function loadSplit(projectRoot, splitId) {
|
|
|
358
514
|
if (!existsSync(path)) {
|
|
359
515
|
throw new Error(`Split "${splitId}" not found at ${path}`);
|
|
360
516
|
}
|
|
361
|
-
|
|
517
|
+
const manifest = JSON.parse(await readFile(path, 'utf-8'));
|
|
518
|
+
checkManifestVersion(manifest, 'split');
|
|
519
|
+
return manifest;
|
|
362
520
|
}
|
|
363
521
|
|
|
364
522
|
/**
|
|
@@ -12,8 +12,9 @@ import { existsSync } from 'node:fs';
|
|
|
12
12
|
import { createHash, randomBytes } from 'node:crypto';
|
|
13
13
|
import { loadExport } from './export.js';
|
|
14
14
|
import { loadTrainingProfile } from './training-profiles.js';
|
|
15
|
-
import { computeConfigFingerprint } from './snapshot.js';
|
|
15
|
+
import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
16
16
|
import { listEvalPacks } from './eval-pack.js';
|
|
17
|
+
import { inputError } from './errors.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Generate a training manifest ID: tm-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -64,6 +65,8 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
64
65
|
|
|
65
66
|
const manifestId = generateManifestId();
|
|
66
67
|
const manifest = {
|
|
68
|
+
// DB-001 / DB-006: stamp manifest schema version
|
|
69
|
+
schema_version: SCHEMA_VERSION,
|
|
67
70
|
training_manifest_id: manifestId,
|
|
68
71
|
created_at: new Date().toISOString(),
|
|
69
72
|
created_by: 'sdlab-training-manifest-v1',
|
|
@@ -89,7 +92,16 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
89
92
|
// Write manifest
|
|
90
93
|
const dir = join(projectRoot, 'training', 'manifests');
|
|
91
94
|
await mkdir(dir, { recursive: true });
|
|
92
|
-
|
|
95
|
+
const manifestPath = join(dir, `${manifestId}.json`);
|
|
96
|
+
// D-005: refuse to silently overwrite on ID collision.
|
|
97
|
+
if (existsSync(manifestPath)) {
|
|
98
|
+
throw inputError(
|
|
99
|
+
'TRAINING_MANIFEST_ID_COLLISION',
|
|
100
|
+
`Training manifest file already exists: ${manifestPath}`,
|
|
101
|
+
'Retry to generate a fresh manifest ID.',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
93
105
|
|
|
94
106
|
return { manifestId, exportId, profileId };
|
|
95
107
|
}
|
|
@@ -139,7 +151,9 @@ export async function loadTrainingManifest(projectRoot, manifestId) {
|
|
|
139
151
|
if (!existsSync(path)) {
|
|
140
152
|
throw new Error(`Training manifest "${manifestId}" not found at ${path}`);
|
|
141
153
|
}
|
|
142
|
-
|
|
154
|
+
const manifest = JSON.parse(await readFile(path, 'utf-8'));
|
|
155
|
+
checkManifestVersion(manifest, 'training-manifest');
|
|
156
|
+
return manifest;
|
|
143
157
|
}
|
|
144
158
|
|
|
145
159
|
/**
|
package/lib/training-packages.js
CHANGED
|
@@ -15,6 +15,8 @@ import { loadTrainingProfile } from './training-profiles.js';
|
|
|
15
15
|
import { loadSplitPartition } from './split.js';
|
|
16
16
|
import { loadRecord } from './records.js';
|
|
17
17
|
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
18
|
+
import { inputError } from './errors.js';
|
|
19
|
+
import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -63,6 +65,14 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
63
65
|
const recordsDir = join(projectRoot, 'records');
|
|
64
66
|
const packageId = generatePackageId();
|
|
65
67
|
const packageDir = join(projectRoot, 'training', 'packages', packageId);
|
|
68
|
+
// D-005: refuse to silently overwrite on ID collision.
|
|
69
|
+
if (existsSync(packageDir)) {
|
|
70
|
+
throw inputError(
|
|
71
|
+
'TRAINING_PACKAGE_ID_COLLISION',
|
|
72
|
+
`Training package directory already exists: ${packageDir}`,
|
|
73
|
+
'Retry to generate a fresh package ID.',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
66
76
|
|
|
67
77
|
// Load split partitions
|
|
68
78
|
const partitions = {};
|
|
@@ -105,19 +115,10 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
105
115
|
|
|
106
116
|
totalImages = adapterResult.imageCount || 0;
|
|
107
117
|
|
|
108
|
-
//
|
|
109
|
-
const checksumEntries = [];
|
|
110
|
-
const allFiles = await collectFiles(packageDir);
|
|
111
|
-
for (const filePath of allFiles.sort()) {
|
|
112
|
-
const relPath = relative(packageDir, filePath);
|
|
113
|
-
if (relPath === 'checksums.txt') continue;
|
|
114
|
-
const hash = await sha256File(filePath);
|
|
115
|
-
checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
|
|
116
|
-
}
|
|
117
|
-
await writeFile(join(packageDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
|
|
118
|
-
|
|
119
|
-
// Write package manifest (last — references everything)
|
|
118
|
+
// D-015: Write manifest + README FIRST so they are covered by checksums.
|
|
120
119
|
const packageManifest = {
|
|
120
|
+
// DB-001 / DB-006: stamp manifest schema version
|
|
121
|
+
schema_version: SCHEMA_VERSION,
|
|
121
122
|
training_package_id: packageId,
|
|
122
123
|
created_at: new Date().toISOString(),
|
|
123
124
|
created_by: 'sdlab-training-package-v1',
|
|
@@ -134,7 +135,7 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
134
135
|
val: partitions.val.length,
|
|
135
136
|
test: partitions.test.length,
|
|
136
137
|
images: totalImages,
|
|
137
|
-
checksums:
|
|
138
|
+
checksums: 0, // updated after checksum pass below
|
|
138
139
|
},
|
|
139
140
|
};
|
|
140
141
|
await writeFile(join(packageDir, 'manifest.json'), JSON.stringify(packageManifest, null, 2) + '\n');
|
|
@@ -143,6 +144,17 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
143
144
|
const readme = generatePackageReadme(packageManifest, profile, manifest, adapterTarget);
|
|
144
145
|
await writeFile(join(packageDir, 'README.md'), readme);
|
|
145
146
|
|
|
147
|
+
// Generate checksums AFTER manifest+README are on disk so they are included.
|
|
148
|
+
const checksumEntries = [];
|
|
149
|
+
const allFiles = await collectFiles(packageDir);
|
|
150
|
+
for (const filePath of allFiles.sort()) {
|
|
151
|
+
const relPath = relative(packageDir, filePath);
|
|
152
|
+
if (relPath === 'checksums.txt') continue;
|
|
153
|
+
const hash = await sha256File(filePath);
|
|
154
|
+
checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
|
|
155
|
+
}
|
|
156
|
+
await writeFile(join(packageDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
|
|
157
|
+
|
|
146
158
|
return { packageId, records: totalRecords, images: totalImages };
|
|
147
159
|
}
|
|
148
160
|
|
|
@@ -237,5 +249,7 @@ export async function loadTrainingPackage(projectRoot, packageId) {
|
|
|
237
249
|
if (!existsSync(path)) {
|
|
238
250
|
throw new Error(`Training package "${packageId}" not found at ${path}`);
|
|
239
251
|
}
|
|
240
|
-
|
|
252
|
+
const manifest = JSON.parse(await readFile(path, 'utf-8'));
|
|
253
|
+
checkManifestVersion(manifest, 'training-package');
|
|
254
|
+
return manifest;
|
|
241
255
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow profile management.
|
|
3
|
+
*
|
|
4
|
+
* A workflow profile is a production recipe — it defines how to generate
|
|
5
|
+
* a specific kind of output set (portrait pack, expression sheet, moodboard, etc.)
|
|
6
|
+
* using project canon and trained assets.
|
|
7
|
+
*
|
|
8
|
+
* Workflow profiles are NOT training profiles. They drive generation, not training.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { getWorkflowProfilesDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
const REQUIRED_FIELDS = [
|
|
17
|
+
'workflow_id', 'label', 'lane_id', 'output_mode', 'output_count',
|
|
18
|
+
'prompt_strategy', 'negative_strategy', 'canon_focus', 'drift_guards',
|
|
19
|
+
'runtime_defaults',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const VALID_SUBJECT_MODES = ['required', 'optional', 'forbidden'];
|
|
23
|
+
|
|
24
|
+
const VALID_OUTPUT_MODES = [
|
|
25
|
+
'portrait_set', 'expression_sheet', 'variant_pack',
|
|
26
|
+
'moodboard', 'silhouette_sheet', 'turnaround',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const VALID_CANON_FOCUS = [
|
|
30
|
+
'silhouette', 'material_language', 'palette', 'anatomy',
|
|
31
|
+
'costume_logic', 'era_logic', 'scale_logic', 'lighting',
|
|
32
|
+
'surface_wear', 'gesture', 'composition', 'faction_read',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a workflow profile object.
|
|
37
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
38
|
+
*/
|
|
39
|
+
export function validateWorkflowProfile(profile) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
|
|
42
|
+
for (const field of REQUIRED_FIELDS) {
|
|
43
|
+
if (profile[field] === undefined || profile[field] === null) {
|
|
44
|
+
errors.push(`missing required field: ${field}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (profile.subject_mode && !VALID_SUBJECT_MODES.includes(profile.subject_mode)) {
|
|
49
|
+
errors.push(`invalid subject_mode "${profile.subject_mode}" — must be one of: ${VALID_SUBJECT_MODES.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (profile.output_mode && !VALID_OUTPUT_MODES.includes(profile.output_mode)) {
|
|
53
|
+
errors.push(`invalid output_mode "${profile.output_mode}" — must be one of: ${VALID_OUTPUT_MODES.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!Number.isInteger(profile.output_count) || profile.output_count < 1) {
|
|
57
|
+
errors.push('output_count must be a positive integer');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Prompt strategy structure
|
|
61
|
+
const ps = profile.prompt_strategy;
|
|
62
|
+
if (ps) {
|
|
63
|
+
if (!Array.isArray(ps.style_prefix)) errors.push('prompt_strategy.style_prefix must be an array');
|
|
64
|
+
if (typeof ps.structure !== 'string') errors.push('prompt_strategy.structure must be a string');
|
|
65
|
+
if (!Array.isArray(ps.must_include)) errors.push('prompt_strategy.must_include must be an array');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Negative strategy structure
|
|
69
|
+
const ns = profile.negative_strategy;
|
|
70
|
+
if (ns) {
|
|
71
|
+
if (!Array.isArray(ns.must_avoid)) errors.push('negative_strategy.must_avoid must be an array');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Canon focus items
|
|
75
|
+
if (Array.isArray(profile.canon_focus)) {
|
|
76
|
+
for (const item of profile.canon_focus) {
|
|
77
|
+
if (!VALID_CANON_FOCUS.includes(item)) {
|
|
78
|
+
errors.push(`invalid canon_focus item "${item}" — must be one of: ${VALID_CANON_FOCUS.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} else if (profile.canon_focus !== undefined) {
|
|
82
|
+
errors.push('canon_focus must be an array');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (profile.drift_guards && !Array.isArray(profile.drift_guards)) {
|
|
86
|
+
errors.push('drift_guards must be an array');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Runtime defaults
|
|
90
|
+
const rd = profile.runtime_defaults;
|
|
91
|
+
if (rd) {
|
|
92
|
+
if (!rd.adapter_target) errors.push('runtime_defaults.adapter_target is required');
|
|
93
|
+
if (rd.seed_mode && !['fixed', 'increment', 'random'].includes(rd.seed_mode)) {
|
|
94
|
+
errors.push(`invalid runtime_defaults.seed_mode "${rd.seed_mode}" — must be fixed, increment, or random`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { valid: errors.length === 0, errors };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Load a single workflow profile by ID.
|
|
103
|
+
*/
|
|
104
|
+
export async function getWorkflowProfile(projectRoot, workflowId) {
|
|
105
|
+
const dir = getWorkflowProfilesDir(projectRoot);
|
|
106
|
+
const path = join(dir, `${workflowId}.json`);
|
|
107
|
+
if (!existsSync(path)) {
|
|
108
|
+
throw new Error(`Workflow profile "${workflowId}" not found at ${path}`);
|
|
109
|
+
}
|
|
110
|
+
const data = JSON.parse(await readFile(path, 'utf-8'));
|
|
111
|
+
const { valid, errors } = validateWorkflowProfile(data);
|
|
112
|
+
if (!valid) {
|
|
113
|
+
throw new Error(`Workflow profile "${workflowId}" is invalid:\n ${errors.join('\n ')}`);
|
|
114
|
+
}
|
|
115
|
+
return data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List all workflow profiles in a project.
|
|
120
|
+
*/
|
|
121
|
+
export async function listWorkflowProfiles(projectRoot) {
|
|
122
|
+
const dir = getWorkflowProfilesDir(projectRoot);
|
|
123
|
+
if (!existsSync(dir)) return [];
|
|
124
|
+
|
|
125
|
+
const files = (await readdir(dir)).filter(f => f.endsWith('.json')).sort();
|
|
126
|
+
const profiles = [];
|
|
127
|
+
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
try {
|
|
130
|
+
const data = JSON.parse(await readFile(join(dir, file), 'utf-8'));
|
|
131
|
+
profiles.push({
|
|
132
|
+
workflow_id: data.workflow_id,
|
|
133
|
+
label: data.label,
|
|
134
|
+
lane_id: data.lane_id,
|
|
135
|
+
subject_mode: data.subject_mode || 'optional',
|
|
136
|
+
output_mode: data.output_mode,
|
|
137
|
+
output_count: data.output_count,
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
// Skip malformed
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return profiles;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Save a workflow profile to a project.
|
|
149
|
+
*/
|
|
150
|
+
export async function saveWorkflowProfile(projectRoot, profile) {
|
|
151
|
+
const { valid, errors } = validateWorkflowProfile(profile);
|
|
152
|
+
if (!valid) {
|
|
153
|
+
throw new Error(`Invalid workflow profile:\n ${errors.join('\n ')}`);
|
|
154
|
+
}
|
|
155
|
+
const dir = getWorkflowProfilesDir(projectRoot);
|
|
156
|
+
await mkdir(dir, { recursive: true });
|
|
157
|
+
await writeFile(
|
|
158
|
+
join(dir, `${profile.workflow_id}.json`),
|
|
159
|
+
JSON.stringify(profile, null, 2) + '\n'
|
|
160
|
+
);
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcptoolshop/style-dataset-lab",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "Canon-aligned dataset production and generation workbench — define visual rules, build versioned training data, compile production briefs, run local workflows, batch-produce, select winners, and re-ingest into your corpus",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"sdlab": "./bin/sdlab.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
+
"test": "node --test tests/**/*.test.js",
|
|
10
11
|
"verify": "node bin/sdlab.js --help && node bin/sdlab.js project doctor --project star-freight",
|
|
11
12
|
"generate": "node scripts/generate.js",
|
|
12
13
|
"curate": "node scripts/curate.js",
|
|
@@ -19,11 +20,16 @@
|
|
|
19
20
|
"bin/",
|
|
20
21
|
"lib/",
|
|
21
22
|
"scripts/",
|
|
23
|
+
"!scripts/bulk-curate-*.js",
|
|
24
|
+
"!scripts/curate-wave*.js",
|
|
25
|
+
"!scripts/migrate-records.js",
|
|
22
26
|
"templates/",
|
|
23
27
|
"README.md",
|
|
24
28
|
"LICENSE",
|
|
25
29
|
"SECURITY.md",
|
|
26
|
-
"CHANGELOG.md"
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"CONTRIBUTING.md",
|
|
32
|
+
"CODE_OF_CONDUCT.md"
|
|
27
33
|
],
|
|
28
34
|
"keywords": [
|
|
29
35
|
"dataset",
|
|
@@ -38,7 +44,10 @@
|
|
|
38
44
|
"style-guide",
|
|
39
45
|
"lora",
|
|
40
46
|
"multimodal",
|
|
41
|
-
"comfyui"
|
|
47
|
+
"comfyui",
|
|
48
|
+
"generation-workflow",
|
|
49
|
+
"batch-production",
|
|
50
|
+
"reintegration"
|
|
42
51
|
],
|
|
43
52
|
"homepage": "https://mcp-tool-shop-org.github.io/style-dataset-lab/",
|
|
44
53
|
"repository": {
|