@mcptoolshop/style-dataset-lab 3.0.0 → 3.0.1

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 (88) hide show
  1. package/CHANGELOG.md +68 -15
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.md +46 -1
  5. package/SECURITY.md +6 -5
  6. package/bin/sdlab.js +281 -62
  7. package/lib/adapters/diffusers-lora.js +7 -2
  8. package/lib/adapters/generic-image-caption.js +6 -1
  9. package/lib/args.js +200 -13
  10. package/lib/batch-compiler.js +7 -3
  11. package/lib/batch-runs.js +100 -31
  12. package/lib/batch-sheet-render.js +5 -2
  13. package/lib/brief-compiler.js +38 -11
  14. package/lib/card.js +11 -4
  15. package/lib/comfyui.js +100 -6
  16. package/lib/config.js +68 -3
  17. package/lib/deprecation.js +1 -1
  18. package/lib/eval-pack.js +94 -4
  19. package/lib/eval-runs.js +42 -3
  20. package/lib/export.js +144 -34
  21. package/lib/implementation-packs.js +15 -1
  22. package/lib/log.js +41 -4
  23. package/lib/paths.js +41 -3
  24. package/lib/refine-briefs.js +16 -9
  25. package/lib/reingest-selected.js +14 -3
  26. package/lib/reingest.js +15 -1
  27. package/lib/run-summary.js +3 -2
  28. package/lib/runtime-runs.js +59 -9
  29. package/lib/selections.js +54 -2
  30. package/lib/snapshot.js +100 -7
  31. package/lib/split.js +175 -17
  32. package/lib/training-manifests.js +17 -3
  33. package/lib/training-packages.js +28 -14
  34. package/package.json +8 -2
  35. package/scripts/batch-generate.js +16 -7
  36. package/scripts/batch-sheet.js +3 -3
  37. package/scripts/batch-show.js +3 -3
  38. package/scripts/brief-compile.js +8 -8
  39. package/scripts/brief-show.js +4 -3
  40. package/scripts/canon-bind.js +7 -9
  41. package/scripts/card.js +22 -25
  42. package/scripts/compare.js +38 -33
  43. package/scripts/critique-show.js +3 -3
  44. package/scripts/critique.js +3 -3
  45. package/scripts/curate.js +91 -46
  46. package/scripts/doctor.js +3 -5
  47. package/scripts/eligibility.js +17 -14
  48. package/scripts/eval-pack.js +13 -11
  49. package/scripts/eval-run.js +30 -29
  50. package/scripts/export.js +27 -31
  51. package/scripts/generate-controlnet.js +31 -17
  52. package/scripts/generate-identity.js +37 -20
  53. package/scripts/generate-ipadapter.js +22 -16
  54. package/scripts/generate.js +68 -13
  55. package/scripts/implementation-pack.js +19 -14
  56. package/scripts/init.js +115 -17
  57. package/scripts/painterly.js +8 -10
  58. package/scripts/refine.js +3 -3
  59. package/scripts/reingest-selected.js +3 -3
  60. package/scripts/reingest.js +33 -20
  61. package/scripts/run-generate.js +4 -4
  62. package/scripts/run-list.js +3 -3
  63. package/scripts/run-show.js +3 -3
  64. package/scripts/select.js +5 -4
  65. package/scripts/selection-show.js +3 -3
  66. package/scripts/snapshot.js +22 -24
  67. package/scripts/split.js +26 -31
  68. package/scripts/training-manifest.js +29 -24
  69. package/scripts/training-package.js +24 -20
  70. package/scripts/training-profile.js +13 -8
  71. package/scripts/workflow-list.js +2 -2
  72. package/scripts/workflow-show.js +4 -3
  73. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  74. package/templates/domains/architecture/terminology.json +10 -8
  75. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  76. package/templates/domains/character-design/project.json +4 -2
  77. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  78. package/templates/domains/creature-design/project.json +4 -2
  79. package/templates/domains/creature-design/terminology.json +1 -0
  80. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  81. package/templates/domains/game-art/project.json +4 -2
  82. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  83. package/templates/domains/vehicle-mech/project.json +4 -2
  84. package/scripts/bulk-curate-wave2-5.js +0 -138
  85. package/scripts/bulk-curate-waves11-18.js +0 -413
  86. package/scripts/curate-wave25.js +0 -209
  87. package/scripts/migrate-records.js +0 -141
  88. package/templates/inputs/prompts/example-wave.json +0 -27
