@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/CHANGELOG.md +52 -0
- package/README.es.md +137 -108
- package/README.fr.md +137 -108
- package/README.hi.md +137 -108
- package/README.it.md +137 -108
- package/README.ja.md +137 -108
- package/README.md +87 -91
- package/README.pt-BR.md +137 -108
- package/README.zh.md +137 -108
- package/bin/sdlab.js +49 -0
- package/lib/adapters/diffusers-lora.js +110 -0
- package/lib/adapters/generic-image-caption.js +94 -0
- package/lib/card.js +145 -0
- package/lib/config.js +144 -0
- package/lib/eligibility.js +110 -0
- package/lib/eval-pack.js +283 -0
- package/lib/eval-runs.js +285 -0
- package/lib/export.js +259 -0
- package/lib/implementation-packs.js +316 -0
- package/lib/reingest.js +131 -0
- package/lib/snapshot.js +211 -0
- package/lib/split.js +409 -0
- package/lib/training-manifests.js +172 -0
- package/lib/training-packages.js +241 -0
- package/lib/training-profiles.js +97 -0
- package/package.json +12 -10
- package/scripts/canon-bind.js +1 -87
- package/scripts/card.js +73 -0
- package/scripts/doctor.js +8 -0
- package/scripts/eligibility.js +91 -0
- package/scripts/eval-pack.js +92 -0
- package/scripts/eval-run.js +121 -0
- package/scripts/export.js +85 -0
- package/scripts/implementation-pack.js +78 -0
- package/scripts/init.js +8 -0
- package/scripts/reingest.js +86 -0
- package/scripts/snapshot.js +132 -0
- package/scripts/split.js +154 -0
- package/scripts/training-manifest.js +119 -0
- package/scripts/training-package.js +84 -0
- package/scripts/training-profile.js +63 -0
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
|
+
}
|