@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/eval-runs.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eval run management.
|
|
3
|
+
*
|
|
4
|
+
* An eval run scores generated outputs against Phase 2 eval packs.
|
|
5
|
+
* Every eval run links back to a training manifest and eval pack —
|
|
6
|
+
* no orphan evals.
|
|
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 { randomBytes } from 'node:crypto';
|
|
13
|
+
import { loadTrainingManifest } from './training-manifests.js';
|
|
14
|
+
import { loadEvalPack } from './eval-pack.js';
|
|
15
|
+
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate an eval run ID: er-YYYYMMDD-HHMMSS-XXXX
|
|
19
|
+
*/
|
|
20
|
+
function generateEvalRunId() {
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
23
|
+
const rand = randomBytes(2).toString('hex');
|
|
24
|
+
return `er-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create an eval run — links a training manifest to an eval pack.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} projectRoot
|
|
31
|
+
* @param {string} manifestId — training manifest ID
|
|
32
|
+
* @param {string} evalPackId — eval pack ID
|
|
33
|
+
* @returns {Promise<{evalRunId: string}>}
|
|
34
|
+
*/
|
|
35
|
+
export async function createEvalRun(projectRoot, manifestId, evalPackId) {
|
|
36
|
+
// Verify both exist
|
|
37
|
+
const manifest = await loadTrainingManifest(projectRoot, manifestId);
|
|
38
|
+
const evalPack = await loadEvalPack(projectRoot, evalPackId);
|
|
39
|
+
|
|
40
|
+
const evalRunId = generateEvalRunId();
|
|
41
|
+
const evalRunDir = join(projectRoot, 'training', 'eval-runs', evalRunId);
|
|
42
|
+
await mkdir(evalRunDir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
const runManifest = {
|
|
45
|
+
eval_run_id: evalRunId,
|
|
46
|
+
created_at: new Date().toISOString(),
|
|
47
|
+
created_by: 'sdlab-eval-run-v1',
|
|
48
|
+
training_manifest_id: manifestId,
|
|
49
|
+
eval_pack_id: evalPackId,
|
|
50
|
+
source_export_id: manifest.source_export_id,
|
|
51
|
+
training_profile_id: manifest.training_profile_id,
|
|
52
|
+
adapter_target: manifest.adapter_target,
|
|
53
|
+
status: 'created',
|
|
54
|
+
scorecard: null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
await writeFile(join(evalRunDir, 'manifest.json'), JSON.stringify(runManifest, null, 2) + '\n');
|
|
58
|
+
|
|
59
|
+
return { evalRunId };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Score an eval run — compare generated outputs against eval pack tasks.
|
|
64
|
+
*
|
|
65
|
+
* Outputs are provided as a JSONL file where each line has:
|
|
66
|
+
* { record_id, generated_path, accept: true|false, notes? }
|
|
67
|
+
*
|
|
68
|
+
* @param {string} projectRoot
|
|
69
|
+
* @param {string} evalRunId
|
|
70
|
+
* @param {string} outputsPath — path to outputs index JSONL
|
|
71
|
+
* @returns {Promise<{scorecard: Object}>}
|
|
72
|
+
*/
|
|
73
|
+
export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
|
|
74
|
+
const evalRunDir = join(projectRoot, 'training', 'eval-runs', evalRunId);
|
|
75
|
+
const runManifest = JSON.parse(await readFile(join(evalRunDir, 'manifest.json'), 'utf-8'));
|
|
76
|
+
const config = loadProjectConfig(projectRoot);
|
|
77
|
+
|
|
78
|
+
// Load outputs
|
|
79
|
+
const outputsRaw = await readFile(outputsPath, 'utf-8');
|
|
80
|
+
const outputs = outputsRaw.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
81
|
+
|
|
82
|
+
// Copy outputs index into eval run
|
|
83
|
+
await writeFile(join(evalRunDir, 'output-index.jsonl'), outputsRaw);
|
|
84
|
+
|
|
85
|
+
// Load eval pack tasks
|
|
86
|
+
const evalPackDir = join(projectRoot, 'eval-packs', runManifest.eval_pack_id);
|
|
87
|
+
const taskFiles = {
|
|
88
|
+
'lane-coverage': 'lane-coverage.jsonl',
|
|
89
|
+
'forbidden-drift': 'forbidden-drift.jsonl',
|
|
90
|
+
'anchor-gold': 'anchor-gold.jsonl',
|
|
91
|
+
'subject-continuity': 'subject-continuity.jsonl',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const outputMap = new Map(outputs.map(o => [o.record_id, o]));
|
|
95
|
+
|
|
96
|
+
// Score each task type
|
|
97
|
+
const scores = {};
|
|
98
|
+
|
|
99
|
+
for (const [taskName, fileName] of Object.entries(taskFiles)) {
|
|
100
|
+
const taskPath = join(evalPackDir, fileName);
|
|
101
|
+
if (!existsSync(taskPath)) continue;
|
|
102
|
+
|
|
103
|
+
const taskEntries = (await readFile(taskPath, 'utf-8'))
|
|
104
|
+
.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
105
|
+
|
|
106
|
+
if (taskName === 'lane-coverage') {
|
|
107
|
+
scores.lane_coverage = scoreLaneCoverage(taskEntries, outputMap);
|
|
108
|
+
} else if (taskName === 'forbidden-drift') {
|
|
109
|
+
scores.forbidden_drift = scoreForbiddenDrift(taskEntries, outputMap);
|
|
110
|
+
} else if (taskName === 'anchor-gold') {
|
|
111
|
+
scores.anchor_gold = scoreAnchorGold(taskEntries, outputMap);
|
|
112
|
+
} else if (taskName === 'subject-continuity') {
|
|
113
|
+
scores.subject_continuity = scoreSubjectContinuity(taskEntries, outputMap);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Compute overall verdict
|
|
118
|
+
const allPassed = Object.values(scores).every(s => s.passed);
|
|
119
|
+
const overallScore = Object.values(scores).reduce((sum, s) => sum + (s.score || 0), 0) /
|
|
120
|
+
Math.max(Object.values(scores).length, 1);
|
|
121
|
+
|
|
122
|
+
const scorecard = {
|
|
123
|
+
eval_run_id: evalRunId,
|
|
124
|
+
scored_at: new Date().toISOString(),
|
|
125
|
+
overall_verdict: allPassed ? 'pass' : 'fail',
|
|
126
|
+
overall_score: +overallScore.toFixed(3),
|
|
127
|
+
output_count: outputs.length,
|
|
128
|
+
accepted_count: outputs.filter(o => o.accept).length,
|
|
129
|
+
rejected_count: outputs.filter(o => !o.accept).length,
|
|
130
|
+
tasks: scores,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Write scorecard
|
|
134
|
+
await writeFile(join(evalRunDir, 'scorecard.json'), JSON.stringify(scorecard, null, 2) + '\n');
|
|
135
|
+
|
|
136
|
+
// Write summary markdown
|
|
137
|
+
const summary = renderSummary(scorecard, runManifest);
|
|
138
|
+
await writeFile(join(evalRunDir, 'summary.md'), summary);
|
|
139
|
+
|
|
140
|
+
// Update manifest status
|
|
141
|
+
runManifest.status = 'scored';
|
|
142
|
+
runManifest.scorecard = scorecard;
|
|
143
|
+
await writeFile(join(evalRunDir, 'manifest.json'), JSON.stringify(runManifest, null, 2) + '\n');
|
|
144
|
+
|
|
145
|
+
return { scorecard };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function scoreLaneCoverage(tasks, outputMap) {
|
|
149
|
+
let covered = 0;
|
|
150
|
+
const lanes = new Set();
|
|
151
|
+
for (const task of tasks) {
|
|
152
|
+
lanes.add(task.lane);
|
|
153
|
+
const output = outputMap.get(task.record_id);
|
|
154
|
+
if (output?.accept) covered++;
|
|
155
|
+
}
|
|
156
|
+
const score = tasks.length > 0 ? covered / tasks.length : 0;
|
|
157
|
+
return {
|
|
158
|
+
task: 'lane_coverage',
|
|
159
|
+
total: tasks.length,
|
|
160
|
+
covered,
|
|
161
|
+
lanes_represented: lanes.size,
|
|
162
|
+
score: +score.toFixed(3),
|
|
163
|
+
passed: score >= 0.7,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function scoreForbiddenDrift(tasks, outputMap) {
|
|
168
|
+
// For forbidden drift, we check that rejected/borderline records are NOT accepted
|
|
169
|
+
let correctRejections = 0;
|
|
170
|
+
let falseAcceptances = 0;
|
|
171
|
+
for (const task of tasks) {
|
|
172
|
+
const output = outputMap.get(task.record_id);
|
|
173
|
+
if (!output || !output.accept) {
|
|
174
|
+
correctRejections++;
|
|
175
|
+
} else {
|
|
176
|
+
falseAcceptances++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const score = tasks.length > 0 ? correctRejections / tasks.length : 1;
|
|
180
|
+
return {
|
|
181
|
+
task: 'forbidden_drift',
|
|
182
|
+
total: tasks.length,
|
|
183
|
+
correct_rejections: correctRejections,
|
|
184
|
+
false_acceptances: falseAcceptances,
|
|
185
|
+
score: +score.toFixed(3),
|
|
186
|
+
passed: falseAcceptances === 0,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function scoreAnchorGold(tasks, outputMap) {
|
|
191
|
+
let matched = 0;
|
|
192
|
+
for (const task of tasks) {
|
|
193
|
+
const output = outputMap.get(task.record_id);
|
|
194
|
+
if (output?.accept) matched++;
|
|
195
|
+
}
|
|
196
|
+
const score = tasks.length > 0 ? matched / tasks.length : 0;
|
|
197
|
+
return {
|
|
198
|
+
task: 'anchor_gold',
|
|
199
|
+
total: tasks.length,
|
|
200
|
+
matched,
|
|
201
|
+
score: +score.toFixed(3),
|
|
202
|
+
passed: score >= 0.8,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function scoreSubjectContinuity(tasks, outputMap) {
|
|
207
|
+
let subjectsWithCoverage = 0;
|
|
208
|
+
for (const task of tasks) {
|
|
209
|
+
const recordIds = task.record_ids || [];
|
|
210
|
+
const accepted = recordIds.filter(id => outputMap.get(id)?.accept).length;
|
|
211
|
+
if (accepted >= 2) subjectsWithCoverage++;
|
|
212
|
+
}
|
|
213
|
+
const score = tasks.length > 0 ? subjectsWithCoverage / tasks.length : 0;
|
|
214
|
+
return {
|
|
215
|
+
task: 'subject_continuity',
|
|
216
|
+
total_subjects: tasks.length,
|
|
217
|
+
subjects_with_coverage: subjectsWithCoverage,
|
|
218
|
+
score: +score.toFixed(3),
|
|
219
|
+
passed: score >= 0.6,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderSummary(scorecard, manifest) {
|
|
224
|
+
const lines = [];
|
|
225
|
+
lines.push(`# Eval Run: ${scorecard.eval_run_id}`);
|
|
226
|
+
lines.push('');
|
|
227
|
+
lines.push(`**Training manifest:** ${manifest.training_manifest_id}`);
|
|
228
|
+
lines.push(`**Eval pack:** ${manifest.eval_pack_id}`);
|
|
229
|
+
lines.push(`**Scored:** ${scorecard.scored_at}`);
|
|
230
|
+
lines.push(`**Verdict:** ${scorecard.overall_verdict.toUpperCase()}`);
|
|
231
|
+
lines.push(`**Overall score:** ${(scorecard.overall_score * 100).toFixed(1)}%`);
|
|
232
|
+
lines.push('');
|
|
233
|
+
lines.push(`**Outputs:** ${scorecard.output_count} total, ${scorecard.accepted_count} accepted, ${scorecard.rejected_count} rejected`);
|
|
234
|
+
lines.push('');
|
|
235
|
+
lines.push('## Task scores');
|
|
236
|
+
lines.push('');
|
|
237
|
+
lines.push('| Task | Score | Passed |');
|
|
238
|
+
lines.push('|------|-------|--------|');
|
|
239
|
+
for (const [name, task] of Object.entries(scorecard.tasks)) {
|
|
240
|
+
lines.push(`| ${name} | ${(task.score * 100).toFixed(1)}% | ${task.passed ? 'YES' : 'NO'} |`);
|
|
241
|
+
}
|
|
242
|
+
lines.push('');
|
|
243
|
+
return lines.join('\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Load an eval run manifest.
|
|
248
|
+
*/
|
|
249
|
+
export async function loadEvalRun(projectRoot, evalRunId) {
|
|
250
|
+
const path = join(projectRoot, 'training', 'eval-runs', evalRunId, 'manifest.json');
|
|
251
|
+
if (!existsSync(path)) {
|
|
252
|
+
throw new Error(`Eval run "${evalRunId}" not found at ${path}`);
|
|
253
|
+
}
|
|
254
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* List all eval runs.
|
|
259
|
+
*/
|
|
260
|
+
export async function listEvalRuns(projectRoot) {
|
|
261
|
+
const dir = join(projectRoot, 'training', 'eval-runs');
|
|
262
|
+
if (!existsSync(dir)) return [];
|
|
263
|
+
|
|
264
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
265
|
+
const runs = [];
|
|
266
|
+
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
if (!entry.isDirectory() || !entry.name.startsWith('er-')) continue;
|
|
269
|
+
const manifestPath = join(dir, entry.name, 'manifest.json');
|
|
270
|
+
if (!existsSync(manifestPath)) continue;
|
|
271
|
+
try {
|
|
272
|
+
const data = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
273
|
+
runs.push({
|
|
274
|
+
id: data.eval_run_id,
|
|
275
|
+
created_at: data.created_at,
|
|
276
|
+
status: data.status,
|
|
277
|
+
manifest: data.training_manifest_id,
|
|
278
|
+
eval_pack: data.eval_pack_id,
|
|
279
|
+
verdict: data.scorecard?.overall_verdict || null,
|
|
280
|
+
});
|
|
281
|
+
} catch { /* skip */ }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return runs.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
285
|
+
}
|
package/lib/export.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export package builder — self-contained, reproducible dataset exports.
|
|
3
|
+
*
|
|
4
|
+
* An export package contains everything needed to use or rebuild the dataset:
|
|
5
|
+
* manifest, metadata, images (symlinked or copied), splits, card, and checksums.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, writeFile, mkdir, readdir, symlink, copyFile, stat } from 'node:fs/promises';
|
|
9
|
+
import { join, relative, resolve } from 'node:path';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
import { loadProjectConfig, loadExportProfile, detectLane, detectGroup } from './config.js';
|
|
13
|
+
import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint } from './snapshot.js';
|
|
14
|
+
import { loadSplit, loadSplitPartition } from './split.js';
|
|
15
|
+
import { loadRecord } from './records.js';
|
|
16
|
+
import { generateCard } from './card.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate an export ID: export-YYYYMMDD-HHMMSS-XXXX
|
|
20
|
+
*/
|
|
21
|
+
function generateExportId() {
|
|
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 `export-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute SHA-256 of a file.
|
|
30
|
+
*/
|
|
31
|
+
async function sha256File(filePath) {
|
|
32
|
+
const data = await readFile(filePath);
|
|
33
|
+
return createHash('sha256').update(data).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build an export package.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} projectRoot
|
|
40
|
+
* @param {string} snapshotId
|
|
41
|
+
* @param {string} splitId
|
|
42
|
+
* @param {Object} options — { profileId?, copy? }
|
|
43
|
+
* @returns {Promise<{exportId: string, recordCount: number, imageCount: number}>}
|
|
44
|
+
*/
|
|
45
|
+
export async function buildExport(projectRoot, snapshotId, splitId, options = {}) {
|
|
46
|
+
const config = loadProjectConfig(projectRoot);
|
|
47
|
+
const profile = loadExportProfile(projectRoot, options.profileId || null);
|
|
48
|
+
const snapshot = await loadSnapshot(projectRoot, snapshotId);
|
|
49
|
+
const split = await loadSplit(projectRoot, splitId);
|
|
50
|
+
|
|
51
|
+
// Load all included records
|
|
52
|
+
const included = await loadSnapshotIncluded(projectRoot, snapshotId);
|
|
53
|
+
const recordsDir = join(projectRoot, 'records');
|
|
54
|
+
|
|
55
|
+
const exportId = generateExportId();
|
|
56
|
+
const exportDir = join(projectRoot, 'exports', exportId);
|
|
57
|
+
await mkdir(exportDir, { recursive: true });
|
|
58
|
+
|
|
59
|
+
// Build metadata + collect image paths
|
|
60
|
+
const metadata = [];
|
|
61
|
+
const imagePaths = [];
|
|
62
|
+
const checksumEntries = [];
|
|
63
|
+
|
|
64
|
+
for (const entry of included) {
|
|
65
|
+
const record = await loadRecord(recordsDir, entry.record_id);
|
|
66
|
+
if (!record) continue;
|
|
67
|
+
|
|
68
|
+
const prompt = record.provenance?.prompt || '';
|
|
69
|
+
const lane = detectLane(record.id, prompt, config.lanes);
|
|
70
|
+
const group = detectGroup(record.id, prompt, config.terminology);
|
|
71
|
+
|
|
72
|
+
// Build metadata row with requested fields
|
|
73
|
+
const row = {};
|
|
74
|
+
for (const field of profile.metadata_fields) {
|
|
75
|
+
if (field === 'id') row.id = record.id;
|
|
76
|
+
else if (field === 'asset_path') row.asset_path = record.asset_path;
|
|
77
|
+
else if (field === 'provenance') row.provenance = record.provenance;
|
|
78
|
+
else if (field === 'judgment') row.judgment = record.judgment;
|
|
79
|
+
else if (field === 'canon') row.canon = record.canon;
|
|
80
|
+
else if (field === 'identity') row.identity = record.identity || null;
|
|
81
|
+
else if (field === 'lineage') row.lineage = record.lineage || null;
|
|
82
|
+
else if (record[field] !== undefined) row[field] = record[field];
|
|
83
|
+
}
|
|
84
|
+
row.detected_lane = lane;
|
|
85
|
+
row.detected_group = group;
|
|
86
|
+
metadata.push(row);
|
|
87
|
+
|
|
88
|
+
// Track image for linking
|
|
89
|
+
if (profile.include_images && record.asset_path) {
|
|
90
|
+
imagePaths.push({
|
|
91
|
+
record_id: record.id,
|
|
92
|
+
source: join(projectRoot, record.asset_path),
|
|
93
|
+
filename: record.asset_path.split('/').pop(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Write metadata.jsonl
|
|
99
|
+
const metadataPath = join(exportDir, 'metadata.jsonl');
|
|
100
|
+
await writeFile(metadataPath, metadata.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
101
|
+
|
|
102
|
+
// Images directory
|
|
103
|
+
if (profile.include_images && imagePaths.length > 0) {
|
|
104
|
+
const imagesDir = join(exportDir, 'images');
|
|
105
|
+
await mkdir(imagesDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
let linked = 0;
|
|
108
|
+
for (const img of imagePaths) {
|
|
109
|
+
const dest = join(imagesDir, img.filename);
|
|
110
|
+
if (existsSync(img.source)) {
|
|
111
|
+
try {
|
|
112
|
+
if (options.copy) {
|
|
113
|
+
await copyFile(img.source, dest);
|
|
114
|
+
} else {
|
|
115
|
+
const relPath = relative(imagesDir, img.source);
|
|
116
|
+
await symlink(relPath, dest);
|
|
117
|
+
}
|
|
118
|
+
linked++;
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip individual failures (e.g. duplicate filenames)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Splits
|
|
127
|
+
if (profile.include_splits) {
|
|
128
|
+
const splitsDir = join(exportDir, 'splits');
|
|
129
|
+
await mkdir(splitsDir, { recursive: true });
|
|
130
|
+
|
|
131
|
+
for (const partition of ['train', 'val', 'test']) {
|
|
132
|
+
const entries = await loadSplitPartition(projectRoot, splitId, partition);
|
|
133
|
+
await writeFile(
|
|
134
|
+
join(splitsDir, `${partition}.jsonl`),
|
|
135
|
+
entries.map(e => JSON.stringify(e)).join('\n') + '\n'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Dataset card
|
|
141
|
+
if (profile.include_card) {
|
|
142
|
+
const { markdown, json } = await generateCard(projectRoot, snapshotId, splitId);
|
|
143
|
+
await writeFile(join(exportDir, 'dataset-card.md'), markdown);
|
|
144
|
+
await writeFile(join(exportDir, 'dataset-card.json'), JSON.stringify(json, null, 2) + '\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Checksums
|
|
148
|
+
if (profile.include_checksums) {
|
|
149
|
+
// Checksum all files in the export dir (except checksums.txt itself)
|
|
150
|
+
const allFiles = await collectFiles(exportDir);
|
|
151
|
+
for (const filePath of allFiles.sort()) {
|
|
152
|
+
const relPath = relative(exportDir, filePath);
|
|
153
|
+
if (relPath === 'checksums.txt') continue;
|
|
154
|
+
const hash = await sha256File(filePath);
|
|
155
|
+
checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
|
|
156
|
+
}
|
|
157
|
+
await writeFile(join(exportDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Manifest (written last — references everything)
|
|
161
|
+
const manifest = {
|
|
162
|
+
export_id: exportId,
|
|
163
|
+
created_at: new Date().toISOString(),
|
|
164
|
+
created_by: 'sdlab-export-v1',
|
|
165
|
+
project: config.meta.name,
|
|
166
|
+
snapshot_id: snapshotId,
|
|
167
|
+
split_id: splitId,
|
|
168
|
+
config_fingerprint: computeConfigFingerprint(projectRoot),
|
|
169
|
+
profile,
|
|
170
|
+
counts: {
|
|
171
|
+
records: metadata.length,
|
|
172
|
+
images: imagePaths.filter(i => existsSync(i.source)).length,
|
|
173
|
+
checksums: checksumEntries.length,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
await writeFile(join(exportDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
177
|
+
|
|
178
|
+
// Summary
|
|
179
|
+
const summary = {
|
|
180
|
+
export_id: exportId,
|
|
181
|
+
snapshot_id: snapshotId,
|
|
182
|
+
split_id: splitId,
|
|
183
|
+
record_count: metadata.length,
|
|
184
|
+
image_count: manifest.counts.images,
|
|
185
|
+
partitions: {
|
|
186
|
+
train: split.counts.train,
|
|
187
|
+
val: split.counts.val,
|
|
188
|
+
test: split.counts.test,
|
|
189
|
+
},
|
|
190
|
+
config_fingerprint: manifest.config_fingerprint,
|
|
191
|
+
};
|
|
192
|
+
await writeFile(join(exportDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
exportId,
|
|
196
|
+
recordCount: metadata.length,
|
|
197
|
+
imageCount: manifest.counts.images,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Recursively collect all files in a directory.
|
|
203
|
+
*/
|
|
204
|
+
async function collectFiles(dir, base = dir) {
|
|
205
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
206
|
+
const files = [];
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
const full = join(dir, entry.name);
|
|
209
|
+
if (entry.isDirectory()) {
|
|
210
|
+
files.push(...await collectFiles(full, base));
|
|
211
|
+
} else {
|
|
212
|
+
files.push(full);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return files;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* List all exports in a project.
|
|
220
|
+
*/
|
|
221
|
+
export async function listExports(projectRoot) {
|
|
222
|
+
const exportsDir = join(projectRoot, 'exports');
|
|
223
|
+
if (!existsSync(exportsDir)) return [];
|
|
224
|
+
|
|
225
|
+
const entries = await readdir(exportsDir, { withFileTypes: true });
|
|
226
|
+
const exports = [];
|
|
227
|
+
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (!entry.isDirectory() || !entry.name.startsWith('export-')) continue;
|
|
230
|
+
const manifestPath = join(exportsDir, entry.name, 'manifest.json');
|
|
231
|
+
if (!existsSync(manifestPath)) continue;
|
|
232
|
+
try {
|
|
233
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
234
|
+
exports.push({
|
|
235
|
+
id: manifest.export_id,
|
|
236
|
+
created_at: manifest.created_at,
|
|
237
|
+
snapshot_id: manifest.snapshot_id,
|
|
238
|
+
split_id: manifest.split_id,
|
|
239
|
+
records: manifest.counts.records,
|
|
240
|
+
images: manifest.counts.images,
|
|
241
|
+
});
|
|
242
|
+
} catch {
|
|
243
|
+
// Skip malformed
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return exports.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Load an export manifest.
|
|
252
|
+
*/
|
|
253
|
+
export async function loadExport(projectRoot, exportId) {
|
|
254
|
+
const path = join(projectRoot, 'exports', exportId, 'manifest.json');
|
|
255
|
+
if (!existsSync(path)) {
|
|
256
|
+
throw new Error(`Export "${exportId}" not found at ${path}`);
|
|
257
|
+
}
|
|
258
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
259
|
+
}
|