@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
@@ -3,12 +3,117 @@
3
3
  * attached to records created from selected run/batch outputs.
4
4
  *
5
5
  * Normalizes source context (run vs batch) into a single shape
6
- * that traces back through brief → workflow → seed → config.
6
+ * that traces back through brief → workflow → seed → config,
7
+ * plus G4 (model licences), G5 (owner + CC-BY-4.0), and G6 (canon link).
7
8
  */
8
9
 
9
10
  import { readFileSync, existsSync } from 'node:fs';
10
11
  import { join } from 'node:path';
11
12
  import { createHash } from 'node:crypto';
13
+ import { warn } from './log.js';
14
+ import { STUDIO_OWNER, STUDIO_LICENCE } from './records.js';
15
+
16
+ /**
17
+ * Resolve `{ canon_sha, entry_id, entry_sha256 }` from explicit metadata or
18
+ * the project's `canon-build/<canon_sha>/manifest.json` (`per_entity_hashes`).
19
+ */
20
+ export function resolveCanonLink(
21
+ projectRoot,
22
+ {
23
+ canonSha = null,
24
+ entryId = null,
25
+ entrySha256 = null,
26
+ subjectId = null,
27
+ runManifest = null,
28
+ batchManifest = null,
29
+ item = null,
30
+ brief = null,
31
+ } = {},
32
+ ) {
33
+ let resolvedCanonSha =
34
+ canonSha ||
35
+ item?.canon_sha ||
36
+ runManifest?.canon_sha ||
37
+ batchManifest?.canon_sha ||
38
+ brief?.canon_sha ||
39
+ null;
40
+
41
+ if (!resolvedCanonSha && projectRoot) {
42
+ const currentPath = join(projectRoot, 'canon-build', 'CURRENT');
43
+ if (existsSync(currentPath)) {
44
+ try {
45
+ const raw = readFileSync(currentPath, 'utf-8').trim();
46
+ if (raw) resolvedCanonSha = raw;
47
+ } catch {
48
+ // best-effort read of CURRENT pointer
49
+ }
50
+ }
51
+ }
52
+
53
+ let resolvedEntryId =
54
+ entryId ||
55
+ item?.entry_id ||
56
+ runManifest?.entry_id ||
57
+ batchManifest?.entry_id ||
58
+ brief?.entry_id ||
59
+ subjectId ||
60
+ item?.subject_id ||
61
+ runManifest?.subject_id ||
62
+ batchManifest?.subject_id ||
63
+ brief?.subject_id ||
64
+ null;
65
+
66
+ let resolvedEntrySha =
67
+ entrySha256 ||
68
+ item?.entry_sha256 ||
69
+ runManifest?.entry_sha256 ||
70
+ batchManifest?.entry_sha256 ||
71
+ brief?.entry_sha256 ||
72
+ null;
73
+
74
+ if (resolvedCanonSha && projectRoot) {
75
+ const dirName = String(resolvedCanonSha).replace(/:/g, '-');
76
+ const manifestPath = existsSync(join(projectRoot, 'canon-build', String(resolvedCanonSha), 'manifest.json'))
77
+ ? join(projectRoot, 'canon-build', String(resolvedCanonSha), 'manifest.json')
78
+ : join(projectRoot, 'canon-build', dirName, 'manifest.json');
79
+ if (existsSync(manifestPath)) {
80
+ try {
81
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
82
+ const hashes = manifest.per_entity_hashes || {};
83
+ if (resolvedEntryId) {
84
+ if (hashes[resolvedEntryId]) {
85
+ resolvedEntrySha = resolvedEntrySha || hashes[resolvedEntryId];
86
+ } else {
87
+ const suffix = `/${resolvedEntryId}`;
88
+ const matchKey = Object.keys(hashes).find((k) => k.endsWith(suffix));
89
+ if (matchKey) {
90
+ resolvedEntryId = matchKey;
91
+ resolvedEntrySha = resolvedEntrySha || hashes[matchKey];
92
+ }
93
+ }
94
+ } else {
95
+ const keys = Object.keys(hashes);
96
+ if (keys.length === 1) {
97
+ resolvedEntryId = keys[0];
98
+ resolvedEntrySha = resolvedEntrySha || hashes[keys[0]];
99
+ }
100
+ }
101
+ } catch {
102
+ // best-effort manifest lookup
103
+ }
104
+ }
105
+ }
106
+
107
+ if (!resolvedCanonSha && !resolvedEntryId && !resolvedEntrySha) {
108
+ return null;
109
+ }
110
+
111
+ return {
112
+ canon_sha: resolvedCanonSha,
113
+ entry_id: resolvedEntryId,
114
+ entry_sha256: resolvedEntrySha,
115
+ };
116
+ }
12
117
 
