@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.
- package/CHANGELOG.md +68 -15
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +71 -0
- package/README.md +46 -1
- package/SECURITY.md +6 -5
- package/bin/sdlab.js +281 -62
- package/lib/adapters/diffusers-lora.js +7 -2
- package/lib/adapters/generic-image-caption.js +6 -1
- package/lib/args.js +200 -13
- package/lib/batch-compiler.js +7 -3
- package/lib/batch-runs.js +100 -31
- package/lib/batch-sheet-render.js +5 -2
- package/lib/brief-compiler.js +38 -11
- package/lib/card.js +11 -4
- package/lib/comfyui.js +100 -6
- package/lib/config.js +68 -3
- package/lib/deprecation.js +1 -1
- package/lib/eval-pack.js +94 -4
- package/lib/eval-runs.js +42 -3
- package/lib/export.js +144 -34
- package/lib/implementation-packs.js +15 -1
- package/lib/log.js +41 -4
- package/lib/paths.js +41 -3
- package/lib/refine-briefs.js +16 -9
- package/lib/reingest-selected.js +14 -3
- package/lib/reingest.js +15 -1
- package/lib/run-summary.js +3 -2
- package/lib/runtime-runs.js +59 -9
- package/lib/selections.js +54 -2
- package/lib/snapshot.js +100 -7
- package/lib/split.js +175 -17
- package/lib/training-manifests.js +17 -3
- package/lib/training-packages.js +28 -14
- package/package.json +8 -2
- package/scripts/batch-generate.js +16 -7
- package/scripts/batch-sheet.js +3 -3
- package/scripts/batch-show.js +3 -3
- package/scripts/brief-compile.js +8 -8
- package/scripts/brief-show.js +4 -3
- package/scripts/canon-bind.js +7 -9
- package/scripts/card.js +22 -25
- package/scripts/compare.js +38 -33
- package/scripts/critique-show.js +3 -3
- package/scripts/critique.js +3 -3
- package/scripts/curate.js +91 -46
- package/scripts/doctor.js +3 -5
- package/scripts/eligibility.js +17 -14
- package/scripts/eval-pack.js +13 -11
- package/scripts/eval-run.js +30 -29
- package/scripts/export.js +27 -31
- package/scripts/generate-controlnet.js +31 -17
- package/scripts/generate-identity.js +37 -20
- package/scripts/generate-ipadapter.js +22 -16
- package/scripts/generate.js +68 -13
- package/scripts/implementation-pack.js +19 -14
- package/scripts/init.js +115 -17
- package/scripts/painterly.js +8 -10
- package/scripts/refine.js +3 -3
- package/scripts/reingest-selected.js +3 -3
- package/scripts/reingest.js +33 -20
- package/scripts/run-generate.js +4 -4
- package/scripts/run-list.js +3 -3
- package/scripts/run-show.js +3 -3
- package/scripts/select.js +5 -4
- package/scripts/selection-show.js +3 -3
- package/scripts/snapshot.js +22 -24
- package/scripts/split.js +26 -31
- package/scripts/training-manifest.js +29 -24
- package/scripts/training-package.js +24 -20
- package/scripts/training-profile.js +13 -8
- package/scripts/workflow-list.js +2 -2
- package/scripts/workflow-show.js +4 -3
- package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/architecture/terminology.json +10 -8
- package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/character-design/project.json +4 -2
- package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/creature-design/project.json +4 -2
- package/templates/domains/creature-design/terminology.json +1 -0
- package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/game-art/project.json +4 -2
- package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/vehicle-mech/project.json +4 -2
- package/scripts/bulk-curate-wave2-5.js +0 -138
- package/scripts/bulk-curate-waves11-18.js +0 -413
- package/scripts/curate-wave25.js +0 -209
- package/scripts/migrate-records.js +0 -141
- package/templates/inputs/prompts/example-wave.json +0 -27
package/lib/runtime-runs.js
CHANGED
|
@@ -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
|
|
30
|
-
if (!
|
|
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 ??
|
|
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(
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
34
|
-
if (!
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
517
|
+
const manifest = JSON.parse(await readFile(path, 'utf-8'));
|
|
518
|
+
checkManifestVersion(manifest, 'split');
|
|
519
|
+
return manifest;
|
|
362
520
|
}
|
|
363
521
|
|
|
364
522
|
/**
|