@@ -5,17 +5,23 @@
5
5
  * The run folder IS the review surface — humans open it directly.
6
6
  */
7
7
 
8
- import { readFile, writeFile, mkdir, readdir, copyFile } from 'node:fs/promises';
8
+ import { readFile, writeFile, mkdir, readdir, copyFile, rename, unlink } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { randomInt } from 'node:crypto';
11
12
  import { getRunsDir, getBriefsDir } from './paths.js';
12
13
  import { inputError } from './errors.js';
14
+ import { verbose } from './log.js';
13
15
 
14
16
  // ─── Run ID generation ───────────────────────────────────────────
15
17
 
16
18
  /**
17
19
  * Generate a run ID: run_YYYY-MM-DD_NNN
18
20
  */
21
+ // PB-008: tighten regex so stray directories like 'run_<date>_001_wip'
22
+ // don't inflate the sequence via parseInt's lenient parsing.
23
+ const RUN_SEQ_RE = /^(\d{3})$/;
24
+
19
25
  export function generateRunId(runsDir) {
20
26
  const today = new Date().toISOString().slice(0, 10);
21
27
  const prefix = `run_${today}_`;
@@ -26,8 +32,10 @@ export function generateRunId(runsDir) {
26
32
  .filter(e => e.isDirectory() && e.name.startsWith(prefix));
27
33
  for (const e of existing) {
28
34
  const seqStr = e.name.slice(prefix.length);
29
- const seq = parseInt(seqStr, 10);
30
- if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
35
+ const match = RUN_SEQ_RE.exec(seqStr);
36
+ if (!match) continue;
37
+ const seq = parseInt(match[1], 10);
38
+ if (seq > maxSeq) maxSeq = seq;
31
39
  }
32
40
  }
33
41
 
@@ -46,7 +54,7 @@ export function generateRunId(runsDir) {
46
54
  * @returns {{ base_seed: number, seeds: number[] }}
47
55
  */
48
56
  export function buildSeedPlan({ seedMode, outputCount, baseSeed }) {
49
- const base = baseSeed ?? Math.floor(Math.random() * 2_147_483_647);
57
+ const base = baseSeed ?? randomInt(2_147_483_647);
50
58
  const seeds = [];
51
59
 
52
60
  switch (seedMode) {
@@ -59,7 +67,7 @@ export function buildSeedPlan({ seedMode, outputCount, baseSeed }) {
59
67
  case 'random':
60
68
  seeds.push(base);
61
69
  for (let i = 1; i < outputCount; i++) {
62
- seeds.push(Math.floor(Math.random() * 2_147_483_647));
70
+ seeds.push(randomInt(2_147_483_647));
63
71
  }
64
72
  break;
65
73
  default:
@@ -103,14 +111,56 @@ export async function prepareRunDir(projectRoot, brief) {
103
111
 
104
112
  // ─── Manifest ────────────────────────────────────────────────────
105
113
 
114
+ /**
115
+ * Atomic write: write to a temp file in the same directory, then rename.
116
+ * Rename is atomic on the same filesystem, so a crash mid-write can never
117
+ * leave a half-written manifest in place of a good one.
118
+ *
119
+ * PB-002 / PB-005: callers may invoke this after each slot/candidate to
120
+ * produce incremental checkpoints.
121
+ */
122
+ async function atomicWriteJson(targetPath, obj) {
123
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
124
+ const body = JSON.stringify(obj, null, 2) + '\n';
125
+ try {
126
+ await writeFile(tmpPath, body);
127
+ await rename(tmpPath, targetPath);
128
+ } catch (err) {
129
+ // Best-effort cleanup of the temp file on failure.
130
+ try { await unlink(tmpPath); } catch { /* ignore */ }
131
+ throw err;
132
+ }
133
+ }
134
+
106
135
  /**
107
136
  * Build and save a run manifest.
137
+ *
138
+ * PB-002: uses atomic write (temp + rename) so a crash mid-write cannot
139
+ * corrupt an existing manifest. Safe to call repeatedly for incremental
140
+ * checkpointing after each output completes.
108
141
  */
109
142
  export async function saveRunManifest(runDir, manifest) {
110
- await writeFile(
111
- join(runDir, 'manifest.json'),
112
- JSON.stringify(manifest, null, 2) + '\n'
113
- );
143
+ const target = join(runDir, 'manifest.json');
144
+ await atomicWriteJson(target, manifest);
145
+ verbose(`saveRunManifest: wrote ${target}`);
146
+ }
147
+
148
+ /**
149
+ * Incremental checkpoint — same shape as saveRunManifest but stamps
150
+ * `incremental: true` and `last_checkpoint_at` so a human reading a
151
+ * partial manifest knows the run is in progress.
152
+ *
153
+ * PB-002: call after each completed output in long-running loops so a
154
+ * crash/interruption leaves a partial record instead of an orphan dir.
155
+ */
156
+ export async function checkpointRunManifest(runDir, manifest) {
157
+ const snapshot = {
158
+ ...manifest,
159
+ incremental: true,
160
+ last_checkpoint_at: new Date().toISOString(),
161
+ };
162
+ await atomicWriteJson(join(runDir, 'manifest.json'), snapshot);
163
+ verbose(`checkpointRunManifest: ${manifest.run_id} (${manifest.outputs?.length ?? 0} outputs)`);
114
164
  }
115
165
 
116
166
  // ─── Load / List ─────────────────────────────────────────────────
package/lib/selections.js CHANGED
@@ -14,12 +14,30 @@ import { getRunsDir } from './paths.js';
14
14
  import { getBatchesDir } from './batch-runs.js';
15
15
  import { inputError } from './errors.js';
16
16
 
17
+ // ─── Filename validation ─────────────────────────────────────────
18
+
19
+ const SAFE_FILENAME_RE = /^[A-Za-z0-9._-]+\.(png|webp|jpg|jpeg)$/i;
20
+
21
+ function assertSafeFilename(filename, context) {
22
+ if (typeof filename !== 'string' || !SAFE_FILENAME_RE.test(filename)) {
23
+ throw inputError(
24
+ 'UNSAFE_FILENAME',
25
+ `Invalid filename "${filename}" in ${context}`,
26
+ `Filenames must match letters/digits/dot/underscore/hyphen and end in .png/.webp/.jpg/.jpeg`,
27
+ );
28
+ }
29
+ }
30
+
17
31
  // ─── Selection ID generation ─────────────────────────────────────
18
32
 
19
33
  export function getSelectionsDir(projectRoot) {
20
34
  return join(projectRoot, 'selections');
21
35
  }
22
36
 
37
+ // PB-008: tighten regex so partial matches (e.g. 'selection_<date>_001_wip')
38
+ // don't inflate the sequence. We require exactly 3 digits.
39
+ const SELECTION_SEQ_RE = /^(\d{3})$/;
40
+
23
41
  export function generateSelectionId(selectionsDir) {
24
42
  const today = new Date().toISOString().slice(0, 10);
25
43
  const prefix = `selection_${today}_`;
@@ -30,14 +48,46 @@ export function generateSelectionId(selectionsDir) {
30
48
  .filter(e => e.isDirectory() && e.name.startsWith(prefix));
31
49
  for (const e of existing) {
32
50
  const seqStr = e.name.slice(prefix.length);
33
- const seq = parseInt(seqStr, 10);
34
- if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
51
+ const match = SELECTION_SEQ_RE.exec(seqStr);
52
+ if (!match) continue;
53
+ const seq = parseInt(match[1], 10);
54
+ if (seq > maxSeq) maxSeq = seq;
35
55
  }
36
56
  }
37
57
 
38
58
  return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`;
39
59
  }
40
60
 
61
+ /**
62
+ * PB-008: atomically claim the next selection ID by mkdir retry-on-EEXIST.
63
+ * Races safely against a concurrent sdlab process — each one will land on
64
+ * a unique sequence. Returns { selectionId, selectionDir }.
65
+ *
66
+ * Callers that already create the directory themselves can keep using
67
+ * generateSelectionId; the atomic path is opt-in.
68
+ */
69
+ export async function claimSelectionId(selectionsDir) {
70
+ if (!existsSync(selectionsDir)) {
71
+ mkdirSync(selectionsDir, { recursive: true });
72
+ }
73
+ for (let attempt = 0; attempt < 25; attempt++) {
74
+ const candidate = generateSelectionId(selectionsDir);
75
+ const dir = join(selectionsDir, candidate);
76
+ try {
77
+ mkdirSync(dir); // throws EEXIST if a concurrent process already claimed it
78
+ return { selectionId: candidate, selectionDir: dir };
79
+ } catch (err) {
80
+ if (err.code === 'EEXIST') continue;
81
+ throw err;
82
+ }
83
+ }
84
+ throw inputError(
85
+ 'SELECTION_ID_CLAIM_FAILED',
86
+ 'Could not claim a unique selection ID after 25 attempts',
87
+ 'Another sdlab process may be writing selections in a tight loop. Try again.'
88
+ );
89
+ }
90
+
41
91
  // ─── Run selection ───────────────────────────────────────────────
42
92
 
43
93
  /**
@@ -68,6 +118,7 @@ export async function createSelectionFromRun({
68
118
 
69
119
  // Validate all approved files exist
70
120
  for (const file of approvedFiles) {
121
+ assertSafeFilename(file, `approvedFiles for run ${runId}`);
71
122
  if (!existsSync(join(outputsDir, file))) {
72
123
  throw inputError(
73
124
  'OUTPUT_NOT_FOUND',
@@ -162,6 +213,7 @@ export async function createSelectionFromBatch({
162
213
  // Validate each approved slot
163
214
  const runManifests = new Map();
164
215
  for (const { slotId, filename } of approvedSlots) {
216
+ assertSafeFilename(filename, `approvedSlots for batch ${batchId}`);
165
217
  const slot = slotRunMap.get(slotId);
166
218
  if (!slot) {
167
219
  throw inputError(
package/lib/snapshot.js CHANGED
@@ -11,6 +11,45 @@ import { join } from 'node:path';
11
11
  import { createHash, randomBytes } from 'node:crypto';
12
12
  import { loadProjectConfig, loadSelectionProfile, detectLane, detectGroup } from './config.js';
13
13
  import { evaluateEligibility } from './eligibility.js';
14
+ import { inputError } from './errors.js';
15
+ import { warn } from './log.js';
16
+
17
+ /**
18
+ * DB-001 / DB-006: Manifest schema version. Every manifest.json written by
19
+ * snapshot/split/export/eval-pack/eval-run/training-manifest/training-package/
20
+ * impl-pack stamps this version; readers log a WARNING on version mismatch
21
+ * (best-effort compatibility).
22
+ */
23
+ export const SCHEMA_VERSION = '2.2.0';
24
+
25
+ /**
26
+ * Warn if a loaded manifest's schema_version does not match the reader.
27
+ * Non-fatal — continues with best-effort compatibility. No warning when
28
+ * manifest is missing the field (legacy unstamped artifacts).
29
+ */
30
+ export function checkManifestVersion(manifest, kind) {
31
+ if (manifest?.schema_version && manifest.schema_version !== SCHEMA_VERSION) {
32
+ warn(
33
+ `${kind} schema v${manifest.schema_version} loaded by v${SCHEMA_VERSION} reader — continuing with best-effort compatibility`,
34
+ );
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Derive which rule names were evaluated against a record for a given profile.
40
+ * Mirrors the order of checks in evaluateEligibility() so the trace is stable.
41
+ */
42
+ function rulesCheckedForProfile(profile) {
43
+ const rules = [];
44
+ if (profile.require_judgment) rules.push('require_judgment');
45
+ if (profile.require_status?.length > 0) rules.push('require_status');
46
+ if (profile.require_canon_bound) rules.push('require_canon_bound');
47
+ if (profile.minimum_pass_ratio != null) rules.push('minimum_pass_ratio');
48
+ if (profile.exclude_lanes?.length > 0) rules.push('exclude_lanes');
49
+ if (profile.rights_filter != null) rules.push('rights_filter');
50
+ if (profile.exclude_tags?.length > 0) rules.push('exclude_tags');
51
+ return rules;
52
+ }
14
53
 
15
54
  /**
16
55
  * Generate a snapshot ID: snap-YYYYMMDD-HHMMSS-XXXX
@@ -59,6 +98,20 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
59
98
  const snapshotId = generateSnapshotId();
60
99
  const snapshotDir = join(projectRoot, 'snapshots', snapshotId);
61
100
 
101
+ // D-005: refuse to proceed if the generated ID has collided with an existing
102
+ // snapshot dir — silent overwrite would break Law 1 (snapshots are frozen).
103
+ if (!options.dryRun && existsSync(snapshotDir)) {
104
+ throw inputError(
105
+ 'SNAPSHOT_ID_COLLISION',
106
+ `Snapshot directory already exists: ${snapshotDir}`,
107
+ 'Retry to generate a fresh snapshot ID.',
108
+ );
109
+ }
110
+
111
+ const configFingerprint = computeConfigFingerprint(projectRoot);
112
+ const profileId = profile.profile_id || profile.id || 'unknown';
113
+ const rulesChecked = rulesCheckedForProfile(profile);
114
+
62
115
  // Load all records (sorted for deterministic output)
63
116
  const files = (await readdir(recordsDir))
64
117
  .filter(f => f.endsWith('.json'))
@@ -66,13 +119,27 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
66
119
 
67
120
  const included = [];
68
121
  const excluded = [];
122
+ const unreadable = []; // DB-003: collect per-record parse errors, continue
69
123
  const laneDistribution = {};
70
124
  const factionDistribution = {};
71
125
  const verdictDistribution = { pass: 0, fail: 0, partial: 0 };
72
126
 
73
127
  for (const file of files) {
74
- const raw = await readFile(join(recordsDir, file), 'utf-8');
75
- const record = JSON.parse(raw);
128
+ // DB-003: wrap per-record JSON.parse in try/catch so one bad file does
129
+ // not abort the entire snapshot. The operator sees an `unreadable` list
130
+ // in the manifest + a WARNING count on stderr.
131
+ let record;
132
+ try {
133
+ const raw = await readFile(join(recordsDir, file), 'utf-8');
134
+ record = JSON.parse(raw);
135
+ } catch (err) {
136
+ unreadable.push({
137
+ record_id: file.replace(/\.json$/, ''),
138
+ file,
139
+ error: err.message,
140
+ });
141
+ continue;
142
+ }
76
143
 
77
144
  const { eligible, reasons } = evaluateEligibility(
78
145
  record, profile, config.lanes, config.terminology
@@ -89,7 +156,15 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
89
156
  : 'n/a';
90
157
  const reason = `${record.judgment.status}, canon-bound, pass_ratio=${passRatio}, lane=${lane}`;
91
158
 
92
- included.push({ record_id: record.id, reason });
159
+ // D-006: record the rule-trace + profile context so inclusion is
160
+ // replayable without re-running evaluateEligibility.
161
+ included.push({
162
+ record_id: record.id,
163
+ reason,
164
+ rules_checked: rulesChecked,
165
+ profile_id: profileId,
166
+ config_fingerprint: configFingerprint,
167
+ });
93
168
 
94
169
  // Track distributions
95
170
  laneDistribution[lane] = (laneDistribution[lane] || 0) + 1;
@@ -104,30 +179,46 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
104
179
  }
105
180
  }
106
181
 
182
+ // DB-003: surface unreadable records loudly so partial parse failures
183
+ // are not silent. Manifest also carries the full list for drill-down.
184
+ if (unreadable.length > 0) {
185
+ const sample = unreadable.slice(0, 5).map(u => u.file).join(', ');
186
+ warn(
187
+ `snapshot: ${unreadable.length} record file(s) were unreadable and skipped` +
188
+ (unreadable.length > 5 ? ` (first 5: ${sample}, ...)` : ` (${sample})`),
189
+ );
190
+ }
191
+
107
192
  // Write snapshot files (skip if dry run)
108
193
  if (options.dryRun) {
109
- return { snapshotId, included: included.length, excluded: excluded.length };
194
+ return { snapshotId, included: included.length, excluded: excluded.length, unreadable: unreadable.length };
110
195
  }
111
196
  await mkdir(snapshotDir, { recursive: true });
112
197
 
113
198
  const manifest = {
199
+ // DB-001 / DB-006: stamp manifest schema version (reader checks on load)
200
+ schema_version: SCHEMA_VERSION,
114
201
  snapshot_id: snapshotId,
115
202
  created_at: new Date().toISOString(),
116
203
  created_by: 'sdlab-snapshot-v1',
117
204
  project_name: config.meta.name,
118
205
  frozen: true,
119
206
  selection_profile: profile,
120
- config_fingerprint: computeConfigFingerprint(projectRoot),
207
+ config_fingerprint: configFingerprint,
121
208
  config_versions: {
122
209
  constitution: config.constitution.version || '1.0.0',
123
210
  project: config.meta.version || '0.0.0',
124
211
  },
125
212
  counts: {
126
213
  total_records: files.length,
127
- evaluated: files.length,
214
+ // DB-003: evaluated reflects records actually parsed (excludes unreadable)
215
+ evaluated: files.length - unreadable.length,
128
216
  included: included.length,
129
217
  excluded: excluded.length,
218
+ unreadable: unreadable.length,
130
219
  },
220
+ // DB-003: preserve the unreadable list so operators can investigate
221
+ errors: unreadable,
131
222
  };
132
223
 
133
224
  const summary = {
@@ -153,7 +244,9 @@ export async function loadSnapshot(projectRoot, snapshotId) {
153
244
  if (!existsSync(path)) {
154
245
  throw new Error(`Snapshot "${snapshotId}" not found at ${path}`);
155
246
  }
156
- return JSON.parse(await readFile(path, 'utf-8'));
247
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
248
+ checkManifestVersion(manifest, 'snapshot');
249
+ return manifest;
157
250
  }
158
251
 
159
252
  /**
package/lib/split.js CHANGED
@@ -13,8 +13,9 @@ 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 } from './snapshot.js';
16
+ import { loadSnapshot, loadSnapshotIncluded, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
17
17
  import { loadRecord } from './records.js';
18
+ import { inputError } from './errors.js';
18
19
 
19
20
  /**
20
21
  * Mulberry32 — deterministic 32-bit PRNG.
@@ -119,6 +120,74 @@ function buildAncestorMap(records) {
119
120
  return ancestorMap;
120
121
  }
121
122
 
123
+ /**
124
+ * Allocate `total` items across train/val/test using largest-remainder rounding,
125
+ * guaranteeing each partition receives at least 1 when total >= 3.
126
+ */
127
+ function allocateLargestRemainder(total, profile) {
128
+ if (total <= 0) return { train: 0, val: 0, test: 0 };
129
+
130
+ const ratios = {
131
+ train: profile.train_ratio,
132
+ val: profile.val_ratio,
133
+ test: profile.test_ratio,
134
+ };
135
+
136
+ // Ideal fractional counts
137
+ const ideal = {
138
+ train: total * ratios.train,
139
+ val: total * ratios.val,
140
+ test: total * ratios.test,
141
+ };
142
+
143
+ // Floor + sort by fractional remainder desc (tie-break by partition name)
144
+ const base = {
145
+ train: Math.floor(ideal.train),
146
+ val: Math.floor(ideal.val),
147
+ test: Math.floor(ideal.test),
148
+ };
149
+ let assigned = base.train + base.val + base.test;
150
+
151
+ const remainders = [
152
+ ['train', ideal.train - base.train],
153
+ ['val', ideal.val - base.val],
154
+ ['test', ideal.test - base.test],
155
+ ].sort((a, b) => {
156
+ if (b[1] !== a[1]) return b[1] - a[1];
157
+ return a[0].localeCompare(b[0]);
158
+ });
159
+
160
+ let ri = 0;
161
+ while (assigned < total) {
162
+ base[remainders[ri % remainders.length][0]]++;
163
+ assigned++;
164
+ ri++;
165
+ }
166
+
167
+ // Min-1 rebalance: if total >= 3 and any partition is 0, take from the
168
+ // largest partition. Deterministic — always steal from 'train' first when tied.
169
+ if (total >= 3) {
170
+ const partitionOrder = ['train', 'val', 'test'];
171
+ for (const part of partitionOrder) {
172
+ if (base[part] === 0) {
173
+ // find largest (ties broken by name asc to stay deterministic)
174
+ const donor = partitionOrder
175
+ .filter(p => p !== part && base[p] > 1)
176
+ .sort((a, b) => {
177
+ if (base[b] !== base[a]) return base[b] - base[a];
178
+ return a.localeCompare(b);
179
+ })[0];
180
+ if (donor) {
181
+ base[donor]--;
182
+ base[part]++;
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ return base;
189
+ }
190
+
122
191
  /**
123
192
  * Create a split from a snapshot.
124
193
  *
@@ -156,14 +225,34 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
156
225
  familyMap.get(family).push(record);
157
226
  }
158
227
 
159
- // Detect primary lane per family (first record's lane)
228
+ // D-002: Deterministic primary lane per family = majority vote; ties
229
+ // broken by lexicographic lane name. Record the per-family decision
230
+ // for the audit so the chosen lane is explainable.
160
231
  const familyLane = new Map();
232
+ const familyLaneDecisions = [];
161
233
  for (const [family, recs] of familyMap) {
162
- const first = recs[0];
163
- const prompt = first.provenance?.prompt || '';
164
- const lane = detectLane(first.id, prompt, config.lanes);
165
- familyLane.set(family, lane);
234
+ const laneCounts = new Map();
235
+ for (const rec of recs) {
236
+ const prompt = rec.provenance?.prompt || '';
237
+ const recLane = detectLane(rec.id, prompt, config.lanes);
238
+ laneCounts.set(recLane, (laneCounts.get(recLane) || 0) + 1);
239
+ }
240
+ // Sort: count desc, then lane name asc. Deterministic winner.
241
+ const ranked = [...laneCounts.entries()].sort((a, b) => {
242
+ if (b[1] !== a[1]) return b[1] - a[1];
243
+ return a[0].localeCompare(b[0]);
244
+ });
245
+ const [chosenLane, chosenCount] = ranked[0];
246
+ familyLane.set(family, chosenLane);
247
+ familyLaneDecisions.push({
248
+ family,
249
+ chosen_lane: chosenLane,
250
+ chosen_count: chosenCount,
251
+ record_count: recs.length,
252
+ lane_counts: Object.fromEntries(laneCounts),
253
+ });
166
254
  }
255
+ familyLaneDecisions.sort((a, b) => a.family.localeCompare(b.family));
167
256
 
168
257
  // Group families by lane
169
258
  const laneGroups = new Map(); // lane → [family]
@@ -173,7 +262,8 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
173
262
  }
174
263
 
175
264
  // Split families per lane using seeded PRNG
176
- const rng = mulberry32(profile.seed || 42);
265
+ // D-011: seed:0 is a valid seed; use nullish coalescing so 0 is honored.
266
+ const rng = mulberry32(profile.seed ?? 42);
177
267
  const trainFamilies = new Set();
178
268
  const valFamilies = new Set();
179
269
  const testFamilies = new Set();
@@ -191,11 +281,17 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
191
281
  continue;
192
282
  }
193
283
 
194
- // Assign by ratio
284
+ // D-004: Largest-remainder allocation guarantees min-1 per partition
285
+ // when total >= 3, and emits a warning if any partition ends up at 0.
195
286
  const total = shuffled.length;
196
- const trainCount = Math.round(total * profile.train_ratio);
197
- const valCount = Math.round(total * profile.val_ratio);
198
- // test gets the rest
287
+ const { train: trainCount, val: valCount, test: testCount } =
288
+ allocateLargestRemainder(total, profile);
289
+
290
+ if (total >= 3 && (trainCount === 0 || valCount === 0 || testCount === 0)) {
291
+ warnings.push(
292
+ `lane "${lane}": partition size hit 0 after allocation (train=${trainCount}, val=${valCount}, test=${testCount}) — ratios may be extreme`,
293
+ );
294
+ }
199
295
 
200
296
  let idx = 0;
201
297
  for (let i = 0; i < trainCount && idx < total; i++, idx++) {
@@ -226,7 +322,14 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
226
322
  if (trainFamilies.has(family)) trainRecords.push(record.id);
227
323
  else if (valFamilies.has(family)) valRecords.push(record.id);
228
324
  else if (testFamilies.has(family)) testRecords.push(record.id);
229
- else trainRecords.push(record.id); // fallback: unassigned → train
325
+ else {
326
+ // D-003: unassigned family means the familyMap build pass disagreed
327
+ // with the assignment pass — never silently rescue; surface the bug.
328
+ throw new Error(
329
+ `unassigned family for record ${record.id} (family=${family}) — ` +
330
+ `this indicates a bug in family resolution; the record was not placed in train/val/test`,
331
+ );
332
+ }
230
333
  }
231
334
 
232
335
  // Sort for deterministic output
@@ -237,9 +340,9 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
237
340
  // Leakage audit
238
341
  const leakageIssues = auditLeakage(familyMap, trainRecords, valRecords, testRecords);
239
342
 
240
- // Lane balance audit
343
+ // Lane balance audit (DB-005: includes deviation from profile ratios)
241
344
  const laneBalance = auditLaneBalance(
242
- records, trainRecords, valRecords, testRecords, config.lanes
345
+ records, trainRecords, valRecords, testRecords, config.lanes, profile
243
346
  );
244
347
 
245
348
  // Write output (skip if dry run)
@@ -248,9 +351,19 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
248
351
  return { splitId, train: trainRecords.length, val: valRecords.length, test: testRecords.length };
249
352
  }
250
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
+ }
251
362
  await mkdir(splitDir, { recursive: true });
252
363
 
253
364
  const manifest = {
365
+ // DB-001 / DB-006: stamp manifest schema version
366
+ schema_version: SCHEMA_VERSION,
254
367
  split_id: splitId,
255
368
  created_at: new Date().toISOString(),
256
369
  created_by: 'sdlab-split-v1',
@@ -280,6 +393,9 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
280
393
  val: valFamilies.size,
281
394
  test: testFamilies.size,
282
395
  },
396
+ // D-002: record the deterministic per-family lane decision for replay.
397
+ family_lane_decisions: familyLaneDecisions,
398
+ warnings,
283
399
  };
284
400
 
285
401
  await writeFile(join(splitDir, 'split.json'), JSON.stringify(manifest, null, 2) + '\n');
@@ -322,8 +438,13 @@ function auditLeakage(familyMap, trainIds, valIds, testIds) {
322
438
 
323
439
  /**
324
440
  * Audit lane balance across splits.
441
+ *
442
+ * DB-005: includes deviation-from-profile metric per lane + overall score.
443
+ * A lane that shows train=100/val=0/test=0 and another at 33/33/33 look
444
+ * equally "balanced" by percentage alone — deviation makes the real gap
445
+ * between intent and reality visible at a glance.
325
446
  */
326
- function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig) {
447
+ function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig, profile) {
327
448
  const trainSet = new Set(trainIds);
328
449
  const valSet = new Set(valIds);
329
450
  const testSet = new Set(testIds);
@@ -339,14 +460,49 @@ function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig) {
339
460
  else if (testSet.has(record.id)) balance[lane].test++;
340
461
  }
341
462
 
342
- // Compute percentages
463
+ // Compute percentages + deviation from profile ratios (DB-005)
464
+ const targetTrain = (profile?.train_ratio ?? 0) * 100;
465
+ const targetVal = (profile?.val_ratio ?? 0) * 100;
466
+ const targetTest = (profile?.test_ratio ?? 0) * 100;
467
+
468
+ let overallDeviation = 0;
469
+ const deviations = {};
470
+
343
471
  for (const lane of Object.keys(balance)) {
344
472
  const b = balance[lane];
345
473
  b.train_pct = b.total > 0 ? +(b.train / b.total * 100).toFixed(1) : 0;
346
474
  b.val_pct = b.total > 0 ? +(b.val / b.total * 100).toFixed(1) : 0;
347
475
  b.test_pct = b.total > 0 ? +(b.test / b.total * 100).toFixed(1) : 0;
476
+
477
+ // DB-005: absolute deviation from profile target per partition
478
+ const devTrain = Math.abs(b.train_pct - targetTrain);
479
+ const devVal = Math.abs(b.val_pct - targetVal);
480
+ const devTest = Math.abs(b.test_pct - targetTest);
481
+ // Lane deviation: sum of per-partition absolute deviations (max ~200)
482
+ const laneDev = +(devTrain + devVal + devTest).toFixed(1);
483
+ b.deviation_from_target = {
484
+ train: +devTrain.toFixed(1),
485
+ val: +devVal.toFixed(1),
486
+ test: +devTest.toFixed(1),
487
+ total: laneDev,
488
+ };
489
+ deviations[lane] = laneDev;
490
+ overallDeviation += laneDev;
348
491
  }
349
492
 
493
+ // Attach a summary so the operator sees the signal without scanning per-lane rows.
494
+ balance._summary = {
495
+ target_ratios_pct: {
496
+ train: +targetTrain.toFixed(1),
497
+ val: +targetVal.toFixed(1),
498
+ test: +targetTest.toFixed(1),
499
+ },
500
+ overall_deviation_score: +overallDeviation.toFixed(1),
501
+ worst_lane: Object.keys(deviations).length > 0
502
+ ? Object.entries(deviations).sort((a, b) => b[1] - a[1])[0][0]
503
+ : null,
504
+ };
505
+
350
506
  return balance;
351
507
  }
352
508
 
@@ -358,7 +514,9 @@ export async function loadSplit(projectRoot, splitId) {
358
514
  if (!existsSync(path)) {
359
515
  throw new Error(`Split "${splitId}" not found at ${path}`);
360
516
  }
361
- return JSON.parse(await readFile(path, 'utf-8'));
517
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
518
+ checkManifestVersion(manifest, 'split');
519
+ return manifest;
362
520
  }
363
521
 
364
522
  /**