@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
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implementation example packs.
|
|
3
|
+
*
|
|
4
|
+
* Shows how to use the trained asset in practice: prompt examples,
|
|
5
|
+
* lane-targeted examples, subject continuity examples, known failure
|
|
6
|
+
* cases, and re-ingest guidance.
|
|
7
|
+
*
|
|
8
|
+
* Every implementation pack is tied to a training manifest.
|
|
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 { randomBytes } from 'node:crypto';
|
|
15
|
+
import { loadTrainingManifest } from './training-manifests.js';
|
|
16
|
+
import { loadTrainingProfile } from './training-profiles.js';
|
|
17
|
+
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
18
|
+
import { loadSplitPartition } from './split.js';
|
|
19
|
+
import { loadRecord } from './records.js';
|
|
20
|
+
|
|
21
|
+
function generateImplId() {
|
|
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 `impl-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build an implementation pack from a training manifest.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} projectRoot
|
|
32
|
+
* @param {string} manifestId
|
|
33
|
+
* @returns {Promise<{implId: string, prompts: number, failures: number, subjects: number}>}
|
|
34
|
+
*/
|
|
35
|
+
export async function buildImplementationPack(projectRoot, manifestId) {
|
|
36
|
+
const manifest = await loadTrainingManifest(projectRoot, manifestId);
|
|
37
|
+
const profile = await loadTrainingProfile(projectRoot, manifest.training_profile_id);
|
|
38
|
+
const config = loadProjectConfig(projectRoot);
|
|
39
|
+
const recordsDir = join(projectRoot, 'records');
|
|
40
|
+
|
|
41
|
+
// Load test partition records (best for implementation examples)
|
|
42
|
+
const testEntries = await loadSplitPartition(projectRoot, manifest.source_split_id, 'test');
|
|
43
|
+
|
|
44
|
+
// Also load some train records for broader coverage
|
|
45
|
+
const trainEntries = await loadSplitPartition(projectRoot, manifest.source_split_id, 'train');
|
|
46
|
+
|
|
47
|
+
// Build prompt examples — per-lane best records
|
|
48
|
+
const promptExamples = [];
|
|
49
|
+
const laneExamples = new Map();
|
|
50
|
+
|
|
51
|
+
for (const entry of [...testEntries, ...trainEntries.slice(0, 50)]) {
|
|
52
|
+
const record = await loadRecord(recordsDir, entry.record_id);
|
|
53
|
+
if (!record) continue;
|
|
54
|
+
if (record.judgment?.status !== 'approved') continue;
|
|
55
|
+
|
|
56
|
+
const prompt = record.provenance?.prompt || '';
|
|
57
|
+
const lane = detectLane(record.id, prompt, config.lanes);
|
|
58
|
+
const group = detectGroup(record.id, prompt, config.terminology);
|
|
59
|
+
|
|
60
|
+
// Filter by profile lanes
|
|
61
|
+
if (profile.eligible_lanes.length > 0 && !profile.eligible_lanes.includes(lane)) continue;
|
|
62
|
+
|
|
63
|
+
if (!laneExamples.has(lane)) laneExamples.set(lane, []);
|
|
64
|
+
if (laneExamples.get(lane).length < 3) {
|
|
65
|
+
laneExamples.get(lane).push(record);
|
|
66
|
+
|
|
67
|
+
const passRatio = record.canon?.assertion_count > 0
|
|
68
|
+
? record.canon.pass_count / record.canon.assertion_count : 0;
|
|
69
|
+
|
|
70
|
+
promptExamples.push({
|
|
71
|
+
record_id: record.id,
|
|
72
|
+
lane,
|
|
73
|
+
group: group || 'unknown',
|
|
74
|
+
prompt: prompt.slice(0, 500),
|
|
75
|
+
pass_ratio: +passRatio.toFixed(3),
|
|
76
|
+
asset_path: record.asset_path,
|
|
77
|
+
usage_note: `Use as ${lane} reference for ${group || 'general'} style`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build known failure cases from rejected records
|
|
83
|
+
const knownFailures = [];
|
|
84
|
+
const allFiles = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
|
|
85
|
+
let failCount = 0;
|
|
86
|
+
|
|
87
|
+
for (const file of allFiles) {
|
|
88
|
+
if (failCount >= 10) break;
|
|
89
|
+
const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
|
|
90
|
+
if (record.judgment?.status !== 'rejected') continue;
|
|
91
|
+
|
|
92
|
+
const prompt = record.provenance?.prompt || '';
|
|
93
|
+
const lane = detectLane(record.id, prompt, config.lanes);
|
|
94
|
+
if (profile.eligible_lanes.length > 0 && !profile.eligible_lanes.includes(lane)) continue;
|
|
95
|
+
|
|
96
|
+
const failureModes = record.judgment?.failure_modes || [];
|
|
97
|
+
if (failureModes.length === 0 && !record.judgment?.explanation) continue;
|
|
98
|
+
|
|
99
|
+
knownFailures.push({
|
|
100
|
+
record_id: record.id,
|
|
101
|
+
lane,
|
|
102
|
+
failure_modes: failureModes,
|
|
103
|
+
explanation: record.judgment?.explanation || null,
|
|
104
|
+
lesson: `Avoid: ${failureModes.join(', ') || record.judgment?.explanation?.slice(0, 100)}`,
|
|
105
|
+
});
|
|
106
|
+
failCount++;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Build subject continuity examples
|
|
110
|
+
const subjectExamples = [];
|
|
111
|
+
const bySubject = new Map();
|
|
112
|
+
|
|
113
|
+
for (const entry of [...testEntries, ...trainEntries]) {
|
|
114
|
+
const record = await loadRecord(recordsDir, entry.record_id);
|
|
115
|
+
if (!record?.identity?.subject_name) continue;
|
|
116
|
+
const name = record.identity.subject_name;
|
|
117
|
+
if (!bySubject.has(name)) bySubject.set(name, []);
|
|
118
|
+
bySubject.get(name).push(record);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const [name, records] of [...bySubject].sort((a, b) => b[1].length - a[1].length).slice(0, 5)) {
|
|
122
|
+
subjectExamples.push({
|
|
123
|
+
subject_name: name,
|
|
124
|
+
record_count: records.length,
|
|
125
|
+
record_ids: records.map(r => r.id).sort(),
|
|
126
|
+
faction: records[0].identity?.faction || null,
|
|
127
|
+
note: `${records.length} views of ${name} — use to verify identity consistency`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build expected behaviors
|
|
132
|
+
const expectedBehaviors = {
|
|
133
|
+
style_markers: buildStyleMarkers(profile, config),
|
|
134
|
+
forbidden_patterns: buildForbiddenPatterns(config),
|
|
135
|
+
quality_thresholds: config.rubric.thresholds || {},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Write implementation pack
|
|
139
|
+
const implId = generateImplId();
|
|
140
|
+
const implDir = join(projectRoot, 'training', 'implementations', implId);
|
|
141
|
+
await mkdir(implDir, { recursive: true });
|
|
142
|
+
|
|
143
|
+
const implManifest = {
|
|
144
|
+
implementation_pack_id: implId,
|
|
145
|
+
created_at: new Date().toISOString(),
|
|
146
|
+
created_by: 'sdlab-implementation-v1',
|
|
147
|
+
training_manifest_id: manifestId,
|
|
148
|
+
training_profile_id: manifest.training_profile_id,
|
|
149
|
+
counts: {
|
|
150
|
+
prompt_examples: promptExamples.length,
|
|
151
|
+
known_failures: knownFailures.length,
|
|
152
|
+
subject_examples: subjectExamples.length,
|
|
153
|
+
lanes_covered: laneExamples.size,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
await writeFile(join(implDir, 'manifest.json'), JSON.stringify(implManifest, null, 2) + '\n');
|
|
158
|
+
await writeFile(join(implDir, 'prompts.jsonl'), promptExamples.map(p => JSON.stringify(p)).join('\n') + '\n');
|
|
159
|
+
await writeFile(join(implDir, 'known-failures.json'), JSON.stringify(knownFailures, null, 2) + '\n');
|
|
160
|
+
await writeFile(join(implDir, 'expected-behaviors.json'), JSON.stringify(expectedBehaviors, null, 2) + '\n');
|
|
161
|
+
|
|
162
|
+
if (subjectExamples.length > 0) {
|
|
163
|
+
await writeFile(join(implDir, 'subject-continuity.json'), JSON.stringify(subjectExamples, null, 2) + '\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Write eval tasks JSONL — tasks that can be run against generated outputs
|
|
167
|
+
const evalTasks = promptExamples.map(p => ({
|
|
168
|
+
task: 'generate_and_compare',
|
|
169
|
+
prompt: p.prompt,
|
|
170
|
+
lane: p.lane,
|
|
171
|
+
reference_record: p.record_id,
|
|
172
|
+
expected: 'style-consistent output matching canon',
|
|
173
|
+
}));
|
|
174
|
+
await writeFile(join(implDir, 'eval-tasks.jsonl'), evalTasks.map(t => JSON.stringify(t)).join('\n') + '\n');
|
|
175
|
+
|
|
176
|
+
// Write reingest guide
|
|
177
|
+
const reingestGuide = generateReingestGuide(profile, manifest);
|
|
178
|
+
await writeFile(join(implDir, 'reingest-guide.md'), reingestGuide);
|
|
179
|
+
|
|
180
|
+
// Write README
|
|
181
|
+
const readme = generateImplReadme(implManifest, profile, manifest);
|
|
182
|
+
await writeFile(join(implDir, 'README.md'), readme);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
implId,
|
|
186
|
+
prompts: promptExamples.length,
|
|
187
|
+
failures: knownFailures.length,
|
|
188
|
+
subjects: subjectExamples.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildStyleMarkers(profile, config) {
|
|
193
|
+
const markers = [];
|
|
194
|
+
const constitution = config.constitution;
|
|
195
|
+
if (constitution?.rules) {
|
|
196
|
+
for (const rule of constitution.rules.slice(0, 10)) {
|
|
197
|
+
markers.push({ rule_id: rule.id, summary: rule.summary || rule.id });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return markers;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildForbiddenPatterns(config) {
|
|
204
|
+
const forbidden = [];
|
|
205
|
+
const rubric = config.rubric;
|
|
206
|
+
if (rubric?.failure_to_rules) {
|
|
207
|
+
for (const [mode, rules] of Object.entries(rubric.failure_to_rules)) {
|
|
208
|
+
forbidden.push({ failure_mode: mode, linked_rules: rules });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return forbidden;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function generateReingestGuide(profile, manifest) {
|
|
215
|
+
return `# Re-ingest Guide
|
|
216
|
+
|
|
217
|
+
## When to re-ingest
|
|
218
|
+
|
|
219
|
+
After using the trained ${profile.asset_type} (${profile.label}) to generate new images,
|
|
220
|
+
accepted outputs should be re-ingested into the project as new records.
|
|
221
|
+
|
|
222
|
+
## How to re-ingest
|
|
223
|
+
|
|
224
|
+
\`\`\`bash
|
|
225
|
+
sdlab reingest generated --project <name> --source <outputs-dir>
|
|
226
|
+
\`\`\`
|
|
227
|
+
|
|
228
|
+
## Rules
|
|
229
|
+
|
|
230
|
+
1. Generated outputs enter as new records with \`provenance.source: "generated"\`
|
|
231
|
+
2. They must go through normal curation (\`sdlab curate\`)
|
|
232
|
+
3. They must be canon-bound (\`sdlab bind\`)
|
|
233
|
+
4. No bypass around review — generated work is judged like everything else
|
|
234
|
+
5. Accepted re-ingested records become eligible for future snapshots
|
|
235
|
+
|
|
236
|
+
## Provenance fields
|
|
237
|
+
|
|
238
|
+
Re-ingested records carry:
|
|
239
|
+
|
|
240
|
+
- \`provenance.source\`: \`"generated"\`
|
|
241
|
+
- \`provenance.training_manifest_id\`: \`"${manifest.training_manifest_id}"\`
|
|
242
|
+
- \`provenance.training_profile_id\`: \`"${manifest.training_profile_id}"\`
|
|
243
|
+
- \`provenance.adapter_target\`: \`"${manifest.adapter_target}"\`
|
|
244
|
+
- \`provenance.base_model\`: \`"${manifest.base_model || '(not specified)'}"\`
|
|
245
|
+
`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function generateImplReadme(impl, profile, manifest) {
|
|
249
|
+
return `# Implementation Pack: ${impl.implementation_pack_id}
|
|
250
|
+
|
|
251
|
+
**Profile:** ${profile.label} (${profile.profile_id})
|
|
252
|
+
**Training manifest:** ${manifest.training_manifest_id}
|
|
253
|
+
**Created:** ${impl.created_at}
|
|
254
|
+
|
|
255
|
+
## Contents
|
|
256
|
+
|
|
257
|
+
| File | Description |
|
|
258
|
+
|------|-------------|
|
|
259
|
+
| prompts.jsonl | ${impl.counts.prompt_examples} prompt examples across ${impl.counts.lanes_covered} lanes |
|
|
260
|
+
| known-failures.json | ${impl.counts.known_failures} documented failure cases to avoid |
|
|
261
|
+
| expected-behaviors.json | Style markers and forbidden patterns from constitution |
|
|
262
|
+
| subject-continuity.json | ${impl.counts.subject_examples} named-subject groups for identity testing |
|
|
263
|
+
| eval-tasks.jsonl | Generate-and-compare tasks for automated evaluation |
|
|
264
|
+
| reingest-guide.md | How to re-ingest accepted generated outputs |
|
|
265
|
+
|
|
266
|
+
## Usage
|
|
267
|
+
|
|
268
|
+
1. Load the trained ${profile.asset_type} with the prompt strategy from the profile
|
|
269
|
+
2. Use prompts.jsonl as starting points for each lane
|
|
270
|
+
3. Check generated outputs against expected-behaviors.json
|
|
271
|
+
4. Compare with known-failures.json to catch common drift
|
|
272
|
+
5. For named subjects, verify against subject-continuity.json
|
|
273
|
+
6. Re-ingest accepted outputs per reingest-guide.md
|
|
274
|
+
`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* List all implementation packs.
|
|
279
|
+
*/
|
|
280
|
+
export async function listImplementationPacks(projectRoot) {
|
|
281
|
+
const dir = join(projectRoot, 'training', 'implementations');
|
|
282
|
+
if (!existsSync(dir)) return [];
|
|
283
|
+
|
|
284
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
285
|
+
const packs = [];
|
|
286
|
+
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (!entry.isDirectory() || !entry.name.startsWith('impl-')) continue;
|
|
289
|
+
const manifestPath = join(dir, entry.name, 'manifest.json');
|
|
290
|
+
if (!existsSync(manifestPath)) continue;
|
|
291
|
+
try {
|
|
292
|
+
const data = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
293
|
+
packs.push({
|
|
294
|
+
id: data.implementation_pack_id,
|
|
295
|
+
created_at: data.created_at,
|
|
296
|
+
manifest: data.training_manifest_id,
|
|
297
|
+
profile: data.training_profile_id,
|
|
298
|
+
prompts: data.counts.prompt_examples,
|
|
299
|
+
failures: data.counts.known_failures,
|
|
300
|
+
});
|
|
301
|
+
} catch { /* skip */ }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return packs.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Load an implementation pack manifest.
|
|
309
|
+
*/
|
|
310
|
+
export async function loadImplementationPack(projectRoot, implId) {
|
|
311
|
+
const path = join(projectRoot, 'training', 'implementations', implId, 'manifest.json');
|
|
312
|
+
if (!existsSync(path)) {
|
|
313
|
+
throw new Error(`Implementation pack "${implId}" not found at ${path}`);
|
|
314
|
+
}
|
|
315
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
316
|
+
}
|
package/lib/reingest.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-ingest engine for generated outputs.
|
|
3
|
+
*
|
|
4
|
+
* Accepted generated outputs re-enter the project as new records
|
|
5
|
+
* with provenance fields linking back to the training manifest.
|
|
6
|
+
* No bypass around review — generated work must be curated and
|
|
7
|
+
* canon-bound like everything else.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
11
|
+
import { join, basename, extname } from 'node:path';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { loadTrainingManifest } from './training-manifests.js';
|
|
14
|
+
import { buildBaseRecord } from './records.js';
|
|
15
|
+
|
|
16
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Re-ingest generated outputs from a directory into the project.
|
|
20
|
+
*
|
|
21
|
+
* Each image in the source directory becomes a new record with:
|
|
22
|
+
* - provenance.source = "generated"
|
|
23
|
+
* - provenance.training_manifest_id = <manifest-id>
|
|
24
|
+
* - judgment = null (must be reviewed)
|
|
25
|
+
* - canon = null (must be bound)
|
|
26
|
+
*
|
|
27
|
+
* @param {string} projectRoot
|
|
28
|
+
* @param {string} sourcePath — directory containing generated images
|
|
29
|
+
* @param {string} manifestId — training manifest that produced them
|
|
30
|
+
* @param {Object} options — { dryRun? }
|
|
31
|
+
* @returns {Promise<{created: number, skipped: number, records: string[]}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function reingestGenerated(projectRoot, sourcePath, manifestId, options = {}) {
|
|
34
|
+
const manifest = await loadTrainingManifest(projectRoot, manifestId);
|
|
35
|
+
const recordsDir = join(projectRoot, 'records');
|
|
36
|
+
|
|
37
|
+
// Find image files in source directory
|
|
38
|
+
const entries = await readdir(sourcePath);
|
|
39
|
+
const imageFiles = entries
|
|
40
|
+
.filter(f => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
|
|
41
|
+
.sort();
|
|
42
|
+
|
|
43
|
+
const created = [];
|
|
44
|
+
const skipped = [];
|
|
45
|
+
|
|
46
|
+
for (const file of imageFiles) {
|
|
47
|
+
const stem = basename(file, extname(file));
|
|
48
|
+
const recordId = `gen_${stem}`;
|
|
49
|
+
const recordPath = join(recordsDir, `${recordId}.json`);
|
|
50
|
+
|
|
51
|
+
// Skip if record already exists
|
|
52
|
+
if (existsSync(recordPath)) {
|
|
53
|
+
skipped.push(recordId);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const filePath = join(sourcePath, file);
|
|
58
|
+
const fileStat = await stat(filePath);
|
|
59
|
+
|
|
60
|
+
// Relative asset path from project root
|
|
61
|
+
const assetPath = `outputs/candidates/${file}`;
|
|
62
|
+
|
|
63
|
+
const provenance = {
|
|
64
|
+
source: 'generated',
|
|
65
|
+
training_manifest_id: manifestId,
|
|
66
|
+
training_profile_id: manifest.training_profile_id,
|
|
67
|
+
adapter_target: manifest.adapter_target,
|
|
68
|
+
base_model: manifest.base_model || null,
|
|
69
|
+
source_export_id: manifest.source_export_id,
|
|
70
|
+
generated_at: new Date().toISOString(),
|
|
71
|
+
original_path: filePath,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const record = buildBaseRecord(recordId, assetPath, {
|
|
75
|
+
width: null,
|
|
76
|
+
height: null,
|
|
77
|
+
bytes: fileStat.size,
|
|
78
|
+
}, provenance);
|
|
79
|
+
|
|
80
|
+
record.schema_version = '2.1.0';
|
|
81
|
+
|
|
82
|
+
if (!options.dryRun) {
|
|
83
|
+
await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
created.push(recordId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { created: created.length, skipped: skipped.length, records: created };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Audit re-ingested records — check that they've been properly reviewed.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} projectRoot
|
|
96
|
+
* @returns {Promise<{total: number, reviewed: number, unreviewed: number, bound: number, unbound: number}>}
|
|
97
|
+
*/
|
|
98
|
+
export async function auditReingest(projectRoot) {
|
|
99
|
+
const recordsDir = join(projectRoot, 'records');
|
|
100
|
+
const files = (await readdir(recordsDir))
|
|
101
|
+
.filter(f => f.startsWith('gen_') && f.endsWith('.json'))
|
|
102
|
+
.sort();
|
|
103
|
+
|
|
104
|
+
let reviewed = 0;
|
|
105
|
+
let unreviewed = 0;
|
|
106
|
+
let bound = 0;
|
|
107
|
+
let unbound = 0;
|
|
108
|
+
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
|
|
111
|
+
|
|
112
|
+
if (record.judgment) {
|
|
113
|
+
reviewed++;
|
|
114
|
+
if (record.canon?.assertions?.length > 0) {
|
|
115
|
+
bound++;
|
|
116
|
+
} else {
|
|
117
|
+
unbound++;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
unreviewed++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
total: files.length,
|
|
126
|
+
reviewed,
|
|
127
|
+
unreviewed,
|
|
128
|
+
bound,
|
|
129
|
+
unbound,
|
|
130
|
+
};
|
|
131
|
+
}
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot creation and management.
|
|
3
|
+
*
|
|
4
|
+
* A snapshot is a frozen, versioned selection of records from a project
|
|
5
|
+
* at a point in time. Once created, it never silently changes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
9
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
import { loadProjectConfig, loadSelectionProfile, detectLane, detectGroup } from './config.js';
|
|
13
|
+
import { evaluateEligibility } from './eligibility.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate a snapshot ID: snap-YYYYMMDD-HHMMSS-XXXX
|
|
17
|
+
*/
|
|
18
|
+
function generateSnapshotId() {
|
|
19
|
+
const now = new Date();
|
|
20
|
+
const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14); // YYYYMMDDHHMMSS
|
|
21
|
+
const rand = randomBytes(2).toString('hex');
|
|
22
|
+
return `snap-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute config fingerprint — SHA-256 of all 5 config files concatenated.
|
|
27
|
+
* Deterministic: files read in alphabetical order.
|
|
28
|
+
*/
|
|
29
|
+
export function computeConfigFingerprint(projectRoot) {
|
|
30
|
+
const configFiles = [
|
|
31
|
+
'constitution.json',
|
|
32
|
+
'lanes.json',
|
|
33
|
+
'project.json',
|
|
34
|
+
'rubric.json',
|
|
35
|
+
'terminology.json',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const hash = createHash('sha256');
|
|
39
|
+
for (const file of configFiles) {
|
|
40
|
+
const filePath = join(projectRoot, file);
|
|
41
|
+
if (existsSync(filePath)) {
|
|
42
|
+
hash.update(`--- ${file} ---\n`);
|
|
43
|
+
hash.update(readFileSync(filePath, 'utf-8'));
|
|
44
|
+
hash.update('\n');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return hash.digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a snapshot — frozen selection of eligible records.
|
|
52
|
+
* @param {string} projectRoot — absolute path to project
|
|
53
|
+
* @param {Object} profile — selection profile
|
|
54
|
+
* @returns {Promise<{snapshotId: string, included: number, excluded: number}>}
|
|
55
|
+
*/
|
|
56
|
+
export async function createSnapshot(projectRoot, profile) {
|
|
57
|
+
const config = loadProjectConfig(projectRoot);
|
|
58
|
+
const recordsDir = join(projectRoot, 'records');
|
|
59
|
+
const snapshotId = generateSnapshotId();
|
|
60
|
+
const snapshotDir = join(projectRoot, 'snapshots', snapshotId);
|
|
61
|
+
|
|
62
|
+
// Load all records (sorted for deterministic output)
|
|
63
|
+
const files = (await readdir(recordsDir))
|
|
64
|
+
.filter(f => f.endsWith('.json'))
|
|
65
|
+
.sort();
|
|
66
|
+
|
|
67
|
+
const included = [];
|
|
68
|
+
const excluded = [];
|
|
69
|
+
const laneDistribution = {};
|
|
70
|
+
const factionDistribution = {};
|
|
71
|
+
const verdictDistribution = { pass: 0, fail: 0, partial: 0 };
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const raw = await readFile(join(recordsDir, file), 'utf-8');
|
|
75
|
+
const record = JSON.parse(raw);
|
|
76
|
+
|
|
77
|
+
const { eligible, reasons } = evaluateEligibility(
|
|
78
|
+
record, profile, config.lanes, config.terminology
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (eligible) {
|
|
82
|
+
const prompt = record.provenance?.prompt || '';
|
|
83
|
+
const lane = detectLane(record.id, prompt, config.lanes);
|
|
84
|
+
const faction = detectGroup(record.id, prompt, config.terminology);
|
|
85
|
+
|
|
86
|
+
// Build reason trace
|
|
87
|
+
const passRatio = record.canon?.assertion_count > 0
|
|
88
|
+
? `${record.canon.pass_count}/${record.canon.assertion_count}`
|
|
89
|
+
: 'n/a';
|
|
90
|
+
const reason = `${record.judgment.status}, canon-bound, pass_ratio=${passRatio}, lane=${lane}`;
|
|
91
|
+
|
|
92
|
+
included.push({ record_id: record.id, reason });
|
|
93
|
+
|
|
94
|
+
// Track distributions
|
|
95
|
+
laneDistribution[lane] = (laneDistribution[lane] || 0) + 1;
|
|
96
|
+
factionDistribution[faction || 'unknown'] = (factionDistribution[faction || 'unknown'] || 0) + 1;
|
|
97
|
+
if (record.canon?.assertions) {
|
|
98
|
+
for (const a of record.canon.assertions) {
|
|
99
|
+
verdictDistribution[a.verdict] = (verdictDistribution[a.verdict] || 0) + 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
excluded.push({ record_id: record.id, reasons });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Write snapshot files
|
|
108
|
+
await mkdir(snapshotDir, { recursive: true });
|
|
109
|
+
|
|
110
|
+
const manifest = {
|
|
111
|
+
snapshot_id: snapshotId,
|
|
112
|
+
created_at: new Date().toISOString(),
|
|
113
|
+
created_by: 'sdlab-snapshot-v1',
|
|
114
|
+
project_name: config.meta.name,
|
|
115
|
+
frozen: true,
|
|
116
|
+
selection_profile: profile,
|
|
117
|
+
config_fingerprint: computeConfigFingerprint(projectRoot),
|
|
118
|
+
config_versions: {
|
|
119
|
+
constitution: config.constitution.version || '1.0.0',
|
|
120
|
+
project: config.meta.version || '0.0.0',
|
|
121
|
+
},
|
|
122
|
+
counts: {
|
|
123
|
+
total_records: files.length,
|
|
124
|
+
evaluated: files.length,
|
|
125
|
+
included: included.length,
|
|
126
|
+
excluded: excluded.length,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const summary = {
|
|
131
|
+
snapshot_id: snapshotId,
|
|
132
|
+
lane_distribution: laneDistribution,
|
|
133
|
+
faction_distribution: factionDistribution,
|
|
134
|
+
verdict_distribution: verdictDistribution,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await writeFile(join(snapshotDir, 'snapshot.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
138
|
+
await writeFile(join(snapshotDir, 'included.jsonl'), included.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
139
|
+
await writeFile(join(snapshotDir, 'excluded.jsonl'), excluded.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
140
|
+
await writeFile(join(snapshotDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
|
|
141
|
+
|
|
142
|
+
return { snapshotId, included: included.length, excluded: excluded.length };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Load a snapshot manifest.
|
|
147
|
+
*/
|
|
148
|
+
export async function loadSnapshot(projectRoot, snapshotId) {
|
|
149
|
+
const path = join(projectRoot, 'snapshots', snapshotId, 'snapshot.json');
|
|
150
|
+
if (!existsSync(path)) {
|
|
151
|
+
throw new Error(`Snapshot "${snapshotId}" not found at ${path}`);
|
|
152
|
+
}
|
|
153
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Load included record IDs from a snapshot.
|
|
158
|
+
*/
|
|
159
|
+
export async function loadSnapshotIncluded(projectRoot, snapshotId) {
|
|
160
|
+
const path = join(projectRoot, 'snapshots', snapshotId, 'included.jsonl');
|
|
161
|
+
const raw = await readFile(path, 'utf-8');
|
|
162
|
+
return raw.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* List all snapshots in a project.
|
|
167
|
+
*/
|
|
168
|
+
export async function listSnapshots(projectRoot) {
|
|
169
|
+
const snapshotsDir = join(projectRoot, 'snapshots');
|
|
170
|
+
if (!existsSync(snapshotsDir)) return [];
|
|
171
|
+
|
|
172
|
+
const entries = await readdir(snapshotsDir, { withFileTypes: true });
|
|
173
|
+
const snapshots = [];
|
|
174
|
+
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (!entry.isDirectory() || !entry.name.startsWith('snap-')) continue;
|
|
177
|
+
try {
|
|
178
|
+
const manifest = JSON.parse(
|
|
179
|
+
await readFile(join(snapshotsDir, entry.name, 'snapshot.json'), 'utf-8')
|
|
180
|
+
);
|
|
181
|
+
snapshots.push({
|
|
182
|
+
id: manifest.snapshot_id,
|
|
183
|
+
created_at: manifest.created_at,
|
|
184
|
+
included: manifest.counts.included,
|
|
185
|
+
excluded: manifest.counts.excluded,
|
|
186
|
+
fingerprint: manifest.config_fingerprint.slice(0, 12),
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
// Skip malformed snapshots
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return snapshots.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Diff two snapshots — records added, removed, and shared.
|
|
198
|
+
*/
|
|
199
|
+
export async function diffSnapshots(projectRoot, idA, idB) {
|
|
200
|
+
const includedA = await loadSnapshotIncluded(projectRoot, idA);
|
|
201
|
+
const includedB = await loadSnapshotIncluded(projectRoot, idB);
|
|
202
|
+
|
|
203
|
+
const setA = new Set(includedA.map(r => r.record_id));
|
|
204
|
+
const setB = new Set(includedB.map(r => r.record_id));
|
|
205
|
+
|
|
206
|
+
const added = [...setB].filter(id => !setA.has(id));
|
|
207
|
+
const removed = [...setA].filter(id => !setB.has(id));
|
|
208
|
+
const shared = [...setA].filter(id => setB.has(id));
|
|
209
|
+
|
|
210
|
+
return { added, removed, shared, countA: setA.size, countB: setB.size };
|
|
211
|
+
}
|