@mcptoolshop/style-dataset-lab 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/card.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Dataset card generation — markdown + JSON twin.
3
+ *
4
+ * Produces a human-readable dataset card summarizing provenance,
5
+ * selection criteria, split strategy, and distribution statistics.
6
+ */
7
+
8
+ import { loadSnapshot, loadSnapshotIncluded } from './snapshot.js';
9
+ import { loadSplit, loadSplitAudit } from './split.js';
10
+ import { loadProjectConfig } from './config.js';
11
+
12
+ /**
13
+ * Generate a dataset card from snapshot + split + project config.
14
+ *
15
+ * @param {string} projectRoot
16
+ * @param {string} snapshotId
17
+ * @param {string} splitId
18
+ * @returns {Promise<{markdown: string, json: Object}>}
19
+ */
20
+ export async function generateCard(projectRoot, snapshotId, splitId) {
21
+ const config = loadProjectConfig(projectRoot);
22
+ const snapshot = await loadSnapshot(projectRoot, snapshotId);
23
+ const split = await loadSplit(projectRoot, splitId);
24
+ const audit = await loadSplitAudit(projectRoot, splitId);
25
+ const included = await loadSnapshotIncluded(projectRoot, snapshotId);
26
+
27
+ const json = {
28
+ dataset_name: `${config.meta.name}-visual`,
29
+ version: config.meta.version || '1.0.0',
30
+ created_at: new Date().toISOString(),
31
+ created_by: 'sdlab-card-v1',
32
+ project: {
33
+ name: config.meta.name,
34
+ display_name: config.meta.display_name,
35
+ domain: config.meta.domain,
36
+ },
37
+ snapshot: {
38
+ id: snapshot.snapshot_id,
39
+ created_at: snapshot.created_at,
40
+ config_fingerprint: snapshot.config_fingerprint,
41
+ total_records: snapshot.counts.total_records,
42
+ included: snapshot.counts.included,
43
+ excluded: snapshot.counts.excluded,
44
+ },
45
+ selection: snapshot.selection_profile,
46
+ split: {
47
+ id: split.split_id,
48
+ strategy: split.profile.strategy,
49
+ seed: split.profile.seed,
50
+ ratios: {
51
+ train: split.profile.train_ratio,
52
+ val: split.profile.val_ratio,
53
+ test: split.profile.test_ratio,
54
+ },
55
+ counts: split.counts,
56
+ leakage_free: audit.leakage_check.passed,
57
+ },
58
+ lane_balance: audit.lane_balance,
59
+ constitution: {
60
+ rule_count: config.constitution.rules?.length || 0,
61
+ },
62
+ rubric: {
63
+ dimension_count: config.rubric.dimensions?.length || 0,
64
+ thresholds: config.rubric.thresholds,
65
+ },
66
+ };
67
+
68
+ const markdown = renderMarkdown(json, config);
69
+
70
+ return { markdown, json };
71
+ }
72
+
73
+ function renderMarkdown(card, config) {
74
+ const lines = [];
75
+ const add = (s = '') => lines.push(s);
76
+
77
+ add(`# ${card.dataset_name}`);
78
+ add();
79
+ add(`**Project:** ${card.project.display_name} `);
80
+ add(`**Domain:** ${card.project.domain} `);
81
+ add(`**Version:** ${card.version} `);
82
+ add(`**Generated:** ${card.created_at} `);
83
+ add();
84
+
85
+ add('## Selection');
86
+ add();
87
+ add(`- **Snapshot:** \`${card.snapshot.id}\``);
88
+ add(`- **Config fingerprint:** \`${card.snapshot.config_fingerprint.slice(0, 16)}...\``);
89
+ add(`- **Total records evaluated:** ${card.snapshot.total_records}`);
90
+ add(`- **Included:** ${card.snapshot.included} (${(card.snapshot.included / card.snapshot.total_records * 100).toFixed(1)}%)`);
91
+ add(`- **Excluded:** ${card.snapshot.excluded}`);
92
+ add();
93
+
94
+ add('### Selection criteria');
95
+ add();
96
+ const sel = card.selection;
97
+ if (sel.require_judgment) add('- Requires human judgment');
98
+ if (sel.require_status?.length) add(`- Status: ${sel.require_status.join(', ')}`);
99
+ if (sel.require_canon_bound) add('- Must be canon-bound (constitution assertions)');
100
+ if (sel.minimum_pass_ratio != null) add(`- Minimum pass ratio: ${(sel.minimum_pass_ratio * 100).toFixed(0)}%`);
101
+ add();
102
+
103
+ add('## Split');
104
+ add();
105
+ add(`- **Split ID:** \`${card.split.id}\``);
106
+ add(`- **Strategy:** ${card.split.strategy}`);
107
+ add(`- **Seed:** ${card.split.seed}`);
108
+ add(`- **Subject leakage:** ${card.split.leakage_free ? 'None (verified)' : 'DETECTED'}`);
109
+ add();
110
+
111
+ add('| Partition | Records | % |');
112
+ add('|-----------|---------|---|');
113
+ const total = card.split.counts.total_records;
114
+ add(`| Train | ${card.split.counts.train} | ${(card.split.counts.train / total * 100).toFixed(1)}% |`);
115
+ add(`| Val | ${card.split.counts.val} | ${(card.split.counts.val / total * 100).toFixed(1)}% |`);
116
+ add(`| Test | ${card.split.counts.test} | ${(card.split.counts.test / total * 100).toFixed(1)}% |`);
117
+ add();
118
+
119
+ add('## Lane Balance');
120
+ add();
121
+ add('| Lane | Total | Train% | Val% | Test% |');
122
+ add('|------|-------|--------|------|-------|');
123
+ for (const [lane, b] of Object.entries(card.lane_balance).sort((a, b) => b[1].total - a[1].total)) {
124
+ add(`| ${lane} | ${b.total} | ${b.train_pct}% | ${b.val_pct}% | ${b.test_pct}% |`);
125
+ }
126
+ add();
127
+
128
+ add('## Quality Gates');
129
+ add();
130
+ add(`- **Constitution rules:** ${card.constitution.rule_count}`);
131
+ add(`- **Rubric dimensions:** ${card.rubric.dimension_count}`);
132
+ if (card.rubric.thresholds) {
133
+ add(`- **Pass threshold:** ${(card.rubric.thresholds.pass * 100).toFixed(0)}%`);
134
+ }
135
+ add();
136
+
137
+ add('## Provenance');
138
+ add();
139
+ add('This dataset was produced by [Style Dataset Lab](https://github.com/mcp-tool-shop-org/style-dataset-lab).');
140
+ add('Every included record has been human-reviewed, canon-bound, and verified against project constitution rules.');
141
+ add('Split strategy ensures no subject appears in multiple partitions.');
142
+ add();
143
+
144
+ return lines.join('\n');
145
+ }
package/lib/config.js CHANGED
@@ -102,3 +102,147 @@ export function loadProjectConfig(projectRoot) {
102
102
  terminology: loadTerminology(projectRoot),
103
103
  };
