@mcptoolshop/style-dataset-lab 2.0.0 → 2.1.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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Canon-aware eval pack builder.
3
+ *
4
+ * Eval packs are curated test instruments that verify a model's
5
+ * relationship with project canon. Four task types:
6
+ *
7
+ * 1. Lane coverage — representative approved records per lane
8
+ * 2. Forbidden drift — rejected/borderline records paired with violated rules
9
+ * 3. Anchor/gold — highest pass-ratio records per faction
10
+ * 4. Subject continuity — same-subject records for identity testing
11
+ */
12
+
13
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { existsSync } from 'node:fs';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
+ import { loadRecord } from './records.js';
19
+
20
+ /**
21
+ * Generate an eval-pack ID: eval-YYYYMMDD-HHMMSS-XXXX
22
+ */
23
+ function generateEvalId() {
24
+ const now = new Date();
25
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
26
+ const rand = randomBytes(2).toString('hex');
27
+ return `eval-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
28
+ }
29
+
30
+ /**
31
+ * Build an eval pack from project records.
32
+ *
33
+ * @param {string} projectRoot
34
+ * @param {Object} options — { maxPerLane?, maxPerFaction?, maxDrift?, maxContinuity? }
35
+ * @returns {Promise<{evalId: string, tasks: Object}>}
36
+ */
37
+ export async function buildEvalPack(projectRoot, options = {}) {
38
+ const config = loadProjectConfig(projectRoot);
39
+ const recordsDir = join(projectRoot, 'records');
40
+ const files = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
41
+
42
+ const maxPerLane = options.maxPerLane || 5;
43
+ const maxPerFaction = options.maxPerFaction || 3;
44
+ const maxDrift = options.maxDrift || 20;
45
+ const maxContinuity = options.maxContinuity || 10;
46
+
47
+ // Load all records
48
+ const records = [];
49
+ for (const file of files) {
50
+ const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
51
+ records.push(record);
52
+ }
53
+
54
+ // Task 1: Lane coverage
55
+ const laneCoverage = buildLaneCoverage(records, config, maxPerLane);
56
+
57
+ // Task 2: Forbidden drift
58
+ const forbiddenDrift = buildForbiddenDrift(records, config, maxDrift);
59
+
60
+ // Task 3: Anchor/gold
61
+ const anchorGold = buildAnchorGold(records, config, maxPerFaction);
62
+
63
+ // Task 4: Subject continuity
64
+ const subjectContinuity = buildSubjectContinuity(records, maxContinuity);
65
+
66
+ // Write eval pack
67
+ const evalId = generateEvalId();
68
+ const evalDir = join(projectRoot, 'eval-packs', evalId);
69
+ await mkdir(evalDir, { recursive: true });
70
+
71
+ const manifest = {
72
+ eval_id: evalId,
73
+ created_at: new Date().toISOString(),
74
+ created_by: 'sdlab-eval-v1',
75
+ project: config.meta.name,
76
+ task_counts: {
77
+ lane_coverage: laneCoverage.length,
78
+ forbidden_drift: forbiddenDrift.length,
79
+ anchor_gold: anchorGold.length,
80
+ subject_continuity: subjectContinuity.length,
81
+ },
82
+ total_records: laneCoverage.length + forbiddenDrift.length +
83
+ anchorGold.length + subjectContinuity.length,
84
+ };
85
+
86
+ await writeFile(join(evalDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
87
+ await writeFile(join(evalDir, 'lane-coverage.jsonl'), laneCoverage.map(r => JSON.stringify(r)).join('\n') + '\n');
88
+ await writeFile(join(evalDir, 'forbidden-drift.jsonl'), forbiddenDrift.map(r => JSON.stringify(r)).join('\n') + '\n');
89
+ await writeFile(join(evalDir, 'anchor-gold.jsonl'), anchorGold.map(r => JSON.stringify(r)).join('\n') + '\n');
90
+ await writeFile(join(evalDir, 'subject-continuity.jsonl'), subjectContinuity.map(r => JSON.stringify(r)).join('\n') + '\n');
91
+
92
+ return {
93
+ evalId,
94
+ tasks: manifest.task_counts,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Task 1: Lane coverage — best approved record per lane.
100
+ */
101
+ function buildLaneCoverage(records, config, maxPerLane) {
102
+ const byLane = new Map();
103
+
104
+ for (const record of records) {
105
+ if (record.judgment?.status !== 'approved') continue;
106
+ if (!record.canon?.assertions?.length) continue;
107
+
108
+ const prompt = record.provenance?.prompt || '';
109
+ const lane = detectLane(record.id, prompt, config.lanes);
110
+ if (!byLane.has(lane)) byLane.set(lane, []);
111
+ byLane.get(lane).push(record);
112
+ }
113
+
114
+ const result = [];
115
+ for (const [lane, recs] of [...byLane].sort((a, b) => a[0].localeCompare(b[0]))) {
116
+ // Sort by pass ratio descending
117
+ const sorted = recs.sort((a, b) => {
118
+ const ratioA = a.canon.assertion_count > 0 ? a.canon.pass_count / a.canon.assertion_count : 0;
119
+ const ratioB = b.canon.assertion_count > 0 ? b.canon.pass_count / b.canon.assertion_count : 0;
120
+ return ratioB - ratioA;
121
+ });
122
+
123
+ for (const record of sorted.slice(0, maxPerLane)) {
124
+ result.push({
125
+ task: 'lane_coverage',
126
+ record_id: record.id,
127
+ lane,
128
+ pass_ratio: record.canon.assertion_count > 0
129
+ ? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : 0,
130
+ asset_path: record.asset_path,
131
+ });
132
+ }
133
+ }
134
+
135
+ return result;
136
+ }
137
+
138
+ /**
139
+ * Task 2: Forbidden drift — rejected/borderline records with violated rules.
140
+ */
141
+ function buildForbiddenDrift(records, config, maxDrift) {
142
+ const candidates = [];
143
+
144
+ for (const record of records) {
145
+ const status = record.judgment?.status;
146
+ if (status !== 'rejected' && status !== 'borderline') continue;
147
+
148
+ // Collect failure modes from judgment
149
+ const failureModes = record.judgment?.failure_modes || [];
150
+ // Collect failed canon assertions
151
+ const failedAssertions = (record.canon?.assertions || [])
152
+ .filter(a => a.verdict === 'fail')
153
+ .map(a => a.rule_id || a.rule);
154
+
155
+ const violations = [...new Set([...failureModes, ...failedAssertions])];
156
+ if (violations.length === 0 && !record.judgment?.explanation) continue;
157
+
158
+ candidates.push({
159
+ task: 'forbidden_drift',
160
+ record_id: record.id,
161
+ status,
162
+ violations,
163
+ explanation: record.judgment?.explanation || null,
164
+ asset_path: record.asset_path,
165
+ });
166
+ }
167
+
168
+ // Sort by number of violations descending (most instructive first)
169
+ candidates.sort((a, b) => b.violations.length - a.violations.length);
170
+ return candidates.slice(0, maxDrift);
171
+ }
172
+
173
+ /**
174
+ * Task 3: Anchor/gold — highest pass-ratio records per faction.
175
+ */
176
+ function buildAnchorGold(records, config, maxPerFaction) {
177
+ const byFaction = new Map();
178
+
179
+ for (const record of records) {
180
+ if (record.judgment?.status !== 'approved') continue;
181
+ if (!record.canon?.assertions?.length) continue;
182
+
183
+ const prompt = record.provenance?.prompt || '';
184
+ const faction = detectGroup(record.id, prompt, config.terminology) || 'unknown';
185
+ if (!byFaction.has(faction)) byFaction.set(faction, []);
186
+ byFaction.get(faction).push(record);
187
+ }
188
+
189
+ const result = [];
190
+ for (const [faction, recs] of [...byFaction].sort((a, b) => a[0].localeCompare(b[0]))) {
191
+ const sorted = recs.sort((a, b) => {
192
+ const ratioA = a.canon.assertion_count > 0 ? a.canon.pass_count / a.canon.assertion_count : 0;
193
+ const ratioB = b.canon.assertion_count > 0 ? b.canon.pass_count / b.canon.assertion_count : 0;
194
+ return ratioB - ratioA;
195
+ });
196
+
197
+ for (const record of sorted.slice(0, maxPerFaction)) {
198
+ result.push({
199
+ task: 'anchor_gold',
200
+ record_id: record.id,
201
+ faction,
202
+ pass_ratio: record.canon.assertion_count > 0
203
+ ? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : 0,
204
+ assertion_count: record.canon.assertion_count,
205
+ asset_path: record.asset_path,
206
+ });
207
+ }
208
+ }
209
+
210
+ return result;
211
+ }
212
+
213
+ /**
214
+ * Task 4: Subject continuity — groups of same-subject records for identity testing.
215
+ */
216
+ function buildSubjectContinuity(records, maxGroups) {
217
+ const bySubject = new Map();
218
+
219
+ for (const record of records) {
220
+ const subject = record.identity?.subject_name;
221
+ if (!subject) continue;
222
+ if (!bySubject.has(subject)) bySubject.set(subject, []);
223
+ bySubject.get(subject).push(record);
224
+ }
225
+
226
+ const result = [];
227
+ // Sort subjects by record count descending (richest clusters first)
228
+ const sorted = [...bySubject].sort((a, b) => b[1].length - a[1].length);
229
+
230
+ for (const [subject, recs] of sorted.slice(0, maxGroups)) {
231
+ result.push({
232
+ task: 'subject_continuity',
233
+ subject_name: subject,
234
+ record_count: recs.length,
235
+ record_ids: recs.map(r => r.id).sort(),
236
+ subject_type: recs[0].identity?.subject_type || 'unknown',
237
+ faction: recs[0].identity?.faction || null,
238
+ });
239
+ }
240
+
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * List all eval packs in a project.
246
+ */
247
+ export async function listEvalPacks(projectRoot) {
248
+ const evalDir = join(projectRoot, 'eval-packs');
249
+ if (!existsSync(evalDir)) return [];
250
+
251
+ const entries = await readdir(evalDir, { withFileTypes: true });
252
+ const packs = [];
253
+
254
+ for (const entry of entries) {
255
+ if (!entry.isDirectory() || !entry.name.startsWith('eval-')) continue;
256
+ const manifestPath = join(evalDir, entry.name, 'manifest.json');
257
+ if (!existsSync(manifestPath)) continue;
258
+ try {
259
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
260
+ packs.push({
261
+ id: manifest.eval_id,
262
+ created_at: manifest.created_at,
263
+ total: manifest.total_records,
264
+ tasks: manifest.task_counts,
265
+ });
266
+ } catch {
267
+ // Skip malformed
268
+ }
269
+ }
270
+
271
+ return packs.sort((a, b) => a.created_at.localeCompare(b.created_at));
272
+ }
273
+
274
+ /**
275
+ * Load an eval pack manifest.
276
+ */
277
+ export async function loadEvalPack(projectRoot, evalId) {
278
+ const path = join(projectRoot, 'eval-packs', evalId, 'manifest.json');
279
+ if (!existsSync(path)) {
280
+ throw new Error(`Eval pack "${evalId}" not found at ${path}`);
281
+ }
282
+ return JSON.parse(await readFile(path, 'utf-8'));
283
+ }
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
+ }