@mcptoolshop/style-dataset-lab 3.3.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 (92) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.es.md +35 -25
  3. package/README.fr.md +38 -28
  4. package/README.hi.md +36 -26
  5. package/README.it.md +37 -27
  6. package/README.ja.md +40 -30
  7. package/README.md +15 -5
  8. package/README.pt-BR.md +35 -25
  9. package/README.zh.md +33 -23
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +79 -26
  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 +36 -2
  28. package/lib/eligibility.js +121 -9
  29. package/lib/eval-pack.js +14 -14
  30. package/lib/eval-runs.js +33 -0
  31. package/lib/export.js +122 -34
  32. package/lib/freeze-gate.js +13 -3
  33. package/lib/freeze-stamp.js +28 -3
  34. package/lib/generated-provenance.js +16 -1
  35. package/lib/implementation-packs.js +95 -17
  36. package/lib/ingest.js +396 -0
  37. package/lib/judgment-provenance.js +220 -0
  38. package/lib/measure.js +422 -0
  39. package/lib/paths.js +196 -7
  40. package/lib/reingest-selected.js +98 -16
  41. package/lib/run-manifest.js +102 -16
  42. package/lib/run-summary.js +12 -6
  43. package/lib/runtime-runs.js +45 -4
  44. package/lib/snapshot.js +114 -15
  45. package/lib/split.js +364 -48
  46. package/lib/training-manifests.js +114 -21
  47. package/lib/training-packages.js +147 -15
  48. package/lib/workflow-profiles.js +24 -3
  49. package/package.json +3 -2
  50. package/runtime/comfyui/txt2img-qwen.json +38 -0
  51. package/runtime/comfyui/txt2img-standard.json +21 -0
  52. package/scripts/_resume.js +62 -0
  53. package/scripts/_shared-args.js +44 -0
  54. package/scripts/brief-compile.js +2 -1
  55. package/scripts/brief-show.js +2 -1
  56. package/scripts/canon-bind.js +2 -2
  57. package/scripts/canon-build.js +27 -20
  58. package/scripts/canon-drift.js +16 -7
  59. package/scripts/canon-freeze-status.js +14 -6
  60. package/scripts/canon-freeze.js +27 -11
  61. package/scripts/canon-unfreeze.js +17 -9
  62. package/scripts/card.js +2 -2
  63. package/scripts/compare.js +2 -2
  64. package/scripts/critique.js +16 -1
  65. package/scripts/curate.js +2 -2
  66. package/scripts/doctor.js +181 -55
  67. package/scripts/eligibility.js +42 -2
  68. package/scripts/eval-pack.js +2 -2
  69. package/scripts/eval-run.js +2 -2
  70. package/scripts/export.js +2 -2
  71. package/scripts/generate-controlnet.js +45 -25
  72. package/scripts/generate-identity.js +53 -23
  73. package/scripts/generate-ipadapter.js +44 -23
  74. package/scripts/generate.js +48 -5
  75. package/scripts/implementation-pack.js +2 -2
  76. package/scripts/ingest.js +145 -0
  77. package/scripts/init.js +73 -13
  78. package/scripts/measure.js +170 -0
  79. package/scripts/measure_image.py +458 -0
  80. package/scripts/migrate-records.js +142 -0
  81. package/scripts/painterly-test.js +4 -3
  82. package/scripts/painterly.js +7 -3
  83. package/scripts/qwen_generate.py +138 -20
  84. package/scripts/reingest.js +2 -2
  85. package/scripts/sheet.js +142 -0
  86. package/scripts/snapshot.js +2 -2
  87. package/scripts/split.js +2 -2
  88. package/scripts/training-manifest.js +2 -2
  89. package/scripts/training-package.js +2 -2
  90. package/scripts/training-profile.js +2 -2
  91. package/scripts/workflow-list.js +2 -1
  92. package/scripts/workflow-show.js +2 -1
