@mcptoolshop/style-dataset-lab 2.0.0 → 2.2.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/lib/split.js ADDED
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Split engine — subject-isolated, lane-balanced dataset splitting.
3
+ *
4
+ * Laws:
5
+ * 1. Records sharing a subject family always land in the same split
6
+ * 2. Lane balance is maintained within defined tolerances
7
+ * 3. Splits are deterministic (seeded PRNG, sorted inputs)
8
+ * 4. Every split produces an audit trail proving no leakage
9
+ */
10
+
11
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { existsSync } from 'node:fs';
14
+ import { createHash, randomBytes } from 'node:crypto';
15
+ import { loadProjectConfig, loadSplitProfile, detectLane } from './config.js';
16
+ import { loadSnapshot, loadSnapshotIncluded } from './snapshot.js';
17
+ import { loadRecord } from './records.js';
18
+
19
+ /**
20
+ * Mulberry32 — deterministic 32-bit PRNG.
21
+ * Same seed always produces the same sequence.
22
+ */
23
+ function mulberry32(seed) {
24
+ let t = seed >>> 0;
25
+ return function () {
26
+ t = (t + 0x6d2b79f5) | 0;
27
+ let v = t;
28
+ v = Math.imul(v ^ (v >>> 15), v | 1);
29
+ v ^= v + Math.imul(v ^ (v >>> 7), v | 61);
30
+ return ((v ^ (v >>> 14)) >>> 0) / 4294967296;
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Fisher-Yates shuffle with seeded PRNG.
36
+ */
37
+ function seededShuffle(arr, rng) {
38
+ const out = [...arr];
39
+ for (let i = out.length - 1; i > 0; i--) {
40
+ const j = Math.floor(rng() * (i + 1));
41
+ [out[i], out[j]] = [out[j], out[i]];
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Generate a split ID: split-YYYYMMDD-HHMMSS-XXXX
48
+ */
49
+ function generateSplitId() {
50
+ const now = new Date();
51
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
52
+ const rand = randomBytes(2).toString('hex');
53
+ return `split-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
54
+ }
55
+
56
+ /**
57
+ * Determine the subject family for a record.
58
+ *
59
+ * Priority:
60
+ * 1. identity.subject_name (named characters like renna_vasik)
61
+ * 2. lineage.derived_from_record_id (walk chain to root ancestor)
62
+ * 3. Strip _v\d+$, _s\d+$ suffixes from record ID
63
+ */
64
+ function resolveSubjectFamily(record) {
65
+ // 1. Named subject
66
+ if (record.identity?.subject_name) {
67
+ return record.identity.subject_name;
68
+ }
69
+
70
+ // 2. Lineage chain — use root ancestor ID as family
71
+ if (record.lineage?.derived_from_record_id) {
72
+ // We return the ancestor ID; the caller's ancestor map handles chain walking
73
+ return record.lineage.derived_from_record_id;
74
+ }
75
+
76
+ // 3. Strip version/seed suffixes from record ID
77
+ return record.id.replace(/_[vs]\d+$/, '');
78
+ }
79
+
80
+ /**
81
+ * Walk lineage chains to find root ancestors.
82
+ * Returns a map: recordId → rootAncestorFamily
83
+ */
84
+ function buildAncestorMap(records) {
85
+ const parentOf = new Map();
86
+ const idToRecord = new Map();
87
+
88
+ for (const record of records) {
89
+ idToRecord.set(record.id, record);
90
+ if (record.lineage?.derived_from_record_id) {
91
+ parentOf.set(record.id, record.lineage.derived_from_record_id);
92
+ }
93
+ }
94
+
95
+ // Walk each chain to root (with cycle protection)
96
+ function findRoot(id, visited = new Set()) {
97
+ if (visited.has(id)) return id; // cycle — stop
98
+ visited.add(id);
99
+ const parent = parentOf.get(id);
100
+ if (!parent) return id;
101
+ return findRoot(parent, visited);
102
+ }
103
+
104
+ const ancestorMap = new Map();
105
+ for (const record of records) {
106
+ if (record.lineage?.derived_from_record_id) {
107
+ const rootId = findRoot(record.id);
108
+ // Use the root's subject family
109
+ const rootRecord = idToRecord.get(rootId);
110
+ if (rootRecord) {
111
+ ancestorMap.set(record.id, resolveSubjectFamily(rootRecord));
112
+ } else {
113
+ // Root not in dataset — strip suffixes from root ID
114
+ ancestorMap.set(record.id, rootId.replace(/_[vs]\d+$/, ''));
115
+ }
116
+ }
117
+ }
118
+
119
+ return ancestorMap;
120
+ }
121
+
122
+ /**
123
+ * Create a split from a snapshot.
124
+ *
125
+ * @param {string} projectRoot — absolute path to project
126
+ * @param {string} snapshotId — snapshot to split
127
+ * @param {Object} profile — split profile (from loadSplitProfile)
128
+ * @returns {Promise<{splitId: string, train: number, val: number, test: number}>}
129
+ */
130
+ export async function createSplit(projectRoot, snapshotId, profile) {
131
+ const config = loadProjectConfig(projectRoot);
132
+ const snapshot = await loadSnapshot(projectRoot, snapshotId);
133
+ const includedEntries = await loadSnapshotIncluded(projectRoot, snapshotId);
134
+ const recordsDir = join(projectRoot, 'records');
135
+
136
+ // Load full records for included entries
137
+ const records = [];
138
+ for (const entry of includedEntries) {
139
+ const record = await loadRecord(recordsDir, entry.record_id);
140
+ if (record) records.push(record);
141
+ }
142
+
143
+ // Build ancestor map for lineage chain resolution
144
+ const ancestorMap = buildAncestorMap(records);
145
+
146
+ // Assign each record to a subject family
147
+ const familyMap = new Map(); // family → [record]
148
+ for (const record of records) {
149
+ let family;
150
+ if (ancestorMap.has(record.id)) {
151
+ family = ancestorMap.get(record.id);
152
+ } else {
153
+ family = resolveSubjectFamily(record);
154
+ }
155
+ if (!familyMap.has(family)) familyMap.set(family, []);
156
+ familyMap.get(family).push(record);
157
+ }
158
+
159
+ // Detect primary lane per family (first record's lane)
160
+ const familyLane = new Map();
161
+ 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);
166
+ }
167
+
168
+ // Group families by lane
169
+ const laneGroups = new Map(); // lane → [family]
170
+ for (const [family, lane] of familyLane) {
171
+ if (!laneGroups.has(lane)) laneGroups.set(lane, []);
172
+ laneGroups.get(lane).push(family);
173
+ }
174
+
175
+ // Split families per lane using seeded PRNG
176
+ const rng = mulberry32(profile.seed || 42);
177
+ const trainFamilies = new Set();
178
+ const valFamilies = new Set();
179
+ const testFamilies = new Set();
180
+ const warnings = [];
181
+
182
+ const sortedLanes = [...laneGroups.keys()].sort();
183
+ for (const lane of sortedLanes) {
184
+ const families = laneGroups.get(lane).sort(); // sort for determinism before shuffle
185
+ const shuffled = seededShuffle(families, rng);
186
+
187
+ if (shuffled.length < 3) {
188
+ // Too few families — all to train
189
+ for (const f of shuffled) trainFamilies.add(f);
190
+ warnings.push(`lane "${lane}": only ${shuffled.length} families — all assigned to train`);
191
+ continue;
192
+ }
193
+
194
+ // Assign by ratio
195
+ 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
199
+
200
+ let idx = 0;
201
+ for (let i = 0; i < trainCount && idx < total; i++, idx++) {
202
+ trainFamilies.add(shuffled[idx]);
203
+ }
204
+ for (let i = 0; i < valCount && idx < total; i++, idx++) {
205
+ valFamilies.add(shuffled[idx]);
206
+ }
207
+ while (idx < total) {
208
+ testFamilies.add(shuffled[idx]);
209
+ idx++;
210
+ }
211
+ }
212
+
213
+ // Build record → split assignment
214
+ const trainRecords = [];
215
+ const valRecords = [];
216
+ const testRecords = [];
217
+
218
+ for (const record of records) {
219
+ let family;
220
+ if (ancestorMap.has(record.id)) {
221
+ family = ancestorMap.get(record.id);
222
+ } else {
223
+ family = resolveSubjectFamily(record);
224
+ }
225
+
226
+ if (trainFamilies.has(family)) trainRecords.push(record.id);
227
+ else if (valFamilies.has(family)) valRecords.push(record.id);
228
+ else if (testFamilies.has(family)) testRecords.push(record.id);
229
+ else trainRecords.push(record.id); // fallback: unassigned → train
230
+ }
231
+
232
+ // Sort for deterministic output
233
+ trainRecords.sort();
234
+ valRecords.sort();
235
+ testRecords.sort();
236
+
237
+ // Leakage audit
238
+ const leakageIssues = auditLeakage(familyMap, trainRecords, valRecords, testRecords);
239
+
240
+ // Lane balance audit
241
+ const laneBalance = auditLaneBalance(
242
+ records, trainRecords, valRecords, testRecords, config.lanes
243
+ );
244
+
245
+ // Write output
246
+ const splitId = generateSplitId();
247
+ const splitDir = join(projectRoot, 'splits', splitId);
248
+ await mkdir(splitDir, { recursive: true });
249
+
250
+ const manifest = {
251
+ split_id: splitId,
252
+ created_at: new Date().toISOString(),
253
+ created_by: 'sdlab-split-v1',
254
+ snapshot_id: snapshotId,
255
+ snapshot_fingerprint: snapshot.config_fingerprint,
256
+ profile,
257
+ counts: {
258
+ total_records: records.length,
259
+ total_families: familyMap.size,
260
+ train: trainRecords.length,
261
+ val: valRecords.length,
262
+ test: testRecords.length,
263
+ },
264
+ warnings,
265
+ };
266
+
267
+ const audit = {
268
+ split_id: splitId,
269
+ leakage_check: {
270
+ passed: leakageIssues.length === 0,
271
+ issues: leakageIssues,
272
+ },
273
+ lane_balance: laneBalance,
274
+ family_count: familyMap.size,
275
+ families_per_split: {
276
+ train: trainFamilies.size,
277
+ val: valFamilies.size,
278
+ test: testFamilies.size,
279
+ },
280
+ };
281
+
282
+ await writeFile(join(splitDir, 'split.json'), JSON.stringify(manifest, null, 2) + '\n');
283
+ await writeFile(join(splitDir, 'train.jsonl'), trainRecords.map(id => JSON.stringify({ record_id: id })).join('\n') + '\n');
284
+ await writeFile(join(splitDir, 'val.jsonl'), valRecords.map(id => JSON.stringify({ record_id: id })).join('\n') + '\n');
285
+ await writeFile(join(splitDir, 'test.jsonl'), testRecords.map(id => JSON.stringify({ record_id: id })).join('\n') + '\n');
286
+ await writeFile(join(splitDir, 'audit.json'), JSON.stringify(audit, null, 2) + '\n');
287
+
288
+ return { splitId, train: trainRecords.length, val: valRecords.length, test: testRecords.length };
289
+ }
290
+
291
+ /**
292
+ * Audit for subject leakage across splits.
293
+ * Returns an array of issues (empty = clean).
294
+ */
295
+ function auditLeakage(familyMap, trainIds, valIds, testIds) {
296
+ const trainSet = new Set(trainIds);
297
+ const valSet = new Set(valIds);
298
+ const testSet = new Set(testIds);
299
+ const issues = [];
300
+
301
+ for (const [family, records] of familyMap) {
302
+ const splits = new Set();
303
+ for (const r of records) {
304
+ if (trainSet.has(r.id)) splits.add('train');
305
+ if (valSet.has(r.id)) splits.add('val');
306
+ if (testSet.has(r.id)) splits.add('test');
307
+ }
308
+ if (splits.size > 1) {
309
+ issues.push({
310
+ family,
311
+ record_count: records.length,
312
+ leaked_to: [...splits],
313
+ });
314
+ }
315
+ }
316
+
317
+ return issues;
318
+ }
319
+
320
+ /**
321
+ * Audit lane balance across splits.
322
+ */
323
+ function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig) {
324
+ const trainSet = new Set(trainIds);
325
+ const valSet = new Set(valIds);
326
+ const testSet = new Set(testIds);
327
+ const balance = {};
328
+
329
+ for (const record of records) {
330
+ const prompt = record.provenance?.prompt || '';
331
+ const lane = detectLane(record.id, prompt, lanesConfig);
332
+ if (!balance[lane]) balance[lane] = { total: 0, train: 0, val: 0, test: 0 };
333
+ balance[lane].total++;
334
+ if (trainSet.has(record.id)) balance[lane].train++;
335
+ else if (valSet.has(record.id)) balance[lane].val++;
336
+ else if (testSet.has(record.id)) balance[lane].test++;
337
+ }
338
+
339
+ // Compute percentages
340
+ for (const lane of Object.keys(balance)) {
341
+ const b = balance[lane];
342
+ b.train_pct = b.total > 0 ? +(b.train / b.total * 100).toFixed(1) : 0;
343
+ b.val_pct = b.total > 0 ? +(b.val / b.total * 100).toFixed(1) : 0;
344
+ b.test_pct = b.total > 0 ? +(b.test / b.total * 100).toFixed(1) : 0;
345
+ }
346
+
347
+ return balance;
348
+ }
349
+
350
+ /**
351
+ * Load a split manifest.
352
+ */
353
+ export async function loadSplit(projectRoot, splitId) {
354
+ const path = join(projectRoot, 'splits', splitId, 'split.json');
355
+ if (!existsSync(path)) {
356
+ throw new Error(`Split "${splitId}" not found at ${path}`);
357
+ }
358
+ return JSON.parse(await readFile(path, 'utf-8'));
359
+ }
360
+
361
+ /**
362
+ * Load split record IDs for a partition.
363
+ */
364
+ export async function loadSplitPartition(projectRoot, splitId, partition) {
365
+ const path = join(projectRoot, 'splits', splitId, `${partition}.jsonl`);
366
+ const raw = await readFile(path, 'utf-8');
367
+ return raw.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
368
+ }
369
+
370
+ /**
371
+ * Load split audit.
372
+ */
373
+ export async function loadSplitAudit(projectRoot, splitId) {
374
+ const path = join(projectRoot, 'splits', splitId, 'audit.json');
375
+ return JSON.parse(await readFile(path, 'utf-8'));
376
+ }
377
+
378
+ /**
379
+ * List all splits in a project.
380
+ */
381
+ export async function listSplits(projectRoot) {
382
+ const splitsDir = join(projectRoot, 'splits');
383
+ if (!existsSync(splitsDir)) return [];
384
+
385
+ const entries = await readdir(splitsDir, { withFileTypes: true });
386
+ const splits = [];
387
+
388
+ for (const entry of entries) {
389
+ if (!entry.isDirectory() || !entry.name.startsWith('split-')) continue;
390
+ try {
391
+ const manifest = JSON.parse(
392
+ await readFile(join(splitsDir, entry.name, 'split.json'), 'utf-8')
393
+ );
394
+ splits.push({
395
+ id: manifest.split_id,
396
+ created_at: manifest.created_at,
397
+ snapshot_id: manifest.snapshot_id,
398
+ train: manifest.counts.train,
399
+ val: manifest.counts.val,
400
+ test: manifest.counts.test,
401
+ families: manifest.counts.total_families,
402
+ });
403
+ } catch {
404
+ // Skip malformed
405
+ }
406
+ }
407
+
408
+ return splits.sort((a, b) => a.created_at.localeCompare(b.created_at));
409
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Training manifest management.
3
+ *
4
+ * A training manifest is a frozen, versioned contract for one training attempt.
5
+ * It captures the exact export package, split, profile, and config fingerprint
6
+ * so the training run is fully reproducible.
7
+ */
8
+
9
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { createHash, randomBytes } from 'node:crypto';
13
+ import { loadExport } from './export.js';
14
+ import { loadTrainingProfile } from './training-profiles.js';
15
+ import { computeConfigFingerprint } from './snapshot.js';
16
+ import { listEvalPacks } from './eval-pack.js';
17
+
18
+ /**
19
+ * Generate a training manifest ID: tm-YYYYMMDD-HHMMSS-XXXX
20
+ */
21
+ function generateManifestId() {
22
+ const now = new Date();
23
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
24
+ const rand = randomBytes(2).toString('hex');
25
+ return `tm-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
26
+ }
27
+
28
+ /**
29
+ * Compute SHA-256 of a file's contents.
30
+ */
31
+ async function hashFile(filePath) {
32
+ const data = await readFile(filePath);
33
+ return createHash('sha256').update(data).digest('hex');
34
+ }
35
+
36
+ /**
37
+ * Create a training manifest from an export package and training profile.
38
+ *
39
+ * @param {string} projectRoot
40
+ * @param {string} exportId
41
+ * @param {string} profileId
42
+ * @param {Object} options — { baseModel?, adapterTarget?, hyperparameters?, captionMode? }
43
+ * @returns {Promise<{manifestId: string, exportId: string, profileId: string}>}
44
+ */
45
+ export async function createTrainingManifest(projectRoot, exportId, profileId, options = {}) {
46
+ const profile = await loadTrainingProfile(projectRoot, profileId);
47
+ const exportManifest = await loadExport(projectRoot, exportId);
48
+
49
+ // Hash the export manifest for integrity
50
+ const exportManifestPath = join(projectRoot, 'exports', exportId, 'manifest.json');
51
+ const exportManifestHash = await hashFile(exportManifestPath);
52
+
53
+ // Determine adapter target
54
+ const adapterTarget = options.adapterTarget || profile.adapter_targets[0];
55
+ if (!profile.adapter_targets.includes(adapterTarget)) {
56
+ throw new Error(
57
+ `Adapter target "${adapterTarget}" not in profile's adapter_targets: [${profile.adapter_targets.join(', ')}]`
58
+ );
59
+ }
60
+
61
+ // Find linked eval packs
62
+ const evalPacks = await listEvalPacks(projectRoot);
63
+ const linkedEvalPackIds = evalPacks.map(p => p.id);
64
+
65
+ const manifestId = generateManifestId();
66
+ const manifest = {
67
+ training_manifest_id: manifestId,
68
+ created_at: new Date().toISOString(),
69
+ created_by: 'sdlab-training-manifest-v1',
70
+ project_id: exportManifest.project,
71
+ training_profile_id: profileId,
72
+ source_export_id: exportId,
73
+ source_snapshot_id: exportManifest.snapshot_id,
74
+ source_split_id: exportManifest.split_id,
75
+ config_fingerprint: computeConfigFingerprint(projectRoot),
76
+ export_manifest_hash: exportManifestHash,
77
+ dataset_counts: exportManifest.counts,
78
+ base_model: options.baseModel || profile.base_model_recommendations?.[0] || null,
79
+ adapter_target: adapterTarget,
80
+ hyperparameters: options.hyperparameters || {},
81
+ caption_mode: options.captionMode || profile.caption_strategy || 'filename',
82
+ expected_outputs: {
83
+ asset_type: profile.asset_type,
84
+ target_family: profile.target_family,
85
+ },
86
+ linked_eval_pack_ids: linkedEvalPackIds,
87
+ };
88
+
89
+ // Write manifest
90
+ const dir = join(projectRoot, 'training', 'manifests');
91
+ await mkdir(dir, { recursive: true });
92
+ await writeFile(join(dir, `${manifestId}.json`), JSON.stringify(manifest, null, 2) + '\n');
93
+
94
+ return { manifestId, exportId, profileId };
95
+ }
96
+
97
+ /**
98
+ * Validate a training manifest against current project state.
99
+ * Checks that referenced artifacts still exist and fingerprints match.
100
+ *
101
+ * @returns {{ valid: boolean, issues: string[] }}
102
+ */
103
+ export async function validateTrainingManifest(projectRoot, manifestId) {
104
+ const manifest = await loadTrainingManifest(projectRoot, manifestId);
105
+ const issues = [];
106
+
107
+ // Check export exists
108
+ const exportPath = join(projectRoot, 'exports', manifest.source_export_id, 'manifest.json');
109
+ if (!existsSync(exportPath)) {
110
+ issues.push(`source export "${manifest.source_export_id}" not found`);
111
+ } else {
112
+ // Check export hash
113
+ const currentHash = await hashFile(exportPath);
114
+ if (currentHash !== manifest.export_manifest_hash) {
115
+ issues.push(`export manifest hash mismatch — export may have been modified`);
116
+ }
117
+ }
118
+
119
+ // Check config fingerprint
120
+ const currentFingerprint = computeConfigFingerprint(projectRoot);
121
+ if (currentFingerprint !== manifest.config_fingerprint) {
122
+ issues.push(`config fingerprint mismatch — project config has changed since manifest creation`);
123
+ }
124
+
125
+ // Check training profile exists
126
+ const profilePath = join(projectRoot, 'training', 'profiles', `${manifest.training_profile_id}.json`);
127
+ if (!existsSync(profilePath)) {
128
+ issues.push(`training profile "${manifest.training_profile_id}" not found`);
129
+ }
130
+
131
+ return { valid: issues.length === 0, issues };
132
+ }
133
+
134
+ /**
135
+ * Load a training manifest.
136
+ */
137
+ export async function loadTrainingManifest(projectRoot, manifestId) {
138
+ const path = join(projectRoot, 'training', 'manifests', `${manifestId}.json`);
139
+ if (!existsSync(path)) {
140
+ throw new Error(`Training manifest "${manifestId}" not found at ${path}`);
141
+ }
142
+ return JSON.parse(await readFile(path, 'utf-8'));
143
+ }
144
+
145
+ /**
146
+ * List all training manifests in a project.
147
+ */
148
+ export async function listTrainingManifests(projectRoot) {
149
+ const dir = join(projectRoot, 'training', 'manifests');
150
+ if (!existsSync(dir)) return [];
151
+
152
+ const files = (await readdir(dir)).filter(f => f.endsWith('.json')).sort();
153
+ const manifests = [];
154
+
155
+ for (const file of files) {
156
+ try {
157
+ const data = JSON.parse(await readFile(join(dir, file), 'utf-8'));
158
+ manifests.push({
159
+ id: data.training_manifest_id,
160
+ created_at: data.created_at,
161
+ profile: data.training_profile_id,
162
+ export_id: data.source_export_id,
163
+ adapter: data.adapter_target,
164
+ records: data.dataset_counts?.records || 0,
165
+ });
166
+ } catch {
167
+ // Skip malformed
168
+ }
169
+ }
170
+
171
+ return manifests.sort((a, b) => a.created_at.localeCompare(b.created_at));
172
+ }