104
104
  }
105
+
106
+ // ─── Profile Loaders ─────────────────────────────────────────────────
107
+
108
+ const DEFAULT_SELECTION_PROFILE = {
109
+ require_judgment: true,
110
+ require_status: ['approved'],
111
+ require_canon_bound: true,
112
+ minimum_pass_ratio: 0.5,
113
+ rights_filter: null,
114
+ exclude_lanes: [],
115
+ max_per_subject: null,
116
+ max_per_lane: null,
117
+ };
118
+
119
+ const DEFAULT_SPLIT_PROFILE = {
120
+ profile_id: 'balanced-default',
121
+ strategy: 'subject-isolated',
122
+ train_ratio: 0.8,
123
+ val_ratio: 0.1,
124
+ test_ratio: 0.1,
125
+ lane_balance: true,
126
+ subject_isolation: true,
127
+ seed: 42,
128
+ };
129
+
130
+ const DEFAULT_EXPORT_PROFILE = {
131
+ profile_id: 'canonical-package',
132
+ include_images: true,
133
+ image_strategy: 'symlink',
134
+ include_splits: true,
135
+ include_card: true,
136
+ include_checksums: true,
137
+ metadata_fields: ['id', 'asset_path', 'provenance', 'judgment', 'canon', 'identity', 'lineage'],
138
+ };
139
+
140
+ /**
141
+ * Load a selection profile from project or return built-in default.
142
+ */
143
+ export function loadSelectionProfile(projectRoot, profileId) {
144
+ if (!profileId) return { ...DEFAULT_SELECTION_PROFILE };
145
+ const data = loadJsonFile(join(projectRoot, 'selection-profiles', `${profileId}.json`));
146
+ if (!data) return { ...DEFAULT_SELECTION_PROFILE };
147
+ return { ...DEFAULT_SELECTION_PROFILE, ...data };
148
+ }
149
+
150
+ /**
151
+ * Load a split profile from project or return built-in default.
152
+ */
153
+ export function loadSplitProfile(projectRoot, profileId) {
154
+ if (!profileId) return { ...DEFAULT_SPLIT_PROFILE };
155
+ const data = loadJsonFile(join(projectRoot, 'split-profiles', `${profileId}.json`));
156
+ if (!data) return { ...DEFAULT_SPLIT_PROFILE };
157
+ return { ...DEFAULT_SPLIT_PROFILE, ...data };
158
+ }
159
+
160
+ /**
161
+ * Load an export profile from project or return built-in default.
162
+ */
163
+ export function loadExportProfile(projectRoot, profileId) {
164
+ if (!profileId) return { ...DEFAULT_EXPORT_PROFILE };
165
+ const data = loadJsonFile(join(projectRoot, 'export-profiles', `${profileId}.json`));
166
+ if (!data) return { ...DEFAULT_EXPORT_PROFILE };
167
+ return { ...DEFAULT_EXPORT_PROFILE, ...data };
168
+ }
169
+
170
+ // ─── Detection Functions ─────────────────────────────────────────────
171
+ // Extracted from scripts/canon-bind.js for reuse in eligibility,
172
+ // snapshot, and split modules.
173
+
174
+ /**
175
+ * Detect lane from asset ID using project lane config.
176
+ * Tests each lane's id_patterns as regex against the lowercase ID.
177
+ * Returns the first match, or the default_lane.
178
+ */
179
+ export function detectLane(id, prompt, lanesConfig) {
180
+ const lower = id.toLowerCase();
181
+ for (const lane of lanesConfig.lanes) {
182
+ if (!lane.id_patterns || lane.id_patterns.length === 0) continue;
183
+ for (const pattern of lane.id_patterns) {
184
+ if (new RegExp(pattern).test(lower)) return lane.id;
185
+ }
186
+ }
187
+ return lanesConfig.default_lane;
188
+ }
189
+
190
+ /**
191
+ * Detect group (faction) from asset ID and prompt using terminology config.
192
+ * Uses explicit id_detection_order and prompt_detection_order when provided
193
+ * to preserve the exact detection priority of the original implementation.
194
+ */
195
+ export function detectGroup(id, prompt, termConfig) {
196
+ const idOrder = termConfig.id_detection_order || Object.keys(termConfig.groups);
197
+ const promptOrder = termConfig.prompt_detection_order || Object.keys(termConfig.groups);
198
+
199
+ // 1. Primary ID patterns per group (id_detection_order)
200
+ for (const groupName of idOrder) {
201
+ const group = termConfig.groups[groupName];
202
+ if (!group?.id_patterns) continue;
203
+ for (const pattern of group.id_patterns) {
204
+ if (new RegExp(pattern).test(id)) return groupName;
205
+ }
206
+ }
207
+
208
+ // 2. Cross-group patterns
209
+ for (const [pattern, behavior] of Object.entries(termConfig.cross_group_patterns || {})) {
210
+ if (new RegExp(pattern).test(id)) {
211
+ if (behavior === 'detect_from_id_suffix') {
212
+ for (const groupName of idOrder) {
213
+ if (id.includes(groupName)) return groupName;
214
+ }
215
+ }
216
+ return null;
217
+ }
218
+ }
219
+
220
+ // 3. Edge defaults
221
+ for (const [pattern, groupName] of Object.entries(termConfig.edge_defaults || {})) {
222
+ if (new RegExp(pattern).test(id)) return groupName;
223
+ }
224
+
225
+ // 4. Prompt-based fallback (prompt_detection_order)
226
+ if (prompt) {
227
+ const p = prompt.toLowerCase();
228
+ for (const groupName of promptOrder) {
229
+ const group = termConfig.groups[groupName];
230
+ if (!group?.prompt_patterns || group.prompt_patterns.length === 0) continue;
231
+ for (const keyword of group.prompt_patterns) {
232
+ if (new RegExp(keyword).test(p)) return groupName;
233
+ }
234
+ }
235
+ }
236
+
237
+ // 5. Null-faction patterns (generic rejects — no faction)
238
+ for (const pattern of termConfig.null_faction_patterns || []) {
239
+ if (new RegExp(pattern).test(id)) return null;
240
+ }
241
+
242
+ // 6. ID fallback patterns
243
+ for (const [pattern, groupName] of Object.entries(termConfig.id_fallbacks || {})) {
244
+ if (new RegExp(pattern).test(id)) return groupName;
245
+ }
246
+
247
+ return null;
248
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Eligibility evaluation engine.
3
+ *
4
+ * Determines whether each record qualifies for inclusion in a dataset
5
+ * snapshot, with explicit reason traces for every decision.
6
+ */
7
+
8
+ import { detectLane, detectGroup } from './config.js';
9
+
10
+ /**
11
+ * Evaluate a single record against a selection profile.
12
+ * @param {Object} record — full record object
13
+ * @param {Object} profile — selection profile
14
+ * @param {Object} lanesConfig — from lanes.json
15
+ * @param {Object} termConfig — from terminology.json
16
+ * @returns {{ eligible: boolean, reasons: string[] }}
17
+ */
18
+ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
19
+ const reasons = [];
20
+
21
+ // 1. Judgment required
22
+ if (profile.require_judgment) {
23
+ if (!record.judgment) {
24
+ reasons.push('no judgment (record has not been reviewed)');
25
+ }
26
+ }
27
+
28
+ // 2. Status filter
29
+ if (profile.require_status?.length > 0 && record.judgment) {
30
+ if (!profile.require_status.includes(record.judgment.status)) {
31
+ reasons.push(`status "${record.judgment.status}" not in [${profile.require_status.join(', ')}]`);
32
+ }
33
+ }
34
+
35
+ // 3. Canon bound
36
+ if (profile.require_canon_bound) {
37
+ if (!record.canon?.assertions || record.canon.assertions.length === 0) {
38
+ reasons.push('not canon-bound (no assertions)');
39
+ }
40
+ }
41
+
42
+ // 4. Minimum pass ratio
43
+ if (profile.minimum_pass_ratio != null && record.canon?.assertion_count > 0) {
44
+ const ratio = record.canon.pass_count / record.canon.assertion_count;
45
+ if (ratio < profile.minimum_pass_ratio) {
46
+ reasons.push(`pass ratio ${(ratio * 100).toFixed(0)}% below minimum ${(profile.minimum_pass_ratio * 100).toFixed(0)}%`);
47
+ }
48
+ }
49
+
50
+ // 5. Lane exclusion
51
+ if (profile.exclude_lanes?.length > 0) {
52
+ const prompt = record.provenance?.prompt || '';
53
+ const lane = detectLane(record.id, prompt, lanesConfig);
54
+ if (profile.exclude_lanes.includes(lane)) {
55
+ reasons.push(`lane "${lane}" is excluded`);
56
+ }
57
+ }
58
+
59
+ // 6. Rights filter
60
+ if (profile.rights_filter != null) {
61
+ const rights = record.rights_status || record.training_rights_status || null;
62
+ if (rights !== profile.rights_filter) {
63
+ reasons.push(`rights "${rights || 'unknown'}" does not match required "${profile.rights_filter}"`);
64
+ }
65
+ }
66
+
67
+ // 7. Tag exclusion
68
+ if (profile.exclude_tags?.length > 0 && record.dataset_tags) {
69
+ const excluded = record.dataset_tags.filter(t => profile.exclude_tags.includes(t));
70
+ if (excluded.length > 0) {
71
+ reasons.push(`has excluded tags: ${excluded.join(', ')}`);
72
+ }
73
+ }
74
+
75
+ return {
76
+ eligible: reasons.length === 0,
77
+ reasons,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Categorize exclusion reasons for a set of records.
83
+ * @param {Array<{record_id: string, reasons: string[]}>} excluded
84
+ * @returns {Object} — category → count mapping
85
+ */
86
+ export function categorizeExclusions(excluded) {
87
+ const categories = {
88
+ no_judgment: 0,
89
+ wrong_status: 0,
90
+ not_canon_bound: 0,
91
+ low_pass_ratio: 0,
92
+ excluded_lane: 0,
93
+ rights_filter: 0,
94
+ excluded_tags: 0,
95
+ };
96
+
97
+ for (const { reasons } of excluded) {
98
+ for (const r of reasons) {
99
+ if (r.includes('no judgment')) categories.no_judgment++;
100
+ else if (r.includes('status')) categories.wrong_status++;
101
+ else if (r.includes('canon-bound')) categories.not_canon_bound++;
102
+ else if (r.includes('pass ratio')) categories.low_pass_ratio++;
103
+ else if (r.includes('lane')) categories.excluded_lane++;
104
+ else if (r.includes('rights')) categories.rights_filter++;
105
+ else if (r.includes('tags')) categories.excluded_tags++;
106
+ }
107
+ }
108
+
109
+ return categories;
110
+ }
@@ -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
+ }