13
118
  /**
14
119
  * Build a generation provenance payload for a selected output.
@@ -20,6 +125,9 @@ import { createHash } from 'node:crypto';
20
125
  * @param {Object} [opts.batchManifest] — batch manifest if source is batch
21
126
  * @param {Object} opts.item — the selection item (slot_or_output, filename, seed)
22
127
  * @param {string} opts.projectRoot
128
+ * @param {string} [opts.canonSha]
129
+ * @param {string} [opts.entryId]
130
+ * @param {string} [opts.entrySha256]
23
131
  * @returns {Object} generation_provenance block
24
132
  */
25
133
  export function buildGeneratedProvenance({
@@ -29,8 +137,13 @@ export function buildGeneratedProvenance({
29
137
  batchManifest,
30
138
  item,
31
139
  projectRoot,
140
+ canonSha = null,
141
+ entryId = null,
142
+ entrySha256 = null,
32
143
  }) {
33
144
  const prov = {
145
+ owner: STUDIO_OWNER,
146
+ licence: STUDIO_LICENCE,
34
147
  source_type: sourceType,
35
148
  source_id: sourceId,
36
149
  run_id: runManifest.run_id,
@@ -44,12 +157,55 @@ export function buildGeneratedProvenance({
44
157
  prov.batch_id = batchManifest.batch_id;
45
158
  }
46
159
 
160
+ const matchedOutput = runManifest.outputs
161
+ ? runManifest.outputs.find((o) => o.filename === item.filename)
162
+ : null;
163
+
47
164
  // Seed from the item or from the run manifest outputs
48
165
  if (item.seed !== undefined) {
49
166
  prov.seed = item.seed;
50
- } else if (runManifest.outputs) {
51
- const match = runManifest.outputs.find(o => o.filename === item.filename);
52
- if (match?.seed !== undefined) prov.seed = match.seed;
167
+ } else if (matchedOutput?.seed !== undefined) {
168
+ prov.seed = matchedOutput.seed;
169
+ }
170
+
171
+ // Prompt and prompt_sha256 when available on the item or output
172
+ const promptText = item.prompt ?? matchedOutput?.prompt ?? null;
173
+ if (typeof promptText === 'string' && promptText.length > 0) {
174
+ prov.prompt = promptText;
175
+ prov.prompt_sha256 = 'sha256:' + createHash('sha256').update(promptText).digest('hex');
176
+ } else if (item.prompt_sha256 || matchedOutput?.prompt_sha256) {
177
+ prov.prompt_sha256 = item.prompt_sha256 || matchedOutput.prompt_sha256;
178
+ }
179
+
180
+ // Comfy workflow sha and model licences from run manifest pinning (G4, G9)
181
+ const wfSha = runManifest.pinning?.comfy_workflow_sha ?? runManifest.comfy_workflow_sha ?? null;
182
+ if (wfSha) {
183
+ prov.comfy_workflow_sha = wfSha;
184
+ }
185
+ if (runManifest.pinning?.models || runManifest.pinning?.loras) {
186
+ prov.model_licences = {
187
+ cc_by_4_eligible: runManifest.pinning.cc_by_4_eligible ?? null,
188
+ models: Object.fromEntries(
189
+ Object.entries(runManifest.pinning.models || {}).map(([role, m]) => [
190
+ role,
191
+ {
192
+ name: m.name,
193
+ sha256: m.sha256 ?? null,
194
+ licence_id: m.licence_id ?? m.licence?.spdx_id ?? null,
195
+ licence_url: m.licence_url ?? m.licence?.url ?? null,
196
+ date_read: m.date_read ?? m.licence?.verified_at ?? null,
197
+ },
198
+ ]),
199
+ ),
200
+ loras: (runManifest.pinning.loras || []).map((l) => ({
201
+ name: l.name,
202
+ weight: l.weight ?? 1.0,
203
+ sha256: l.sha256 ?? null,
204
+ licence_id: l.licence_id ?? l.licence?.spdx_id ?? null,
205
+ licence_url: l.licence_url ?? l.licence?.url ?? null,
206
+ date_read: l.date_read ?? l.licence?.verified_at ?? null,
207
+ })),
208
+ };
53
209
  }
54
210
 
55
211
  // Adapter target from run manifest
@@ -57,21 +213,53 @@ export function buildGeneratedProvenance({
57
213
  prov.adapter_target = runManifest.adapter_target;
58
214
  }
59
215
 
216
+ let parsedBrief = null;
60
217
  // Subject from run brief or batch manifest
61
218
  if (batchManifest?.subject_id) {
62
219
  prov.subject_id = batchManifest.subject_id;
63
- } else {
220
+ } else if (projectRoot && runManifest.brief_id) {
64
221
  // Try to read subject from brief
65
222
  const briefPath = join(projectRoot, 'briefs', `${runManifest.brief_id}.json`);
66
223
  if (existsSync(briefPath)) {
67
224
  try {
68
225
  const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
226
+ parsedBrief = brief;
69
227
  if (brief.subject_id) prov.subject_id = brief.subject_id;
70
228
  if (brief.parent_brief_id) prov.parent_brief_id = brief.parent_brief_id;
71
- } catch { /* skip */ }
229
+ } catch (err) {
230
+ // B08: the brief exists but couldn't be read/parsed (e.g. a
231
+ // concurrent mid-write) — lineage is genuinely UNKNOWN here, not
232
+ // merely absent. Surface it instead of silently shipping
233
+ // provenance with no lineage and no signal that anything failed.
234
+ warn(`generated-provenance: brief ${briefPath} could not be read (${err.message}) — lineage_incomplete`);
235
+ prov.lineage_incomplete = true;
236
+ }
237
+ } else {
238
+ // B08: no brief on disk at all for this run — the same "genuinely
239
+ // unknown" lineage state as an unreadable brief, not a normal
240
+ // "this brief simply has no subject_id" case (that one is NOT
241
+ // flagged — see the brief-read-fine-but-no-subject_id branch above).
242
+ warn(`generated-provenance: brief not found at ${briefPath} — lineage_incomplete`);
243
+ prov.lineage_incomplete = true;
72
244
  }
73
245
  }
74
246
 
247
+ const canonLink = resolveCanonLink(projectRoot, {
248
+ canonSha,
249
+ entryId,
250
+ entrySha256,
251
+ subjectId: prov.subject_id,
252
+ runManifest,
253
+ batchManifest,
254
+ item,
255
+ brief: parsedBrief,
256
+ });
257
+ if (canonLink) {
258
+ prov.canon_sha = canonLink.canon_sha;
259
+ prov.entry_id = canonLink.entry_id;
260
+ prov.entry_sha256 = canonLink.entry_sha256;
261
+ }
262
+
75
263
  // Config fingerprint — hash the run manifest for reproducibility tracing
76
264
  prov.config_fingerprint = createHash('sha256')
77
265
  .update(JSON.stringify({
@@ -8,7 +8,7 @@
8
8
  * Every implementation pack is tied to a training manifest.
9
9
  */
10
10
 
11
- import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
11
+ import { readFile, writeFile, readdir } from 'node:fs/promises';
12
12
  import { join } from 'node:path';
13
13
  import { existsSync } from 'node:fs';
14
14
  import { randomBytes } from 'node:crypto';
@@ -17,8 +17,8 @@ import { loadTrainingProfile } from './training-profiles.js';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
18
  import { loadSplitPartition } from './split.js';
19
19
  import { loadRecord } from './records.js';
20
- import { inputError } from './errors.js';
21
- import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
20
+ import { SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
21
+ import { warn } from './log.js';
22
22
 
23
23
  function generateImplId() {
24
24
  const now = new Date();
@@ -46,13 +46,27 @@ export async function buildImplementationPack(projectRoot, manifestId) {
46
46
  // Also load some train records for broader coverage
47
47
  const trainEntries = await loadSplitPartition(projectRoot, manifest.source_split_id, 'train');
48
48
 
49
+ // F3: a record the split promised (via source_split_id's train/test
50
+ // partitions) whose file was later deleted from records/ must not
51
+ // silently drop out of the implementation pack with no counter, no
52
+ // warning, no id recorded. loadRecord() returns null on ENOENT
53
+ // (lib/records.js); previously `if (!record) continue` was the entire
54
+ // handling at both loops below. WARN + ledger (not hard-fail): an
55
+ // implementation pack is an illustrative/example artifact, not the
56
+ // Law-4 reproducibility-guaranteeing one (export.js and
57
+ // training-packages.js are, and hard-fail on the identical situation).
58
+ const missingRecords = [];
59
+
49
60
  // Build prompt examples — per-lane best records
50
61
  const promptExamples = [];
51
62
  const laneExamples = new Map();
52
63
 
53
64
  for (const entry of [...testEntries, ...trainEntries.slice(0, 50)]) {
54
65
  const record = await loadRecord(recordsDir, entry.record_id);
55
- if (!record) continue;
66
+ if (!record) {
67
+ missingRecords.push({ record_id: entry.record_id, stage: 'implementation-pack', source: 'prompt-examples' });
68
+ continue;
69
+ }
56
70
  if (record.judgment?.status !== 'approved') continue;
57
71
 
58
72
  const prompt = record.provenance?.prompt || '';
@@ -85,10 +99,26 @@ export async function buildImplementationPack(projectRoot, manifestId) {
85
99
  const knownFailures = [];
86
100
  const allFiles = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
87
101
  let failCount = 0;
102
+ // F4: adopt the DB-003 pattern already proven in snapshot.js's
103
+ // createSnapshot (and mirrored in eval-pack.js's buildEvalPack) — this
104
+ // loop was the one place left in the codebase still doing a bare
105
+ // JSON.parse per record file with no try/catch. One truncated/corrupt
106
+ // record file threw a filename-free SyntaxError here and discarded the
107
+ // prompt-examples work (the loop above) and the subject-continuity work
108
+ // (the loop below) this function had already computed, since every
109
+ // artifact is written to disk only at the very end of
110
+ // buildImplementationPack — skip, count, warn, continue instead.
111
+ const unreadable = [];
88
112
 
89
113
  for (const file of allFiles) {
90
114
  if (failCount >= 10) break;
91
- const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
115
+ let record;
116
+ try {
117
+ record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
118
+ } catch (err) {
119
+ unreadable.push({ record_id: file.replace(/\.json$/, ''), file, error: err.message });
120
+ continue;
121
+ }
92
122
  if (record.judgment?.status !== 'rejected') continue;
93
123
 
94
124
  const prompt = record.provenance?.prompt || '';
@@ -107,6 +137,15 @@ export async function buildImplementationPack(projectRoot, manifestId) {
107
137
  });
108
138
  failCount++;
109
139
  }
140
+ // F4: surface unreadable records loudly (DB-003 parity) — same shape,
141
+ // same warn() visibility as snapshot.js's createSnapshot.
142
+ if (unreadable.length > 0) {
143
+ const sample = unreadable.slice(0, 5).map(u => u.file).join(', ');
144
+ warn(
145
+ `implementation-pack: ${unreadable.length} record file(s) were unreadable and skipped` +
146
+ (unreadable.length > 5 ? ` (first 5: ${sample}, ...)` : ` (${sample})`),
147
+ );
148
+ }
110
149
 
111
150
  // Build subject continuity examples
112
151
  const subjectExamples = [];
@@ -114,12 +153,33 @@ export async function buildImplementationPack(projectRoot, manifestId) {
114
153
 
115
154
  for (const entry of [...testEntries, ...trainEntries]) {
116
155
  const record = await loadRecord(recordsDir, entry.record_id);
117
- if (!record?.identity?.subject_name) continue;
156
+ // F3: keep "record file is missing" (a data-integrity problem worth a
157
+ // warning) distinct from "record loaded fine but simply has no
158
+ // identity.subject_name" (the normal, expected case for most records —
159
+ // not a missing-record situation). The original combined
160
+ // `!record?.identity?.subject_name` check could not tell these apart.
161
+ if (!record) {
162
+ missingRecords.push({ record_id: entry.record_id, stage: 'implementation-pack', source: 'subject-continuity' });
163
+ continue;
164
+ }
165
+ if (!record.identity?.subject_name) continue;
118
166
  const name = record.identity.subject_name;
119
167
  if (!bySubject.has(name)) bySubject.set(name, []);
120
168
  bySubject.get(name).push(record);
121
169
  }
122
170
 
171
+ // F3: warn once for both loops combined — the ledger records which
172
+ // loop(s) actually hit the miss via `source`, so the summary line stays
173
+ // useful even though it's a single warning for the whole pack build.
174
+ if (missingRecords.length > 0) {
175
+ const sample = missingRecords.slice(0, 10).map(m => `${m.record_id} (${m.source})`);
176
+ warn(
177
+ `implementation-pack: ${missingRecords.length} record(s) referenced by split "${manifest.source_split_id}" ` +
178
+ `could not be loaded from ${recordsDir}` +
179
+ (missingRecords.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`),
180
+ );
181
+ }
182
+
123
183
  for (const [name, records] of [...bySubject].sort((a, b) => b[1].length - a[1].length).slice(0, 5)) {
124
184
  subjectExamples.push({
125
185
  subject_name: name,
@@ -138,17 +198,18 @@ export async function buildImplementationPack(projectRoot, manifestId) {
138
198
  };
139
199
 
140
200
  // Write implementation pack
141
- const implId = generateImplId();
142
- const implDir = join(projectRoot, 'training', 'implementations', implId);
143
- // D-005: refuse to silently overwrite on ID collision.
144
- if (existsSync(implDir)) {
145
- throw inputError(
146
- 'IMPL_PACK_ID_COLLISION',
147
- `Implementation pack directory already exists: ${implDir}`,
148
- 'Retry to generate a fresh implementation pack ID.',
149
- );
150
- }
151
- await mkdir(implDir, { recursive: true });
201
+ // SDL-M10: atomically claim the implementation-pack id/dir — closes the
202
+ // check-then-act race the old existsSync+recursive-mkdir pattern had
203
+ // (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
204
+ const { id: implId, dir: implDir } = await claimIdDir(
205
+ join(projectRoot, 'training', 'implementations'),
206
+ generateImplId,
207
+ {
208
+ code: 'IMPL_PACK_ID_COLLISION',
209
+ message: 'Could not claim a unique implementation-pack ID — every candidate collided with an existing implementation-pack directory.',
210
+ hint: 'Retry; if this persists, another sdlab process may be creating implementation packs in a tight loop.',
211
+ },
212
+ );
152
213
 
153
214
  const implManifest = {
154
215
  // DB-001 / DB-006: stamp manifest schema version
@@ -163,7 +224,24 @@ export async function buildImplementationPack(projectRoot, manifestId) {
163
224
  known_failures: knownFailures.length,
164
225
  subject_examples: subjectExamples.length,
165
226
  lanes_covered: laneExamples.size,
227
+ // F3: how many split-referenced records could not be loaded while
228
+ // building this pack. 0 in the common case; see missing_records
229
+ // below for the actual ids.
230
+ missing_records: missingRecords.length,
231
+ // F4: how many record files (from the known-failures scan over ALL
232
+ // of records/) existed but threw on JSON.parse — distinct from
233
+ // missing_records above, which is "no file at all". 0 in the common
234
+ // case; see unreadable_records below for the actual files.
235
+ unreadable_records: unreadable.length,
166
236
  },
237
+ // F3: ledger of every record_id that failed to load while building
238
+ // this pack (mirrors the images_failed pattern export.js established
239
+ // at export time) — record_id + stage + which loop hit it, so an
240
+ // operator can trace it without re-diffing the split against records/.
241
+ missing_records: missingRecords,
242
+ // F4: ledger of record files that existed but failed to JSON.parse
243
+ // (DB-003 parity with snapshot.js's `errors` field).
244
+ unreadable_records: unreadable,
167
245
  };
168
246
 
169
247
  await writeFile(join(implDir, 'manifest.json'), JSON.stringify(implManifest, null, 2) + '\n');