@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.
- package/CHANGELOG.md +33 -0
- package/README.es.md +35 -25
- package/README.fr.md +38 -28
- package/README.hi.md +36 -26
- package/README.it.md +37 -27
- package/README.ja.md +40 -30
- package/README.md +15 -5
- package/README.pt-BR.md +35 -25
- package/README.zh.md +33 -23
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +10 -2
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +13 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +16 -1
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/measure.js +422 -0
- package/lib/paths.js +196 -7
- package/lib/reingest-selected.js +98 -16
- package/lib/run-manifest.js +102 -16
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/workflow-profiles.js +24 -3
- package/package.json +3 -2
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +27 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +181 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +48 -5
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
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,
|
|
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 {
|
|
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
|
|
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
|
|
78
|
-
|
|
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 →
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
115
|
-
|
|
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)
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
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
|
-
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
*
|