package/lib/split.js CHANGED
@@ -8,14 +8,23 @@
8
8
  * 4. Every split produces an audit trail proving no leakage
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 { createHash, randomBytes } from 'node:crypto';
15
15
  import { loadProjectConfig, loadSplitProfile, detectLane } from './config.js';
16
- import { loadSnapshot, loadSnapshotIncluded, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
16
+ import { loadSnapshot, loadSnapshotIncluded, SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
17
17
  import { loadRecord } from './records.js';
18
- import { inputError } from './errors.js';
18
+ import { warn } from './log.js';
19
+
20
+ /**
21
+ * SDL-C2: share of subject families (0–1) resolved via the id-stripping
22
+ * fallback above which the guess is considered "meaningful" enough that
23
+ * lib/card.js must withhold its "None (verified)" leakage claim. Chosen
24
+ * conservatively low — even a modest guessed share means confidence rests
25
+ * on an unverified naming heuristic, not authored identity.
26
+ */
27
+ export const GUESSED_FAMILY_MEANINGFUL_SHARE = 0.1;
19
28
 
20
29
  /**
21
30
  * Mulberry32 — deterministic 32-bit PRNG.
@@ -54,35 +63,84 @@ function generateSplitId() {
54
63
  return `split-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
55
64
  }
56
65
 
66
+ /**
67
+ * SDL-C2: normalize a record ID into a canonical subject stem.
68
+ *
69
+ * Used by the id-stripping fallback (resolveSubjectFamily case 3) and by
70
+ * the independent leakage cross-check (auditStemCollisions). The naive
71
+ * `.replace(/_[vs]\d+$/, '')` regex it replaces had two gaps that let one
72
+ * real subject split into two families:
73
+ *
74
+ * - case sensitivity: "fooV2" and "foo_V2" normalized to two different
75
+ * stems instead of one.
76
+ * - suffix-only matching: real id schemes embed the version/seed marker
77
+ * as an INFIX token on one segment (`styleset_p04v2_crane_tower`), not
78
+ * only as a trailing suffix (`styleset_p04_crane_tower_v1`). The old
79
+ * regex only ever matched the latter shape.
80
+ *
81
+ * Approach: lowercase, split on `_`, strip a trailing `v<digits>` /
82
+ * `s<digits>` marker from EACH segment individually (not just the whole
83
+ * id), drop any segment that normalizes to empty (a segment that WAS only
84
+ * the marker), and rejoin. Both example ids above reduce to
85
+ * "styleset_p04_crane_tower".
86
+ */
87
+ export function normalizeIdStem(id) {
88
+ const segments = String(id).toLowerCase().split('_');
89
+ const cleaned = segments
90
+ .map(seg => seg.replace(/[vs]\d+$/, ''))
91
+ .filter(seg => seg.length > 0);
92
+ // Every segment stripped to nothing (pathological all-marker id) — fall
93
+ // back to the lowercased original rather than collapsing to ''.
94
+ return cleaned.length > 0 ? cleaned.join('_') : String(id).toLowerCase();
95
+ }
96
+
57
97
  /**
58
98
  * Determine the subject family for a record.
59
99
  *
60
100
  * Priority:
61
101
  * 1. identity.subject_name (named characters like renna_vasik)
62
102
  * 2. lineage.derived_from_record_id (walk chain to root ancestor)
63
- * 3. Strip _v\d+$, _s\d+$ suffixes from record ID
103
+ * 3. Strip version/seed markers from record ID (normalizeIdStem)
104
+ *
105
+ * SDL-C2: case 3 is a GUESS, not an authored fact — on real data (every
106
+ * salt-road record has zero identity.subject_name) it fires for 100% of
107
+ * records. Callers must not treat a guessed resolution as equivalent in
108
+ * confidence to an authored one; the `guessed` flag lets them track and
109
+ * surface that distinction instead of guessing silently.
110
+ *
111
+ * @returns {{family: string, guessed: boolean}}
64
112
  */
65
113
  function resolveSubjectFamily(record) {
66
114
  // 1. Named subject
67
115
  if (record.identity?.subject_name) {
68
- return record.identity.subject_name;
116
+ return { family: record.identity.subject_name, guessed: false };
69
117
  }
70
118
 
71
- // 2. Lineage chain — use root ancestor ID as family
119
+ // 2. Lineage chain — use root ancestor ID as family. Authored data (the
120
+ // pipeline recorded this derivation), not a guess.
72
121
  if (record.lineage?.derived_from_record_id) {
73
122
  // We return the ancestor ID; the caller's ancestor map handles chain walking
74
- return record.lineage.derived_from_record_id;
123
+ return { family: record.lineage.derived_from_record_id, guessed: false };
75
124
  }
76
125
 
77
- // 3. Strip version/seed suffixes from record ID
78
- return record.id.replace(/_[vs]\d+$/, '');
126
+ // 3. Strip version/seed markers from record ID — case-insensitive,
127
+ // infix-aware. Nothing authored says these records share a subject;
128
+ // we are inferring it from a naming convention.
129
+ return { family: normalizeIdStem(record.id), guessed: true };
79
130
  }
80
131
 
81
132
  /**
82
133
  * Walk lineage chains to find root ancestors.
83
- * Returns a map: recordId → rootAncestorFamily
134
+ * Returns a map: recordId → {family, guessed}
135
+ *
136
+ * F7: exported (previously module-private) so its O(n) behavior can be
137
+ * measured directly in tests without paying for a full
138
+ * createSnapshot+createSplit round trip's file I/O on every record —
139
+ * buildAncestorMap itself is a pure function of `records` (no I/O, no
140
+ * side effects), so this is a safe, low-risk addition to the module's
141
+ * export surface purely for testability.
84
142
  */
85
- function buildAncestorMap(records) {
143
+ export function buildAncestorMap(records) {
86
144
  const parentOf = new Map();
87
145
  const idToRecord = new Map();
88
146
 
@@ -93,13 +151,85 @@ function buildAncestorMap(records) {
93
151
  }
94
152
  }
95
153
 
96
- // Walk each chain to root (with cycle protection)
97
- function findRoot(id, visited = new Set()) {
98
- if (visited.has(id)) return id; // cycle — stop
99
- visited.add(id);
100
- const parent = parentOf.get(id);
101
- if (!parent) return id;
102
- return findRoot(parent, visited);
154
+ // F7: memoize resolved roots into a map shared across every findRoot()
155
+ // call below, as the walk proceeds. Before this, findRoot() got a FRESH
156
+ // per-call `visited` Set with no memoization shared across calls — for a
157
+ // linear derivation chain of length k (this pipeline's wave-over-wave
158
+ // iteration model produces exactly that shape: record 2 derived from
159
+ // record 1, record 3 derived from record 2, ...), the outer loop below
160
+ // calls findRoot() once per record, and EACH call re-walked an
161
+ // ever-longer prefix of the same chain from scratch — resolving all k
162
+ // records cost ~k²/2 lookups instead of k.
163
+ //
164
+ // rootCache maps id -> its fully-resolved root id. Every id visited
165
+ // during a walk gets pinned to the same final root once found (path
166
+ // compression): the first call to walk the whole chain still costs O(k),
167
+ // but every subsequent call for any id already visited on that walk (or
168
+ // whose walk crosses a previously-cached id partway through) is an O(1)
169
+ // cache hit instead of a re-walk.
170
+ //
171
+ // Written as an explicit loop rather than recursion: profiling this fix
172
+ // against a synthetic 20,000-record linear chain found the ORIGINAL
173
+ // recursive walk (recursion depth == chain length, once per call) blows
174
+ // V8's call stack ("RangeError: Maximum call stack size exceeded") well
175
+ // before memoization would even matter — a chain long enough to make the
176
+ // O(n^2) cost bite is also long enough to crash outright. Fixing the
177
+ // algorithmic complexity without also fixing the recursion-depth ceiling
178
+ // would leave a strictly worse failure mode (a crash) sitting right next
179
+ // to the fix, so both are closed together here.
180
+ //
181
+ // This does not change results for the acyclic chains this field
182
+ // actually models — for a pathological cycle (which
183
+ // lineage.derived_from_record_id should never legitimately contain) it
184
+ // still terminates safely and deterministically; it just picks whichever
185
+ // member of the cycle the outer loop reaches first as the shared "root"
186
+ // for the whole cycle, rather than each cyclic member resolving to
187
+ // itself independently the way a fully-independent (non-memoized) walk
188
+ // would. Cycle protection itself (a node revisited within the SAME walk)
189
+ // is otherwise semantically unchanged from the original.
190
+ const rootCache = new Map();
191
+
192
+ function findRoot(startId) {
193
+ const cached = rootCache.get(startId);
194
+ if (cached !== undefined) return cached;
195
+
196
+ // Walk forward from startId, collecting the path as we go, until we
197
+ // hit: (a) a node with an already-cached root (reuse it — this is the
198
+ // O(1) fast path for everything after the first walk through a given
199
+ // chain), (b) a node with no parent (it IS the root), or (c) a node
200
+ // already seen ON THIS WALK (a cycle — stop there, matching the
201
+ // original recursive version's "cycle stops at the repeated node"
202
+ // behavior exactly).
203
+ const path = [];
204
+ const onPath = new Set();
205
+ let current = startId;
206
+ let root;
207
+
208
+ while (true) {
209
+ const cachedRoot = rootCache.get(current);
210
+ if (cachedRoot !== undefined) {
211
+ root = cachedRoot;
212
+ break;
213
+ }
214
+ if (onPath.has(current)) {
215
+ root = current; // cycle — stop at the repeated node
216
+ break;
217
+ }
218
+ onPath.add(current);
219
+ path.push(current);
220
+ const parent = parentOf.get(current);
221
+ if (!parent) {
222
+ root = current;
223
+ break;
224
+ }
225
+ current = parent;
226
+ }
227
+
228
+ // Path compression: pin every node visited on THIS walk directly to
229
+ // the resolved root, so any future call for any of them — from this
230
+ // top-level findRoot() call or a later one — is an O(1) lookup.
231
+ for (const id of path) rootCache.set(id, root);
232
+ return root;
103
233
  }
104
234
 
105
235
  const ancestorMap = new Map();
@@ -111,8 +241,10 @@ function buildAncestorMap(records) {
111
241
  if (rootRecord) {
112
242
  ancestorMap.set(record.id, resolveSubjectFamily(rootRecord));
113
243
  } else {
114
- // Root not in dataset — strip suffixes from root ID
115
- ancestorMap.set(record.id, rootId.replace(/_[vs]\d+$/, ''));
244
+ // Root not in dataset — strip markers from root ID (SDL-C2: same
245
+ // case-insensitive, infix-aware normalization as case 3 above;
246
+ // this is a guess too).
247
+ ancestorMap.set(record.id, { family: normalizeIdStem(rootId), guessed: true });
116
248
  }
117
249
  }
118
250
  }
@@ -201,28 +333,65 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
201
333
  const snapshot = await loadSnapshot(projectRoot, snapshotId);
202
334
  const includedEntries = await loadSnapshotIncluded(projectRoot, snapshotId);
203
335
  const recordsDir = join(projectRoot, 'records');
336
+ // Declared here (rather than where the pre-existing lane/family warnings
337
+ // are pushed further below) so the F3 missing-records warning shares the
338
+ // same array — and therefore the same manifest.json / audit.json
339
+ // `warnings` field — as every other warning this function produces.
340
+ const warnings = [];
204
341
 
205
- // Load full records for included entries
342
+ // Load full records for included entries.
343
+ //
344
+ // F3: a record the (frozen, Law-1) snapshot included whose file was
345
+ // later deleted from records/ must never silently drop out of the split
346
+ // with no counter, no warning, no id recorded. loadRecord() returns null
347
+ // on ENOENT (lib/records.js); previously `if (record) records.push(record)`
348
+ // was the ENTIRE handling for that case — a missing record just shrank
349
+ // `records` by one, invisibly. WARN + ledger here rather than hard-fail:
350
+ // a split is an intermediate artifact, not itself the Law-4
351
+ // ("exports are reproducible") contract-bearing one — export.js and
352
+ // training-packages.js are, and hard-fail on the identical situation
353
+ // (see their EXPORT_MISSING_RECORDS / TRAINING_PACKAGE_MISSING_RECORDS
354
+ // guards) rather than warning.
206
355
  const records = [];
356
+ const missingRecords = [];
207
357
  for (const entry of includedEntries) {
208
358
  const record = await loadRecord(recordsDir, entry.record_id);
209
- if (record) records.push(record);
359
+ if (record) {
360
+ records.push(record);
361
+ } else {
362
+ missingRecords.push({ record_id: entry.record_id, stage: 'split' });
363
+ }
364
+ }
365
+ if (missingRecords.length > 0) {
366
+ const sample = missingRecords.slice(0, 10).map(m => m.record_id);
367
+ const msg =
368
+ `split: ${missingRecords.length} of ${includedEntries.length} record(s) referenced by snapshot ` +
369
+ `"${snapshotId}" could not be loaded from ${recordsDir} — this split is built from the ` +
370
+ `${records.length} record(s) actually found` +
371
+ (missingRecords.length > 10 ? ` (first 10: ${sample.join(', ')}, ...)` : ` (${sample.join(', ')})`);
372
+ warn(msg);
373
+ warnings.push(msg);
210
374
  }
211
375
 
212
376
  // Build ancestor map for lineage chain resolution
213
377
  const ancestorMap = buildAncestorMap(records);
214
378
 
215
- // Assign each record to a subject family
379
+ // Assign each record to a subject family. Resolution is cached per
380
+ // record-id (recordResolution) so the second assignment pass below reuses
381
+ // the exact same {family, guessed} decision rather than re-deriving it —
382
+ // the two passes must never be able to disagree.
216
383
  const familyMap = new Map(); // family → [record]
384
+ const familyGuessed = new Map(); // family → true if ANY member arrived via id-stripping fallback
385
+ const recordResolution = new Map(); // record.id → {family, guessed}
217
386
  for (const record of records) {
218
- let family;
219
- if (ancestorMap.has(record.id)) {
220
- family = ancestorMap.get(record.id);
221
- } else {
222
- family = resolveSubjectFamily(record);
223
- }
387
+ const resolution = ancestorMap.has(record.id)
388
+ ? ancestorMap.get(record.id)
389
+ : resolveSubjectFamily(record);
390
+ recordResolution.set(record.id, resolution);
391
+ const { family, guessed } = resolution;
224
392
  if (!familyMap.has(family)) familyMap.set(family, []);
225
393
  familyMap.get(family).push(record);
394
+ if (guessed) familyGuessed.set(family, true);
226
395
  }
227
396
 
228
397
  // D-002: Deterministic primary lane per family = majority vote; ties
@@ -267,7 +436,11 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
267
436
  const trainFamilies = new Set();
268
437
  const valFamilies = new Set();
269
438
  const testFamilies = new Set();
270
- const warnings = [];
439
+ // F3: `warnings` is declared once, up near the top of createSplit (ahead
440
+ // of the missing-records check), and reused here — NOT redeclared — so
441
+ // every warning this function produces (missing records, lane-family
442
+ // count, guessed-family share, stem-collision) lands in one array and
443
+ // therefore one manifest.json / audit.json `warnings` field.
271
444
 
272
445
  const sortedLanes = [...laneGroups.keys()].sort();
273
446
  for (const lane of sortedLanes) {
@@ -312,12 +485,10 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
312
485
  const testRecords = [];
313
486
 
314
487
  for (const record of records) {
315
- let family;
316
- if (ancestorMap.has(record.id)) {
317
- family = ancestorMap.get(record.id);
318
- } else {
319
- family = resolveSubjectFamily(record);
320
- }
488
+ // Reuse the exact resolution cached during the family-map build pass
489
+ // above — recomputing here would risk the two passes silently
490
+ // disagreeing (D-003's whole point is to catch that class of bug).
491
+ const { family } = recordResolution.get(record.id);
321
492
 
322
493
  if (trainFamilies.has(family)) trainRecords.push(record.id);
323
494
  else if (valFamilies.has(family)) valRecords.push(record.id);
@@ -337,29 +508,90 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
337
508
  valRecords.sort();
338
509
  testRecords.sort();
339
510
 
340
- // Leakage audit
511
+ // Leakage audit — checks whether the assignment's OWN family map spans
512
+ // partitions. By construction this can never fire (the assignment loop
513
+ // groups by family before splitting) unless there's a bug in the
514
+ // assignment step itself; it cannot see a bad family RESOLUTION (SDL-C2).
341
515
  const leakageIssues = auditLeakage(familyMap, trainRecords, valRecords, testRecords);
342
516
 
517
+ // SDL-C2b: independent cross-check. Never reads familyMap/ancestorMap —
518
+ // re-derives a canonical id stem per record from scratch and flags any
519
+ // case where records sharing a stem (i.e. very likely one real subject)
520
+ // ended up placed in more than one partition. This is the only check
521
+ // that can catch SDL-C2-shaped bugs, including future regressions of
522
+ // resolveSubjectFamily/normalizeIdStem that the leakage_check above
523
+ // would stay blind to.
524
+ const stemCollisionIssues = auditStemCollisions(records, trainRecords, valRecords, testRecords);
525
+
526
+ // SDL-C2: surface how much of the split's leakage-freedom claim rests on
527
+ // guessed (id-stripped) family resolution vs. authored subject_name /
528
+ // lineage. On real project data (salt-road: 99/99 records, zero
529
+ // identity.subject_name) this is 100% — silent guessing is how SDL-C2
530
+ // stayed invisible. Never silent again: counted here, warned on below,
531
+ // and consumed by lib/card.js to gate the "None (verified)" claim.
532
+ let guessedFamilies = 0;
533
+ for (const family of familyMap.keys()) {
534
+ if (familyGuessed.get(family)) guessedFamilies++;
535
+ }
536
+ let guessedRecords = 0;
537
+ for (const resolution of recordResolution.values()) {
538
+ if (resolution.guessed) guessedRecords++;
539
+ }
540
+ const totalFamilies = familyMap.size;
541
+ const guessedFamilyRatio = totalFamilies > 0 ? guessedFamilies / totalFamilies : 0;
542
+ // Conservatively low: even a modest guessed share means the leakage-free
543
+ // claim is resting on an unverified naming-convention heuristic rather
544
+ // than authored identity, which is worth a caveat rather than silence.
545
+ const meaningfulGuessShare = guessedFamilyRatio > GUESSED_FAMILY_MEANINGFUL_SHARE;
546
+ const familyResolution = {
547
+ total_families: totalFamilies,
548
+ guessed_families: guessedFamilies,
549
+ guessed_family_ratio: +guessedFamilyRatio.toFixed(3),
550
+ total_records: records.length,
551
+ guessed_records: guessedRecords,
552
+ guessed_record_ratio: records.length > 0 ? +(guessedRecords / records.length).toFixed(3) : 0,
553
+ meaningful_guess_share_threshold: GUESSED_FAMILY_MEANINGFUL_SHARE,
554
+ meaningful_guess_share: meaningfulGuessShare,
555
+ };
556
+ if (meaningfulGuessShare) {
557
+ warnings.push(
558
+ `${guessedFamilies} of ${totalFamilies} subject families ` +
559
+ `(${(guessedFamilyRatio * 100).toFixed(1)}%) were inferred by stripping version/seed ` +
560
+ `markers from record IDs — no identity.subject_name or lineage authored. ` +
561
+ `Verify subject grouping manually; the leakage-free claim on this split is unverified for these families.`,
562
+ );
563
+ }
564
+ if (stemCollisionIssues.length > 0) {
565
+ warnings.push(
566
+ `subject-stem cross-check found ${stemCollisionIssues.length} case(s) where records sharing ` +
567
+ `a canonical id stem were resolved to different families — see audit.json stem_collision_check.`,
568
+ );
569
+ }
570
+
343
571
  // Lane balance audit (DB-005: includes deviation from profile ratios)
344
572
  const laneBalance = auditLaneBalance(
345
573
  records, trainRecords, valRecords, testRecords, config.lanes, profile
346
574
  );
347
575
 
348
576
  // Write output (skip if dry run)
349
- const splitId = generateSplitId();
350
577
  if (options.dryRun) {
578
+ // Dry run never touches disk — nothing to atomically claim; a fresh
579
+ // preview id is enough to characterize the shape of the result.
580
+ const splitId = generateSplitId();
351
581
  return { splitId, train: trainRecords.length, val: valRecords.length, test: testRecords.length };
352
582
  }
353
- const splitDir = join(projectRoot, 'splits', splitId);
354
- // D-005: refuse to silently overwrite on ID collision.
355
- if (existsSync(splitDir)) {
356
- throw inputError(
357
- 'SPLIT_ID_COLLISION',
358
- `Split directory already exists: ${splitDir}`,
359
- 'Retry to generate a fresh split ID.',
360
- );
361
- }
362
- await mkdir(splitDir, { recursive: true });
583
+ // SDL-M10: atomically claim the split id/dir — closes the check-then-act
584
+ // race the old existsSync+recursive-mkdir pattern had (D-005's intent,
585
+ // now actually race-proof; see claimIdDir in snapshot.js).
586
+ const { id: splitId, dir: splitDir } = await claimIdDir(
587
+ join(projectRoot, 'splits'),
588
+ generateSplitId,
589
+ {
590
+ code: 'SPLIT_ID_COLLISION',
591
+ message: 'Could not claim a unique split ID — every candidate collided with an existing split directory.',
592
+ hint: 'Retry; if this persists, another sdlab process may be creating splits in a tight loop.',
593
+ },
594
+ );
363
595
 
364
596
  const manifest = {
365
597
  // DB-001 / DB-006: stamp manifest schema version
@@ -376,7 +608,16 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
376
608
  train: trainRecords.length,
377
609
  val: valRecords.length,
378
610
  test: testRecords.length,
611
+ // F3: how many snapshot-included records could not be loaded from
612
+ // records/ at split time. 0 in the common case; see missing_records
613
+ // below for the actual ids.
614
+ missing_records: missingRecords.length,
379
615
  },
616
+ // F3: ledger of every snapshot-included record_id that failed to load
617
+ // (mirrors the images_failed pattern export.js established one layer
618
+ // down at export time) — record_id + which stage recorded the miss, so
619
+ // an operator can trace it without re-diffing snapshot vs. records/.
620
+ missing_records: missingRecords,
380
621
  warnings,
381
622
  };
382
623
 
@@ -386,6 +627,18 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
386
627
  passed: leakageIssues.length === 0,
387
628
  issues: leakageIssues,
388
629
  },
630
+ // SDL-C2b: distinct from leakage_check above — independently re-derived,
631
+ // does not reuse familyMap, and is the only check that can detect a
632
+ // SDL-C2-shaped family-resolution bug. Read this key (not
633
+ // leakage_check) to know whether the independent cross-check fired.
634
+ stem_collision_check: {
635
+ passed: stemCollisionIssues.length === 0,
636
+ issues: stemCollisionIssues,
637
+ },
638
+ // SDL-C2: how much of family resolution rests on the id-stripping
639
+ // guess vs. authored identity/lineage. lib/card.js reads this to
640
+ // decide whether "None (verified)" is honest to print.
641
+ family_resolution: familyResolution,
389
642
  lane_balance: laneBalance,
390
643
  family_count: familyMap.size,
391
644
  families_per_split: {
@@ -436,6 +689,69 @@ function auditLeakage(familyMap, trainIds, valIds, testIds) {
436
689
  return issues;
437
690
  }
438
691
 
692
+ /**
693
+ * SDL-C2b: independent leakage cross-check.
694
+ *
695
+ * auditLeakage() above can only ever confirm that the assignment loop's OWN
696
+ * familyMap is internally consistent — by construction every record with a
697
+ * given family key was grouped and placed together, so that check can never
698
+ * observe a case where family RESOLUTION itself was wrong (two records that
699
+ * are really the same subject but got resolved to two different family
700
+ * keys — SDL-C2). This function never reads familyMap or ancestorMap.
701
+ *
702
+ * It re-derives a canonical id stem for every record directly from the
703
+ * record's own id (normalizeIdStem — the same normalization the fixed
704
+ * resolveSubjectFamily now uses), independent of whatever family label the
705
+ * assignment computed. It then asks a question the assignment's own audit
706
+ * structurally cannot ask: did records that share a canonical stem (i.e.
707
+ * are very likely one real subject) end up placed in more than one
708
+ * partition? A "yes" is real, unambiguous leakage regardless of what family
709
+ * label the assignment used internally — including if a future regression
710
+ * reintroduces a SDL-C2-shaped bug in family resolution.
711
+ *
712
+ * Returns an array of issues (empty = clean). Each issue also carries
713
+ * `assigned_families` — the (possibly multiple) family labels the members
714
+ * were actually resolved to, purely as an operator diagnostic; it is never
715
+ * consulted for the pass/fail decision above.
716
+ */
717
+ function auditStemCollisions(records, trainIds, valIds, testIds) {
718
+ const trainSet = new Set(trainIds);
719
+ const valSet = new Set(valIds);
720
+ const testSet = new Set(testIds);
721
+
722
+ const byStem = new Map(); // canonical stem → [record]
723
+ for (const record of records) {
724
+ const stem = normalizeIdStem(record.id);
725
+ if (!byStem.has(stem)) byStem.set(stem, []);
726
+ byStem.get(stem).push(record);
727
+ }
728
+
729
+ const issues = [];
730
+ for (const [stem, recs] of byStem) {
731
+ if (recs.length < 2) continue; // nothing to collide with
732
+
733
+ const partitions = new Set();
734
+ for (const r of recs) {
735
+ if (trainSet.has(r.id)) partitions.add('train');
736
+ if (valSet.has(r.id)) partitions.add('val');
737
+ if (testSet.has(r.id)) partitions.add('test');
738
+ }
739
+ if (partitions.size <= 1) continue; // same stem, same partition — fine
740
+
741
+ const assignedFamilies = [...new Set(recs.map(r => resolveSubjectFamily(r).family))].sort();
742
+
743
+ issues.push({
744
+ canonical_stem: stem,
745
+ record_ids: recs.map(r => r.id).sort(),
746
+ spans_partitions: [...partitions].sort(),
747
+ assigned_families: assignedFamilies,
748
+ });
749
+ }
750
+
751
+ issues.sort((a, b) => a.canonical_stem.localeCompare(b.canonical_stem));
752
+ return issues;
753
+ }
754
+
439
755
  /**
440
756
  * Audit lane balance across splits.
441
757
  *