@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. 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, mkdir, readdir } from 'node:fs/promises';
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
- const manifestId = generateManifestId();
67
- const manifest = {
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: manifestId,
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
- // Write manifest
93
- const dir = join(projectRoot, 'training', 'manifests');
94
- await mkdir(dir, { recursive: true });
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');
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) {
@@ -6,19 +6,21 @@
6
6
  * but never mutate inclusion or split truth.
7
7
  */
8
8
 
9
- import { readFile, writeFile, mkdir, readdir, symlink, copyFile } from 'node:fs/promises';
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) continue;
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
- const row = recordToRow(record, lane, group, profile, partition);
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
 
@@ -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
+ }
@@ -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 new Error(`Workflow profile "${workflowId}" not found at ${path}`);
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 new Error(`Workflow profile "${workflowId}" is invalid:\n ${errors.join('\n ')}`);
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.2.0",
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/migrate-records.js",
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
+ }