@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.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 (119) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +117 -55
  3. package/README.fr.md +125 -65
  4. package/README.hi.md +117 -68
  5. package/README.it.md +119 -58
  6. package/README.ja.md +124 -74
  7. package/README.md +73 -7
  8. package/README.pt-BR.md +128 -63
  9. package/README.zh.md +116 -65
  10. package/bin/sdlab.js +100 -4
  11. package/lib/adapters/ai-toolkit.js +224 -35
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +94 -11
  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/asset-ingest.js +575 -0
  18. package/lib/asset-masks.js +323 -0
  19. package/lib/asset-source.js +1176 -0
  20. package/lib/batch-modes.js +29 -8
  21. package/lib/batch-runs.js +29 -1
  22. package/lib/batch-sheet-render.js +13 -3
  23. package/lib/brief-compiler.js +19 -0
  24. package/lib/candidate-sheet.js +327 -0
  25. package/lib/canon-author.js +350 -0
  26. package/lib/canon-build/build.js +146 -93
  27. package/lib/canon-review-gate.js +205 -0
  28. package/lib/captions.js +57 -0
  29. package/lib/card.js +74 -2
  30. package/lib/colab-launcher-guards.js +285 -0
  31. package/lib/comfyui.js +116 -18
  32. package/lib/config.js +116 -12
  33. package/lib/critique-engine.js +36 -2
  34. package/lib/eligibility.js +121 -9
  35. package/lib/eval-pack.js +14 -14
  36. package/lib/eval-runs.js +33 -0
  37. package/lib/export.js +122 -34
  38. package/lib/freeze-gate.js +16 -3
  39. package/lib/freeze-stamp.js +28 -3
  40. package/lib/generated-provenance.js +194 -6
  41. package/lib/implementation-packs.js +95 -17
  42. package/lib/ingest.js +396 -0
  43. package/lib/judgment-provenance.js +220 -0
  44. package/lib/lane1-render.js +668 -0
  45. package/lib/measure.js +422 -0
  46. package/lib/memory-store.js +663 -0
  47. package/lib/model-licences.js +305 -0
  48. package/lib/npy-meta.js +130 -0
  49. package/lib/paths.js +196 -7
  50. package/lib/png-meta.js +512 -0
  51. package/lib/records.js +54 -8
  52. package/lib/reingest-selected.js +113 -17
  53. package/lib/reingest.js +27 -3
  54. package/lib/run-manifest.js +159 -25
  55. package/lib/run-summary.js +12 -6
  56. package/lib/runtime-runs.js +45 -4
  57. package/lib/snapshot.js +114 -15
  58. package/lib/split.js +364 -48
  59. package/lib/train-runs.js +214 -0
  60. package/lib/training-manifests.js +114 -21
  61. package/lib/training-packages.js +147 -15
  62. package/lib/verifier.js +3 -0
  63. package/lib/verify-chain.js +367 -0
  64. package/lib/workflow-profiles.js +24 -3
  65. package/package.json +4 -3
  66. package/runtime/comfyui/txt2img-qwen.json +38 -0
  67. package/runtime/comfyui/txt2img-standard.json +21 -0
  68. package/scripts/_resume.js +62 -0
  69. package/scripts/_shared-args.js +44 -0
  70. package/scripts/asset-ingest.js +160 -0
  71. package/scripts/brief-compile.js +2 -1
  72. package/scripts/brief-show.js +2 -1
  73. package/scripts/canon-bind.js +2 -2
  74. package/scripts/canon-build.js +27 -20
  75. package/scripts/canon-draft.js +82 -0
  76. package/scripts/canon-drift.js +16 -7
  77. package/scripts/canon-freeze-status.js +14 -6
  78. package/scripts/canon-freeze.js +31 -11
  79. package/scripts/canon-unfreeze.js +17 -9
  80. package/scripts/card.js +2 -2
  81. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  82. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  83. package/scripts/colab/colab_window.sh +587 -0
  84. package/scripts/colab/lane1_render.py +970 -0
  85. package/scripts/colab/launch_guards.py +564 -0
  86. package/scripts/colab/launch_lane1_stub.sh +309 -0
  87. package/scripts/colab/launch_lane3_stub.sh +366 -0
  88. package/scripts/colab/remote_runner.py +59 -0
  89. package/scripts/compare.js +2 -2
  90. package/scripts/critique.js +16 -1
  91. package/scripts/curate.js +2 -2
  92. package/scripts/doctor.js +182 -55
  93. package/scripts/eligibility.js +42 -2
  94. package/scripts/eval-pack.js +2 -2
  95. package/scripts/eval-run.js +2 -2
  96. package/scripts/export.js +2 -2
  97. package/scripts/generate-controlnet.js +45 -25
  98. package/scripts/generate-identity.js +53 -23
  99. package/scripts/generate-ipadapter.js +44 -23
  100. package/scripts/generate.js +86 -17
  101. package/scripts/implementation-pack.js +2 -2
  102. package/scripts/ingest.js +145 -0
  103. package/scripts/init.js +73 -13
  104. package/scripts/measure.js +170 -0
  105. package/scripts/measure_image.py +458 -0
  106. package/scripts/migrate-records.js +142 -0
  107. package/scripts/painterly-test.js +4 -3
  108. package/scripts/painterly.js +7 -3
  109. package/scripts/qwen_generate.py +138 -20
  110. package/scripts/reingest.js +2 -2
  111. package/scripts/sheet.js +142 -0
  112. package/scripts/snapshot.js +2 -2
  113. package/scripts/split.js +2 -2
  114. package/scripts/training-manifest.js +2 -2
  115. package/scripts/training-package.js +2 -2
  116. package/scripts/training-profile.js +2 -2
  117. package/scripts/verify.js +67 -0
  118. package/scripts/workflow-list.js +2 -1
  119. package/scripts/workflow-show.js +2 -1
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Trained-LoRA provenance record (`train-run.json`) contract (DESIGN-v3 §7, G13).
3
+ *
4
+ * Links every trained LoRA checkpoint back to:
5
+ * - dataset_package_id + dataset_manifest_sha256
6
+ * - config_sha256 (ai-toolkit-config.yaml)
7
+ * - override_file + override_sha256 + upstream_issue (#1087)
8
+ * - base_model pin with cleared Apache-2.0 SPDX licence
9
+ * - window_id
10
+ * - steps, checkpoint_sha256s, sample_grid_sha256s
11
+ * - eval_pack_sha (attached once the eval pack runs)
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+ import {
17
+ AI_TOOLKIT_UPSTREAM_ISSUE,
18
+ assertNotHeldTrainingData,
19
+ getAiToolkitOverrideMetadata,
20
+ } from './adapters/ai-toolkit.js';
21
+ import { inputError as rawInputError } from './errors.js';
22
+
23
+ function inputError(code, msg) {
24
+ return rawInputError(code, `${code}: ${msg}`);
25
+ }
26
+
27
+ export const CLEARED_TRAIN_BASE_LICENCES = new Set([
28
+ 'Apache-2.0',
29
+ 'MIT',
30
+ 'BSD-2-Clause',
31
+ 'BSD-3-Clause',
32
+ 'CC0-1.0',
33
+ 'CC-BY-4.0',
34
+ ]);
35
+
36
+ function normalizeSha256(val) {
37
+ if (typeof val !== 'string') return null;
38
+ const trimmed = val.trim().toLowerCase();
39
+ const hex = trimmed.startsWith('sha256:') ? trimmed.slice(7) : trimmed;
40
+ if (!/^[0-9a-f]{64}$/.test(hex)) return null;
41
+ return `sha256:${hex}`;
42
+ }
43
+
44
+ /**
45
+ * Build and validate a `train-run.json` record (§7, G13).
46
+ *
47
+ * @param {Object} opts
48
+ * @returns {Object}
49
+ */
50
+ export function buildTrainRunRecord(opts = {}) {
51
+ const {
52
+ run_id,
53
+ window_id,
54
+ dataset_package_id,
55
+ dataset_manifest_sha256,
56
+ config_sha256,
57
+ override_file,
58
+ override_sha256,
59
+ upstream_issue = AI_TOOLKIT_UPSTREAM_ISSUE,
60
+ base_model,
61
+ steps = [],
62
+ checkpoint_sha256s = {},
63
+ sample_grid_sha256s = {},
64
+ eval_pack_sha = null,
65
+ created_at = new Date().toISOString(),
66
+ } = opts;
67
+
68
+ if (!run_id || typeof run_id !== 'string') {
69
+ throw inputError('TRAIN_RUN_INVALID', 'train-run.json requires a non-empty "run_id".');
70
+ }
71
+ if (!window_id || typeof window_id !== 'string') {
72
+ throw inputError('TRAIN_RUN_INVALID', 'train-run.json requires a non-empty "window_id".');
73
+ }
74
+ if (!dataset_package_id || typeof dataset_package_id !== 'string') {
75
+ throw inputError('TRAIN_RUN_INVALID', 'train-run.json requires a non-empty "dataset_package_id".');
76
+ }
77
+
78
+ assertNotHeldTrainingData({
79
+ profile: { profile_id: dataset_package_id },
80
+ manifest: { source_export_id: run_id },
81
+ });
82
+
83
+ const normDatasetSha = normalizeSha256(dataset_manifest_sha256);
84
+ const normConfigSha = normalizeSha256(config_sha256);
85
+ const defaultOverride = getAiToolkitOverrideMetadata();
86
+ const normOverrideSha = normalizeSha256(override_sha256 || defaultOverride.override_sha256);
87
+
88
+ if (!normDatasetSha || !normConfigSha || !normOverrideSha) {
89
+ throw inputError(
90
+ 'TRAIN_RUN_SHA_INVALID',
91
+ 'train-run.json requires valid 64-hex dataset_manifest_sha256, config_sha256, and override_sha256.'
92
+ );
93
+ }
94
+
95
+ if (!base_model || typeof base_model !== 'object' || !base_model.name) {
96
+ throw inputError('TRAIN_RUN_BASE_MODEL_INVALID', 'train-run.json requires a base_model object with name.');
97
+ }
98
+ const normBaseSha = normalizeSha256(base_model.sha256);
99
+ if (!normBaseSha) {
100
+ throw inputError('TRAIN_RUN_SHA_INVALID', 'base_model.sha256 must be a valid 64-hex sha256 digest.');
101
+ }
102
+ if (
103
+ !base_model.licence_id ||
104
+ !CLEARED_TRAIN_BASE_LICENCES.has(base_model.licence_id) ||
105
+ !base_model.licence_url ||
106
+ !base_model.date_read
107
+ ) {
108
+ throw inputError(
109
+ 'TRAIN_RUN_LICENCE_INVALID',
110
+ `base_model "${base_model.name}" must carry a cleared commercial SPDX licence_id (${[...CLEARED_TRAIN_BASE_LICENCES].join(', ')}), licence_url, and date_read (got "${base_model.licence_id}").`
111
+ );
112
+ }
113
+
114
+ if (!Array.isArray(steps) || steps.length === 0) {
115
+ throw inputError('TRAIN_RUN_STEPS_EMPTY', 'train-run.json requires at least one checkpoint step.');
116
+ }
117
+
118
+ const normalizedCheckpoints = {};
119
+ const normalizedSamples = {};
120
+ for (const step of steps) {
121
+ const key = String(step);
122
+ const ckptSha = normalizeSha256(checkpoint_sha256s[key] ?? checkpoint_sha256s[step]);
123
+ if (!ckptSha) {
124
+ throw inputError(
125
+ 'TRAIN_RUN_SHA_INVALID',
126
+ `Missing or invalid checkpoint_sha256 for step ${key}.`
127
+ );
128
+ }
129
+ normalizedCheckpoints[key] = ckptSha;
130
+
131
+ const gridRaw = sample_grid_sha256s[key] ?? sample_grid_sha256s[step];
132
+ if (gridRaw !== undefined && gridRaw !== null) {
133
+ const gridSha = normalizeSha256(gridRaw);
134
+ if (!gridSha) {
135
+ throw inputError(
136
+ 'TRAIN_RUN_SHA_INVALID',
137
+ `Invalid sample_grid_sha256 for step ${key}.`
138
+ );
139
+ }
140
+ normalizedSamples[key] = gridSha;
141
+ }
142
+ }
143
+
144
+ const normEvalSha = eval_pack_sha ? normalizeSha256(eval_pack_sha) : null;
145
+ if (eval_pack_sha && !normEvalSha) {
146
+ throw inputError('TRAIN_RUN_SHA_INVALID', 'eval_pack_sha must be null or a valid 64-hex sha256 digest.');
147
+ }
148
+
149
+ return {
150
+ run_id,
151
+ window_id,
152
+ lane: 'train',
153
+ dataset_package_id,
154
+ dataset_manifest_sha256: normDatasetSha,
155
+ config_sha256: normConfigSha,
156
+ override_file: override_file || defaultOverride.override_file,
157
+ override_sha256: normOverrideSha,
158
+ upstream_issue,
159
+ base_model: {
160
+ name: base_model.name,
161
+ repo: base_model.repo || base_model.name,
162
+ revision: base_model.revision || 'main',
163
+ sha256: normBaseSha,
164
+ licence_id: base_model.licence_id,
165
+ licence_url: base_model.licence_url,
166
+ date_read: base_model.date_read,
167
+ },
168
+ steps: steps.map(Number),
169
+ checkpoint_sha256s: normalizedCheckpoints,
170
+ sample_grid_sha256s: normalizedSamples,
171
+ eval_pack_sha: normEvalSha,
172
+ created_at,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Write `train-run.json` to disk.
178
+ *
179
+ * @param {string} trainRunPath
180
+ * @param {Object} record
181
+ * @returns {Object}
182
+ */
183
+ export function writeTrainRunJson(trainRunPath, record) {
184
+ const validated = buildTrainRunRecord(record);
185
+ mkdirSync(dirname(trainRunPath), { recursive: true });
186
+ writeFileSync(trainRunPath, JSON.stringify(validated, null, 2) + '\n', 'utf-8');
187
+ return validated;
188
+ }
189
+
190
+ /**
191
+ * Read and validate `train-run.json` from disk.
192
+ *
193
+ * @param {string} trainRunPath
194
+ * @returns {Object}
195
+ */
196
+ export function readTrainRunJson(trainRunPath) {
197
+ const parsed = JSON.parse(readFileSync(trainRunPath, 'utf-8'));
198
+ return buildTrainRunRecord(parsed);
199
+ }
200
+
201
+ /**
202
+ * Attach `eval_pack_sha` to an existing `train-run.json` once the eval pack runs (G13).
203
+ *
204
+ * @param {string} trainRunPath
205
+ * @param {string} evalPackSha
206
+ * @returns {Object}
207
+ */
208
+ export function attachEvalPackSha(trainRunPath, evalPackSha) {
209
+ const current = readTrainRunJson(trainRunPath);
210
+ return writeTrainRunJson(trainRunPath, {
211
+ ...current,
212
+ eval_pack_sha: evalPackSha,
213
+ });
214
+ }
@@ -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
 
package/lib/verifier.js CHANGED
@@ -13,6 +13,9 @@
13
13
  */
14
14
 
15
15
  import { warn } from './log.js';
16
+ import { assertNoSelfVerification } from './canon-review-gate.js';
17
+
18
+ export { assertNoSelfVerification };
16
19
 
17
20
  /** The rule-based critique engine's judge id. Bump vN when the rule set changes. */
18
21
  export const RULE_BASED_JUDGE = 'rule-based:sdlab-critique-v1';