@mcptoolshop/style-dataset-lab 3.2.0 → 3.4.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 +58 -1
- package/README.es.md +40 -19
- package/README.fr.md +74 -53
- package/README.hi.md +67 -46
- package/README.it.md +42 -21
- package/README.ja.md +44 -23
- package/README.md +25 -4
- package/README.pt-BR.md +42 -21
- package/README.zh.md +66 -45
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +114 -27
- package/lib/adapters/comfyui-workflows.js +10 -2
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +82 -3
- package/lib/critique-render.js +45 -0
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +69 -16
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +16 -1
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/measure.js +422 -0
- package/lib/paths.js +196 -7
- package/lib/reingest-selected.js +98 -16
- package/lib/run-manifest.js +394 -0
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +121 -16
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +67 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +3 -2
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +27 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +48 -8
- package/scripts/curate.js +23 -2
- package/scripts/doctor.js +181 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +72 -5
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +334 -13
- package/scripts/reingest.js +2 -2
- package/scripts/run-generate.js +3 -0
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
* so the training run is fully reproducible.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFile, writeFile,
|
|
9
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
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, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
15
|
+
import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion, claimIdFile } from './snapshot.js';
|
|
16
16
|
import { listEvalPacks } from './eval-pack.js';
|
|
17
|
-
import { inputError } from './errors.js';
|
|
18
17
|
|
|
19
18
|
/**
|
|
20
19
|
* Generate a training manifest ID: tm-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -34,6 +33,24 @@ async function hashFile(filePath) {
|
|
|
34
33
|
return createHash('sha256').update(data).digest('hex');
|
|
35
34
|
}
|
|
36
35
|
|
|
36
|
+
/**
|
|
37
|
+
* SDL-H9: parse a BSD-style checksums.txt (`SHA256 (path) = hash` per
|
|
38
|
+
* line, as written by export.js and training-packages.js) into
|
|
39
|
+
* {relPath, hash} entries. Tolerates stray/blank lines rather than
|
|
40
|
+
* throwing, since this file is meant to be human-readable too.
|
|
41
|
+
*/
|
|
42
|
+
function parseChecksumsFile(text) {
|
|
43
|
+
const entries = [];
|
|
44
|
+
for (const line of text.split('\n')) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (!trimmed) continue;
|
|
47
|
+
const match = /^SHA256 \((.+)\) = ([0-9a-fA-F]{64})$/.exec(trimmed);
|
|
48
|
+
if (!match) continue;
|
|
49
|
+
entries.push({ relPath: match[1], hash: match[2].toLowerCase() });
|
|
50
|
+
}
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
/**
|
|
38
55
|
* Create a training manifest from an export package and training profile.
|
|
39
56
|
*
|
|
@@ -51,6 +68,17 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
51
68
|
const exportManifestPath = join(projectRoot, 'exports', exportId, 'manifest.json');
|
|
52
69
|
const exportManifestHash = await hashFile(exportManifestPath);
|
|
53
70
|
|
|
71
|
+
// SDL-H9: export_manifest_hash above covers ONLY manifest.json — a file
|
|
72
|
+
// that does not include image bytes. checksums.txt (written by
|
|
73
|
+
// export.js after the manifest, D-015) covers every file in the export,
|
|
74
|
+
// including images, so bind to it too when present. `include_checksums`
|
|
75
|
+
// is a profile-controlled export option — an export built without it
|
|
76
|
+
// simply has nothing to bind here, which is not itself an error.
|
|
77
|
+
const exportChecksumsPath = join(projectRoot, 'exports', exportId, 'checksums.txt');
|
|
78
|
+
const exportChecksumsHash = existsSync(exportChecksumsPath)
|
|
79
|
+
? await hashFile(exportChecksumsPath)
|
|
80
|
+
: null;
|
|
81
|
+
|
|
54
82
|
// Determine adapter target
|
|
55
83
|
const adapterTarget = options.adapterTarget || profile.adapter_targets[0];
|
|
56
84
|
if (!profile.adapter_targets.includes(adapterTarget)) {
|
|
@@ -63,11 +91,14 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
63
91
|
const evalPacks = await listEvalPacks(projectRoot);
|
|
64
92
|
const linkedEvalPackIds = evalPacks.map(p => p.id);
|
|
65
93
|
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
// Deferred so the id can be plugged in per-attempt by claimIdFile below
|
|
95
|
+
// (a training manifest is a flat file, not a directory — see claimIdFile
|
|
96
|
+
// in snapshot.js for why the atomic primitive differs from the other
|
|
97
|
+
// artifacts in this domain).
|
|
98
|
+
const buildContent = (id) => JSON.stringify({
|
|
68
99
|
// DB-001 / DB-006: stamp manifest schema version
|
|
69
100
|
schema_version: SCHEMA_VERSION,
|
|
70
|
-
training_manifest_id:
|
|
101
|
+
training_manifest_id: id,
|
|
71
102
|
created_at: new Date().toISOString(),
|
|
72
103
|
created_by: 'sdlab-training-manifest-v1',
|
|
73
104
|
project_id: exportManifest.project,
|
|
@@ -77,6 +108,9 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
77
108
|
source_split_id: exportManifest.split_id,
|
|
78
109
|
config_fingerprint: computeConfigFingerprint(projectRoot),
|
|
79
110
|
export_manifest_hash: exportManifestHash,
|
|
111
|
+
// SDL-H9: the binding hash that actually covers image bytes. null
|
|
112
|
+
// when the source export was built with include_checksums: false.
|
|
113
|
+
export_checksums_hash: exportChecksumsHash,
|
|
80
114
|
dataset_counts: exportManifest.counts,
|
|
81
115
|
base_model: options.baseModel || profile.base_model_recommendations?.[0] || null,
|
|
82
116
|
adapter_target: adapterTarget,
|
|
@@ -87,21 +121,21 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
87
121
|
target_family: profile.target_family,
|
|
88
122
|
},
|
|
89
123
|
linked_eval_pack_ids: linkedEvalPackIds,
|
|
90
|
-
};
|
|
124
|
+
}, null, 2) + '\n';
|
|
91
125
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
'
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
126
|
+
// SDL-M10: atomically claim the manifest file — closes the
|
|
127
|
+
// check-then-act race the old existsSync+writeFile pattern had (D-005's
|
|
128
|
+
// intent, now actually race-proof).
|
|
129
|
+
const { id: manifestId } = await claimIdFile(
|
|
130
|
+
join(projectRoot, 'training', 'manifests'),
|
|
131
|
+
generateManifestId,
|
|
132
|
+
buildContent,
|
|
133
|
+
{
|
|
134
|
+
code: 'TRAINING_MANIFEST_ID_COLLISION',
|
|
135
|
+
message: 'Could not claim a unique training-manifest ID — every candidate collided with an existing manifest file.',
|
|
136
|
+
hint: 'Retry; if this persists, another sdlab process may be creating training manifests in a tight loop.',
|
|
137
|
+
},
|
|
138
|
+
);
|
|
105
139
|
|
|
106
140
|
return { manifestId, exportId, profileId };
|
|
107
141
|
}
|
|
@@ -121,13 +155,72 @@ export async function validateTrainingManifest(projectRoot, manifestId) {
|
|
|
121
155
|
if (!existsSync(exportPath)) {
|
|
122
156
|
issues.push(`source export "${manifest.source_export_id}" not found`);
|
|
123
157
|
} else {
|
|
124
|
-
// Check export hash
|
|
158
|
+
// Check export hash — NOTE: manifest.json alone does not include image
|
|
159
|
+
// bytes (SDL-H9); see the checksums.txt re-verification below for the
|
|
160
|
+
// check that actually catches a replaced image.
|
|
125
161
|
const currentHash = await hashFile(exportPath);
|
|
126
162
|
if (currentHash !== manifest.export_manifest_hash) {
|
|
127
163
|
issues.push(`export manifest hash mismatch — export may have been modified`);
|
|
128
164
|
}
|
|
129
165
|
}
|
|
130
166
|
|
|
167
|
+
// SDL-H9: export_manifest_hash (above) never covered image bytes —
|
|
168
|
+
// checksums.txt does. Re-verify it two ways:
|
|
169
|
+
// 1. binding check — checksums.txt's own hash still matches what was
|
|
170
|
+
// captured at training-manifest creation time (only meaningful for
|
|
171
|
+
// manifests created after SDL-H9, when export_checksums_hash was
|
|
172
|
+
// first captured).
|
|
173
|
+
// 2. full re-verification — every individual file checksums.txt
|
|
174
|
+
// records is re-hashed against its CURRENT on-disk content. This is
|
|
175
|
+
// the check that actually catches "an image was silently replaced
|
|
176
|
+
// after export": checksums.txt's own bytes don't change just
|
|
177
|
+
// because a file it lists was overwritten, so step 1 alone cannot
|
|
178
|
+
// see it. Runs whenever checksums.txt exists, independent of
|
|
179
|
+
// whether this manifest predates export_checksums_hash, so it also
|
|
180
|
+
// retroactively protects manifests created before this fix.
|
|
181
|
+
const exportChecksumsPath = join(projectRoot, 'exports', manifest.source_export_id, 'checksums.txt');
|
|
182
|
+
if (manifest.export_checksums_hash && !existsSync(exportChecksumsPath)) {
|
|
183
|
+
issues.push(`export checksums.txt not found at ${exportChecksumsPath} — export may have been modified (checksums.txt removed)`);
|
|
184
|
+
} else if (existsSync(exportChecksumsPath)) {
|
|
185
|
+
if (manifest.export_checksums_hash) {
|
|
186
|
+
const currentChecksumsHash = await hashFile(exportChecksumsPath);
|
|
187
|
+
if (currentChecksumsHash !== manifest.export_checksums_hash) {
|
|
188
|
+
issues.push(`export checksums.txt hash mismatch — the checksum manifest itself has changed since this training manifest was created`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const exportDir = join(projectRoot, 'exports', manifest.source_export_id);
|
|
193
|
+
const checksumsText = await readFile(exportChecksumsPath, 'utf-8');
|
|
194
|
+
const entries = parseChecksumsFile(checksumsText);
|
|
195
|
+
const missing = [];
|
|
196
|
+
const mismatched = [];
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const filePath = join(exportDir, entry.relPath);
|
|
199
|
+
if (!existsSync(filePath)) {
|
|
200
|
+
missing.push(entry.relPath);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const currentFileHash = await hashFile(filePath);
|
|
204
|
+
if (currentFileHash !== entry.hash) {
|
|
205
|
+
mismatched.push(entry.relPath);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (missing.length > 0) {
|
|
209
|
+
const sample = missing.slice(0, 5).join(', ');
|
|
210
|
+
issues.push(
|
|
211
|
+
`export file(s) referenced by checksums.txt are missing: ${sample}` +
|
|
212
|
+
(missing.length > 5 ? `, ... (${missing.length} total)` : ''),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (mismatched.length > 0) {
|
|
216
|
+
const sample = mismatched.slice(0, 5).join(', ');
|
|
217
|
+
issues.push(
|
|
218
|
+
`export file(s) modified since export — content hash no longer matches checksums.txt: ${sample}` +
|
|
219
|
+
(mismatched.length > 5 ? `, ... (${mismatched.length} total)` : ''),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
131
224
|
// Check config fingerprint
|
|
132
225
|
const currentFingerprint = computeConfigFingerprint(projectRoot);
|
|
133
226
|
if (currentFingerprint !== manifest.config_fingerprint) {
|
package/lib/training-packages.js
CHANGED
|
@@ -6,19 +6,21 @@
|
|
|
6
6
|
* but never mutate inclusion or split truth.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFile, writeFile,
|
|
9
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
10
10
|
import { join, relative } from 'node:path';
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
|
+
import { platform } from 'node:os';
|
|
12
13
|
import { createHash, randomBytes } from 'node:crypto';
|
|
13
14
|
import { loadTrainingManifest } from './training-manifests.js';
|
|
14
15
|
import { loadTrainingProfile } from './training-profiles.js';
|
|
15
16
|
import { loadSplitPartition } from './split.js';
|
|
16
17
|
import { loadRecord } from './records.js';
|
|
17
18
|
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
18
|
-
import { inputError } from './errors.js';
|
|
19
|
-
import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
19
|
+
import { inputError, SdlabError } from './errors.js';
|
|
20
|
+
import { SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
|
|
20
21
|
import { loadAdapter } from './training-adapters.js';
|
|
21
22
|
import { recordToRow, filterRowsForProfile } from './rows.js';
|
|
23
|
+
import { assertSafeAssetPath, assertSafeId } from './export.js';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -51,16 +53,6 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
51
53
|
const adapter = await loadAdapter(adapterTarget);
|
|
52
54
|
|
|
53
55
|
const recordsDir = join(projectRoot, 'records');
|
|
54
|
-
const packageId = generatePackageId();
|
|
55
|
-
const packageDir = join(projectRoot, 'training', 'packages', packageId);
|
|
56
|
-
// D-005: refuse to silently overwrite on ID collision.
|
|
57
|
-
if (existsSync(packageDir)) {
|
|
58
|
-
throw inputError(
|
|
59
|
-
'TRAINING_PACKAGE_ID_COLLISION',
|
|
60
|
-
`Training package directory already exists: ${packageDir}`,
|
|
61
|
-
'Retry to generate a fresh package ID.',
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
56
|
|
|
65
57
|
// Convert split partitions → canonical rows (D6 adapter input shape).
|
|
66
58
|
// Adapters only understand rows — records-flow callers go through this
|
|
@@ -68,23 +60,90 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
68
60
|
// contract, two producers.
|
|
69
61
|
const rows = [];
|
|
70
62
|
const partitionCounts = { train: 0, val: 0, test: 0 };
|
|
63
|
+
// F3: same contract as export.js's identical guard — a record the split
|
|
64
|
+
// promised (via source_split_id's train/val/test.jsonl) whose file was
|
|
65
|
+
// later deleted from records/ must never silently shrink the training
|
|
66
|
+
// package below what the split counted. loadRecord() returns null on
|
|
67
|
+
// ENOENT; previously that was a silent `continue` with no counter, no
|
|
68
|
+
// warning, no id recorded. Accumulated across all three partitions so
|
|
69
|
+
// one thrown error names every missing id instead of discovering them
|
|
70
|
+
// one rebuild at a time.
|
|
71
|
+
const missingRecords = [];
|
|
72
|
+
// H3: recordToRow() can throw CAPTION_SUBJECT_MISSING (lib/captions.js's
|
|
73
|
+
// buildSubjectNaturalLanguageCaption) per-record. That throw is correct —
|
|
74
|
+
// the granularity of handling it here was not: a bare loop with no
|
|
75
|
+
// try/catch meant the FIRST offending record aborted the whole build,
|
|
76
|
+
// before claimIdDir (below) ever reserved a package id, so a 400-record
|
|
77
|
+
// build died having written nothing, named exactly one offender, and had
|
|
78
|
+
// to be rerun in full for each subsequent one. Mirrors the aggregation
|
|
79
|
+
// pattern lib/canon-build/build.js already uses for contextLimitFailures:
|
|
80
|
+
// collect across the WHOLE loop, throw one summary naming every offender.
|
|
81
|
+
const captionFailures = [];
|
|
71
82
|
|
|
72
83
|
for (const partition of ['train', 'val', 'test']) {
|
|
73
84
|
const entries = await loadSplitPartition(projectRoot, manifest.source_split_id, partition);
|
|
74
85
|
|
|
75
86
|
for (const entry of entries) {
|
|
76
87
|
const record = await loadRecord(recordsDir, entry.record_id);
|
|
77
|
-
if (!record)
|
|
88
|
+
if (!record) {
|
|
89
|
+
missingRecords.push({ record_id: entry.record_id, stage: 'training-package', partition });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
78
92
|
|
|
79
93
|
const prompt = record.provenance?.prompt || '';
|
|
80
94
|
const lane = detectLane(record.id, prompt, config.lanes);
|
|
81
95
|
const group = detectGroup(record.id, prompt, config.terminology);
|
|
82
96
|
|
|
83
|
-
|
|
97
|
+
let row;
|
|
98
|
+
try {
|
|
99
|
+
row = recordToRow(record, lane, group, profile, partition);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Only batch STRUCTURED/expected failures (SdlabError) — an
|
|
102
|
+
// unexpected bug should still crash loud with its real stack
|
|
103
|
+
// instead of being swallowed into a friendly summary.
|
|
104
|
+
if (!(err instanceof SdlabError)) throw err;
|
|
105
|
+
captionFailures.push({ record_id: entry.record_id, partition, message: err.message });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
84
108
|
if (row) rows.push(row);
|
|
85
109
|
}
|
|
86
110
|
}
|
|
87
111
|
|
|
112
|
+
// F3: hard-fail — training packages sit on the same Law 4 ("exports are
|
|
113
|
+
// reproducible") contract export.js does, one layer further downstream;
|
|
114
|
+
// see export.js's EXPORT_MISSING_RECORDS guard for the full rationale on
|
|
115
|
+
// why this escalates to a thrown error rather than the warn()-and-ledger
|
|
116
|
+
// pattern used in split.js / implementation-packs.js. Checked before the
|
|
117
|
+
// Windows symlink guard, row-safety validation, and claimIdDir below, so
|
|
118
|
+
// a doomed build never reserves a package id or touches the filesystem.
|
|
119
|
+
if (missingRecords.length > 0) {
|
|
120
|
+
const sample = missingRecords.slice(0, 10).map(m => `${m.record_id} (${m.partition})`);
|
|
121
|
+
throw inputError(
|
|
122
|
+
'TRAINING_PACKAGE_MISSING_RECORDS',
|
|
123
|
+
`${missingRecords.length} record(s) referenced by split "${manifest.source_split_id}" could not be loaded from ${recordsDir} — this training package would silently contain fewer rows than the split promised.` +
|
|
124
|
+
(missingRecords.length > 10
|
|
125
|
+
? ` First 10: ${sample.join(', ')}, ...`
|
|
126
|
+
: ` Missing: ${sample.join(', ')}.`),
|
|
127
|
+
'Record file(s) were likely deleted after the split was created. Restore the missing file(s), or create a fresh snapshot/split reflecting current on-disk records before building a training package.',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// H3: aggregated caption-failure report — see the captionFailures
|
|
132
|
+
// declaration above. Checked alongside missingRecords, before claimIdDir,
|
|
133
|
+
// so this also never reserves a package id or touches the filesystem.
|
|
134
|
+
if (captionFailures.length > 0) {
|
|
135
|
+
const sample = captionFailures.slice(0, 10).map(f => `${f.record_id} (${f.partition}): ${f.message}`);
|
|
136
|
+
throw inputError(
|
|
137
|
+
'TRAINING_PACKAGE_CAPTION_FAILURES',
|
|
138
|
+
`${captionFailures.length} record(s) failed to caption while building this training package.\n` +
|
|
139
|
+
sample.join('\n') +
|
|
140
|
+
(captionFailures.length > 10 ? `\n...and ${captionFailures.length - 10} more.` : ''),
|
|
141
|
+
'Fix the offending record(s) — commonly: populate canon.subject on records using caption_strategy ' +
|
|
142
|
+
'"subject-natural-language" — then retry. Every failure is listed above so this can be fixed in one ' +
|
|
143
|
+
'pass instead of one rerun per offender.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
88
147
|
// Apply profile filters (eligible_lanes + entity_id_scope — D8).
|
|
89
148
|
// filterRowsForProfile returns rows where lane ∈ eligible_lanes AND
|
|
90
149
|
// entity_id === entity_id_scope (when set). Zero filters = identity.
|
|
@@ -95,6 +154,54 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
95
154
|
const totalRecords = filteredRows.length;
|
|
96
155
|
let totalImages = 0;
|
|
97
156
|
|
|
157
|
+
// SDL-C5: mirror export.js's D-008 Windows guard. Symlink mode on
|
|
158
|
+
// Windows without admin/Developer Mode fails on every single row
|
|
159
|
+
// (EPERM) — unguarded, that meant imageCount stayed 0, every
|
|
160
|
+
// metadata/<partition>.jsonl came out empty, and the command still
|
|
161
|
+
// exited 0 printing "Images: 0". Refuse upfront instead of limping
|
|
162
|
+
// through an all-rows-fail package that LOOKS like success.
|
|
163
|
+
if (!options.copy && platform() === 'win32' && filteredRows.length > 0) {
|
|
164
|
+
throw inputError(
|
|
165
|
+
'SYMLINK_UNSUPPORTED_WINDOWS',
|
|
166
|
+
'Symlink mode is not supported on Windows training-package builds (target semantics differ and break on archive/transfer).',
|
|
167
|
+
'Re-run with --copy to produce a portable, self-contained training package.',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// SDL-H5 / SDL-H6: validate every row BEFORE any adapter touches the
|
|
172
|
+
// filesystem. export.js already guards asset_path against traversal
|
|
173
|
+
// (D-007) and now guards the id that becomes the destination filename
|
|
174
|
+
// (SDL-H5); this ports the exact same checks (same error codes) to the
|
|
175
|
+
// training-package path, which previously only checked existsSync — a
|
|
176
|
+
// traversal path satisfies existsSync just as well as a legitimate one.
|
|
177
|
+
// Centralized here once, per the finding's own suggestion, rather than
|
|
178
|
+
// duplicated per-adapter — though each adapter ALSO carries the same
|
|
179
|
+
// guard directly (see lib/adapters/*.js) as defense-in-depth for a
|
|
180
|
+
// caller that invokes adapter.buildPackage without going through this
|
|
181
|
+
// function (e.g. the adapters' own unit tests, or a future canon-build
|
|
182
|
+
// caller — see lib/rows.js's documented second row producer).
|
|
183
|
+
for (const row of filteredRows) {
|
|
184
|
+
assertSafeId(row.entity_id, `row.entity_id for training package (asset_path "${row.asset_path}")`);
|
|
185
|
+
assertSafeAssetPath(row.asset_path, projectRoot, row.entity_id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// SDL-M10: atomically claim the training-package id/dir — closes the
|
|
189
|
+
// check-then-act race the old existsSync+recursive-mkdir pattern had
|
|
190
|
+
// (D-005's intent, now actually race-proof; see claimIdDir in
|
|
191
|
+
// snapshot.js). Deferred to here (rather than claimed at the top of the
|
|
192
|
+
// function) since nothing above this point actually needs packageId —
|
|
193
|
+
// no point reserving one before the Windows guard / row validation have
|
|
194
|
+
// had a chance to reject the build outright.
|
|
195
|
+
const { id: packageId, dir: packageDir } = await claimIdDir(
|
|
196
|
+
join(projectRoot, 'training', 'packages'),
|
|
197
|
+
generatePackageId,
|
|
198
|
+
{
|
|
199
|
+
code: 'TRAINING_PACKAGE_ID_COLLISION',
|
|
200
|
+
message: 'Could not claim a unique training-package ID — every candidate collided with an existing package directory.',
|
|
201
|
+
hint: 'Retry; if this persists, another sdlab process may be creating training packages in a tight loop.',
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
98
205
|
// Let the adapter build the package layout from the canonical rows.
|
|
99
206
|
const adapterResult = await adapter.buildPackage({
|
|
100
207
|
packageDir,
|
|
@@ -107,6 +214,22 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
107
214
|
});
|
|
108
215
|
|
|
109
216
|
totalImages = adapterResult.imageCount || 0;
|
|
217
|
+
// SDL-C5: DB-002-style accounting, ported from export.js — a caught
|
|
218
|
+
// per-row copy/symlink failure must be visible, not silently dropped
|
|
219
|
+
// alongside its metadata row and caption sidecar via a bare
|
|
220
|
+
// `catch { continue; }`. The adapter itself already emitted a warn()
|
|
221
|
+
// (it has the precise per-row context); this builds the PERSISTED
|
|
222
|
+
// record on the package manifest so the failure survives after the
|
|
223
|
+
// terminal scrolls, without re-printing the same stderr line twice.
|
|
224
|
+
const imagesFailed = adapterResult.imagesFailed || [];
|
|
225
|
+
const warnings = [];
|
|
226
|
+
if (imagesFailed.length > 0) {
|
|
227
|
+
const sample = imagesFailed.slice(0, 10).map(f => `${f.record_id} (${f.reason})`);
|
|
228
|
+
const msg =
|
|
229
|
+
`training-package: ${imagesFailed.length} of ${filteredRows.length} rows failed to place` +
|
|
230
|
+
(imagesFailed.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`);
|
|
231
|
+
warnings.push(msg);
|
|
232
|
+
}
|
|
110
233
|
|
|
111
234
|
// D-015: Write manifest + README FIRST so they are covered by checksums.
|
|
112
235
|
const packageManifest = {
|
|
@@ -122,14 +245,23 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
122
245
|
source_snapshot_id: manifest.source_snapshot_id,
|
|
123
246
|
source_split_id: manifest.source_split_id,
|
|
124
247
|
config_fingerprint: manifest.config_fingerprint,
|
|
248
|
+
warnings,
|
|
125
249
|
counts: {
|
|
126
250
|
total_records: totalRecords,
|
|
127
251
|
train: partitionCounts.train,
|
|
128
252
|
val: partitionCounts.val,
|
|
129
253
|
test: partitionCounts.test,
|
|
130
254
|
images: totalImages,
|
|
255
|
+
// SDL-C5: expected/actual/failed breakdown (export.js DB-002 shape)
|
|
256
|
+
// so a partially- or totally-failed placement is visible in the
|
|
257
|
+
// manifest, not just inferable from a suspiciously-low `images`.
|
|
258
|
+
images_expected: filteredRows.length,
|
|
259
|
+
images_actual: totalImages,
|
|
260
|
+
images_failed: imagesFailed.length,
|
|
131
261
|
checksums: 0, // updated after checksum pass below
|
|
132
262
|
},
|
|
263
|
+
// SDL-C5: record-level failure detail for operator triage.
|
|
264
|
+
images_failed: imagesFailed,
|
|
133
265
|
};
|
|
134
266
|
await writeFile(join(packageDir, 'manifest.json'), JSON.stringify(packageManifest, null, 2) + '\n');
|
|
135
267
|
|
package/lib/verifier.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXTERNAL_VERIFIER support (Kambhampati arXiv:2402.01817; Huang / DeepMind
|
|
3
|
+
* arXiv:2310.01798): a judgment records WHO judged it (`judged_by_model`) and
|
|
4
|
+
* WHAT produced the artifact (`generator_model`), and we WARN when they are the
|
|
5
|
+
* same — the self-verification failure mode a verifier must never fall into.
|
|
6
|
+
*
|
|
7
|
+
* Today the two judges are `human` (curate) and a rule-based engine
|
|
8
|
+
* (critique-engine) — both a different artifact class from the image generator,
|
|
9
|
+
* so the warning does not fire in practice and the standard's spirit is already
|
|
10
|
+
* met. Recording the fields + wiring the warning installs the muscle: the moment
|
|
11
|
+
* an LLM critique mode enters the loop sharing the generator's base, the WARN
|
|
12
|
+
* fires, and the standard's remediation flips it from a warning to a hard gate.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { warn } from './log.js';
|
|
16
|
+
|
|
17
|
+
/** The rule-based critique engine's judge id. Bump vN when the rule set changes. */
|
|
18
|
+
export const RULE_BASED_JUDGE = 'rule-based:sdlab-critique-v1';
|
|
19
|
+
|
|
20
|
+
/** The human curator's judge id (curate.js reviewer is "human:mike"). */
|
|
21
|
+
export const HUMAN_JUDGE = 'human';
|
|
22
|
+
|
|
23
|
+
const MODEL_EXT_RE = /\.(safetensors|ckpt|pt|pth|bin|gguf)$/i;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Derive a stable generator model id from a record `provenance` (or any object
|
|
27
|
+
* carrying {base, unet|checkpoint}): `<base>:<model-basename-without-ext>`, e.g.
|
|
28
|
+
* "qwen-image:qwen_image_fp8_e4m3fn" or "sdxl:dreamshaperXL". Returns null when
|
|
29
|
+
* neither a base nor a model file is known (legacy/hand-migrated records).
|
|
30
|
+
*/
|
|
31
|
+
export function deriveGeneratorModel(provenance) {
|
|
32
|
+
if (!provenance || typeof provenance !== 'object') return null;
|
|
33
|
+
const base = provenance.base || null;
|
|
34
|
+
const modelFile = provenance.unet || provenance.checkpoint || null;
|
|
35
|
+
const stripped = modelFile ? String(modelFile).replace(MODEL_EXT_RE, '') : null;
|
|
36
|
+
if (base && stripped) return `${base}:${stripped}`;
|
|
37
|
+
return base || stripped || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Derive the generator model id from a run manifest (comfyui-runner shape).
|
|
42
|
+
* Prefers the pinning block's model identity, falling back to the top-level
|
|
43
|
+
* checkpoint. Qwen manifests carry pinning.models.unet; SDXL carry checkpoint.
|
|
44
|
+
*/
|
|
45
|
+
export function generatorModelFromManifest(manifest) {
|
|
46
|
+
if (!manifest || typeof manifest !== 'object') return null;
|
|
47
|
+
const unet = manifest.pinning?.models?.unet?.name;
|
|
48
|
+
const checkpoint = manifest.checkpoint || manifest.pinning?.models?.checkpoint?.name;
|
|
49
|
+
const base = unet ? 'qwen-image' : (checkpoint ? 'sdxl' : null);
|
|
50
|
+
return deriveGeneratorModel({ base, unet, checkpoint });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Emit a WARN when the judge and the generator are the same model — the
|
|
55
|
+
* self-verification failure mode. No-op when either is null or they differ.
|
|
56
|
+
* Returns true when the warning fired, so callers/tests can assert on it without
|
|
57
|
+
* capturing stderr.
|
|
58
|
+
*/
|
|
59
|
+
export function warnIfSelfVerification(judgedByModel, generatorModel, context = '') {
|
|
60
|
+
if (!judgedByModel || !generatorModel) return false;
|
|
61
|
+
if (judgedByModel !== generatorModel) return false;
|
|
62
|
+
warn(
|
|
63
|
+
`EXTERNAL_VERIFIER: judged_by_model === generator_model ("${judgedByModel}")${context ? ` for ${context}` : ''} — ` +
|
|
64
|
+
'a model must not verify its own output. Use a different-family judge (ai-eyes-mcp or a cross-family LLM).',
|
|
65
|
+
);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
package/lib/workflow-profiles.js
CHANGED
|
@@ -12,6 +12,7 @@ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
|
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
14
|
import { getWorkflowProfilesDir } from './paths.js';
|
|
15
|
+
import { inputError } from './errors.js';
|
|
15
16
|
|
|
16
17
|
const REQUIRED_FIELDS = [
|
|
17
18
|
'workflow_id', 'label', 'lane_id', 'output_mode', 'output_count',
|
|
@@ -105,12 +106,32 @@ export async function getWorkflowProfile(projectRoot, workflowId) {
|
|
|
105
106
|
const dir = getWorkflowProfilesDir(projectRoot);
|
|
106
107
|
const path = join(dir, `${workflowId}.json`);
|
|
107
108
|
if (!existsSync(path)) {
|
|
108
|
-
throw
|
|
109
|
+
throw inputError(
|
|
110
|
+
'WORKFLOW_PROFILE_NOT_FOUND',
|
|
111
|
+
`Workflow profile "${workflowId}" not found at ${path}`,
|
|
112
|
+
'Check the workflow id, or run: sdlab workflow list'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
// B06: JSON.parse used to run unguarded here, so a malformed profile file
|
|
116
|
+
// threw a raw, uncaught SyntaxError naming nothing. Wrapped in the same
|
|
117
|
+
// structured-error pattern lib/config.js's loadJsonFile already uses.
|
|
118
|
+
let data;
|
|
119
|
+
try {
|
|
120
|
+
data = JSON.parse(await readFile(path, 'utf-8'));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw inputError(
|
|
123
|
+
'INPUT_INVALID_JSON',
|
|
124
|
+
`Failed to parse ${path}: ${err.message}`,
|
|
125
|
+
'Check the file for JSON syntax errors (trailing commas, unquoted keys, etc).'
|
|
126
|
+
);
|
|
109
127
|
}
|
|
110
|
-
const data = JSON.parse(await readFile(path, 'utf-8'));
|
|
111
128
|
const { valid, errors } = validateWorkflowProfile(data);
|
|
112
129
|
if (!valid) {
|
|
113
|
-
throw
|
|
130
|
+
throw inputError(
|
|
131
|
+
'WORKFLOW_PROFILE_INVALID',
|
|
132
|
+
`Workflow profile "${workflowId}" is invalid:\n ${errors.join('\n ')}`,
|
|
133
|
+
'Fix the listed field(s) and try again.'
|
|
134
|
+
);
|
|
114
135
|
}
|
|
115
136
|
return data;
|
|
116
137
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcptoolshop/style-dataset-lab",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
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": {
|
|
@@ -40,10 +40,11 @@
|
|
|
40
40
|
"files": [
|
|
41
41
|
"bin/",
|
|
42
42
|
"lib/",
|
|
43
|
+
"runtime/",
|
|
43
44
|
"scripts/",
|
|
44
45
|
"!scripts/bulk-curate-*.js",
|
|
45
46
|
"!scripts/curate-wave*.js",
|
|
46
|
-
"!scripts/
|
|
47
|
+
"!scripts/__pycache__/**",
|
|
47
48
|
"templates/",
|
|
48
49
|
"README.md",
|
|
49
50
|
"LICENSE",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"template_id": "txt2img-qwen",
|
|
3
|
+
"label": "Text-to-Image — Qwen-Image (non-anime studio base)",
|
|
4
|
+
"description": "Qwen-Image txt2img pipeline: UNETLoader → CLIPLoader(qwen_image) + VAELoader → ModelSamplingAuraFlow(shift) → EmptySD3LatentImage → KSampler(euler/simple) → VAEDecode → SaveImage. The studio's verified NON-ANIME base (Apache-2.0). Selected by project/pack defaults.base = \"qwen-image\".",
|
|
5
|
+
"adapter_target": "comfyui",
|
|
6
|
+
"base": "qwen-image",
|
|
7
|
+
"compatible_modes": ["portrait_set", "expression_sheet", "variant_pack", "moodboard", "silhouette_sheet", "turnaround", "concept_art"],
|
|
8
|
+
"placeholders": {
|
|
9
|
+
"PROMPT": "Positive prompt text",
|
|
10
|
+
"NEGATIVE_PROMPT": "Negative prompt text (always include anime/cartoon/cel-shading terms — non-anime is non-negotiable)",
|
|
11
|
+
"UNET": "Diffusion model filename (default qwen_image_fp8_e4m3fn.safetensors)",
|
|
12
|
+
"CLIP": "Text encoder filename (default qwen_2.5_vl_7b_fp8_scaled.safetensors, loaded with type=qwen_image)",
|
|
13
|
+
"VAE": "VAE filename (default qwen_image_vae.safetensors)",
|
|
14
|
+
"SHIFT": "ModelSamplingAuraFlow shift (default 3.1)",
|
|
15
|
+
"WIDTH": "Image width (default 1024)",
|
|
16
|
+
"HEIGHT": "Image height (default 1024)",
|
|
17
|
+
"STEPS": "Sampling steps (default 22)",
|
|
18
|
+
"CFG": "Classifier-free guidance scale (default 3.5)",
|
|
19
|
+
"SAMPLER": "Sampler name (default euler)",
|
|
20
|
+
"SCHEDULER": "Scheduler name (default simple)",
|
|
21
|
+
"SEED": "Random seed"
|
|
22
|
+
},
|
|
23
|
+
"defaults": {
|
|
24
|
+
"unet": "qwen_image_fp8_e4m3fn.safetensors",
|
|
25
|
+
"clip": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
|
26
|
+
"clip_type": "qwen_image",
|
|
27
|
+
"vae": "qwen_image_vae.safetensors",
|
|
28
|
+
"weight_dtype": "default",
|
|
29
|
+
"shift": 3.1,
|
|
30
|
+
"sampler": "euler",
|
|
31
|
+
"scheduler": "simple",
|
|
32
|
+
"steps": 22,
|
|
33
|
+
"cfg": 3.5,
|
|
34
|
+
"width": 1024,
|
|
35
|
+
"height": 1024
|
|
36
|
+
},
|
|
37
|
+
"notes": "Built programmatically — the JSON above is a reference. lib/adapters/comfyui-workflows.js buildQwenGraph() constructs the live graph; buildWorkflowGraph() and scripts/generate.js both route to it when base === 'qwen-image'. Node ids/wiring mirror scripts/qwen_generate.py for cross-runner reproducibility. NEVER substitute an anime checkpoint (Illustrious/Pony/Animagine/NoobAI) — see memory/feedback_no_anime.md."
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"template_id": "txt2img-standard",
|
|
3
|
+
"label": "Text-to-Image Standard",
|
|
4
|
+
"description": "Standard txt2img pipeline: checkpoint → LoRA chain → KSampler → VAEDecode → SaveImage.",
|
|
5
|
+
"adapter_target": "comfyui",
|
|
6
|
+
"compatible_modes": ["portrait_set", "expression_sheet", "variant_pack", "moodboard", "silhouette_sheet", "turnaround"],
|
|
7
|
+
"placeholders": {
|
|
8
|
+
"PROMPT": "Positive prompt text",
|
|
9
|
+
"NEGATIVE_PROMPT": "Negative prompt text",
|
|
10
|
+
"CHECKPOINT": "Checkpoint filename",
|
|
11
|
+
"WIDTH": "Image width",
|
|
12
|
+
"HEIGHT": "Image height",
|
|
13
|
+
"STEPS": "Sampling steps",
|
|
14
|
+
"CFG": "Classifier-free guidance scale",
|
|
15
|
+
"SAMPLER": "Sampler name",
|
|
16
|
+
"SCHEDULER": "Scheduler name",
|
|
17
|
+
"SEED": "Random seed",
|
|
18
|
+
"LORAS": "Array of {name, weight, clip_weight?} objects"
|
|
19
|
+
},
|
|
20
|
+
"notes": "This template is built programmatically — the JSON below is a reference. comfyui-workflows.js uses buildWorkflowGraph() to construct the graph dynamically with LoRA chain support."
|
|
21
|
+
}
|