@mcptoolshop/style-dataset-lab 1.2.1 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +138 -113
  3. package/bin/sdlab.js +126 -0
  4. package/lib/args.js +106 -0
  5. package/lib/card.js +145 -0
  6. package/lib/comfyui.js +109 -0
  7. package/lib/config.js +248 -0
  8. package/lib/deprecation.js +14 -0
  9. package/lib/eligibility.js +110 -0
  10. package/lib/eval-pack.js +283 -0
  11. package/lib/export.js +259 -0
  12. package/lib/paths.js +38 -0
  13. package/lib/records.js +69 -0
  14. package/lib/snapshot.js +211 -0
  15. package/lib/split.js +409 -0
  16. package/package.json +20 -9
  17. package/scripts/bulk-curate-wave2-5.js +1 -1
  18. package/scripts/bulk-curate-waves11-18.js +1 -1
  19. package/scripts/canon-bind.js +188 -338
  20. package/scripts/card.js +73 -0
  21. package/scripts/compare.js +40 -30
  22. package/scripts/curate-wave25.js +1 -1
  23. package/scripts/curate.js +34 -28
  24. package/scripts/doctor.js +324 -0
  25. package/scripts/eligibility.js +91 -0
  26. package/scripts/eval-pack.js +92 -0
  27. package/scripts/export.js +85 -0
  28. package/scripts/generate-controlnet.js +36 -121
  29. package/scripts/generate-identity.js +38 -106
  30. package/scripts/generate-ipadapter.js +42 -119
  31. package/scripts/generate.js +22 -81
  32. package/scripts/init.js +229 -0
  33. package/scripts/migrate-records.js +15 -21
  34. package/scripts/painterly-test.js +20 -47
  35. package/scripts/painterly.js +27 -106
  36. package/scripts/snapshot.js +132 -0
  37. package/scripts/split.js +154 -0
  38. package/templates/domains/architecture/constitution.json +97 -0
  39. package/templates/domains/architecture/lanes.json +41 -0
  40. package/templates/domains/architecture/project.json +20 -0
  41. package/templates/domains/architecture/rubric.json +40 -0
  42. package/templates/domains/architecture/terminology.json +19 -0
  43. package/templates/domains/character-design/constitution.json +88 -0
  44. package/templates/domains/character-design/lanes.json +35 -0
  45. package/templates/domains/character-design/project.json +17 -0
  46. package/templates/domains/character-design/rubric.json +38 -0
  47. package/templates/domains/character-design/terminology.json +21 -0
  48. package/templates/domains/creature-design/constitution.json +87 -0
  49. package/templates/domains/creature-design/lanes.json +41 -0
  50. package/templates/domains/creature-design/project.json +18 -0
  51. package/templates/domains/creature-design/rubric.json +39 -0
  52. package/templates/domains/creature-design/terminology.json +20 -0
  53. package/templates/domains/game-art/constitution.json +113 -0
  54. package/templates/domains/game-art/lanes.json +47 -0
  55. package/templates/domains/game-art/project.json +17 -0
  56. package/templates/domains/game-art/rubric.json +36 -0
  57. package/templates/domains/game-art/terminology.json +30 -0
  58. package/templates/domains/vehicle-mech/constitution.json +87 -0
  59. package/templates/domains/vehicle-mech/lanes.json +41 -0
  60. package/templates/domains/vehicle-mech/project.json +17 -0
  61. package/templates/domains/vehicle-mech/rubric.json +40 -0
  62. package/templates/domains/vehicle-mech/terminology.json +21 -0
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/comfyui.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * ComfyUI HTTP API client — extracted from 5 generation scripts.
3
+ *
4
+ * All functions take comfyUrl as a parameter so projects can override
5
+ * the default localhost:8188 endpoint.
6
+ */
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+
10
+ const DEFAULT_URL = 'http://127.0.0.1:8188';
11
+ const MAX_POLL_MS = 600_000; // 10 minutes
12
+
13
+ /**
14
+ * Check if ComfyUI is reachable.
15
+ * @param {string} [comfyUrl]
16
+ * @returns {Promise<boolean>}
17
+ */
18
+ export async function comfyHealth(comfyUrl = DEFAULT_URL) {
19
+ try {
20
+ const res = await fetch(`${comfyUrl}/system_stats`);
21
+ return res.ok;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Submit a workflow and poll until complete.
29
+ * @param {Object} workflow — ComfyUI prompt graph
30
+ * @param {string} [comfyUrl]
31
+ * @param {{ clientPrefix?: string, timeoutMs?: number }} [opts]
32
+ * @returns {Promise<Object>} — history entry
33
+ */
34
+ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {}) {
35
+ const { clientPrefix = 'sdl', timeoutMs = MAX_POLL_MS } = opts;
36
+ const clientId = `${clientPrefix}-${Date.now()}`;
37
+
38
+ const res = await fetch(`${comfyUrl}/prompt`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ prompt: workflow, client_id: clientId }),
42
+ });
43
+
44
+ if (!res.ok) {
45
+ const text = await res.text();
46
+ throw new Error(`ComfyUI submit failed: ${res.status} ${text}`);
47
+ }
48
+
49
+ const { prompt_id: promptId } = await res.json();
50
+
51
+ const start = Date.now();
52
+ while (true) {
53
+ if (Date.now() - start > timeoutMs) {
54
+ throw new Error(`ComfyUI poll timeout after ${timeoutMs / 1000}s for prompt ${promptId}`);
55
+ }
56
+ await new Promise(r => setTimeout(r, 1000));
57
+ const histRes = await fetch(`${comfyUrl}/history/${promptId}`);
58
+ if (!histRes.ok) continue;
59
+ const history = await histRes.json();
60
+ const entry = history[promptId];
61
+ if (!entry) continue;
62
+ if (entry.status?.completed) return entry;
63
+ if (entry.status?.status_str === 'error') {
64
+ throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status)}`);
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Download a generated image from ComfyUI.
71
+ * @param {string} filename
72
+ * @param {string} [subfolder]
73
+ * @param {string} [comfyUrl]
74
+ * @param {string} [type='output']
75
+ * @returns {Promise<Buffer>}
76
+ */
77
+ export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output') {
78
+ const url = `${comfyUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || '')}&type=${type}`;
79
+ const res = await fetch(url);
80
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
81
+ return Buffer.from(await res.arrayBuffer());
82
+ }
83
+
84
+ /**
85
+ * Upload an image to ComfyUI's input folder.
86
+ * @param {string} filePath — local file to upload
87
+ * @param {string} filename — name to assign in ComfyUI
88
+ * @param {string} [comfyUrl]
89
+ * @returns {Promise<string>} — assigned name from ComfyUI
90
+ */
91
+ export async function uploadImage(filePath, filename, comfyUrl = DEFAULT_URL) {
92
+ const data = await readFile(filePath);
93
+ const formData = new FormData();
94
+ formData.append('image', new Blob([data], { type: 'image/png' }), filename);
95
+ formData.append('overwrite', 'true');
96
+
97
+ const res = await fetch(`${comfyUrl}/upload/image`, {
98
+ method: 'POST',
99
+ body: formData,
100
+ });
101
+
102
+ if (!res.ok) {
103
+ const text = await res.text();
104
+ throw new Error(`Upload failed: ${res.status} ${text}`);
105
+ }
106
+
107
+ const result = await res.json();
108
+ return result.name;
109
+ }
package/lib/config.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Per-project config loader.
3
+ *
4
+ * Loads JSON config files from a project root directory.
5
+ * Falls back to sensible defaults when files don't exist,
6
+ * so unmigrated projects (pre-v2.0) still work.
7
+ */
8
+
9
+ import { readFileSync, existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ function loadJsonFile(filePath) {
13
+ if (!existsSync(filePath)) return null;
14
+ try {
15
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
16
+ } catch (err) {
17
+ throw new Error(`Failed to parse ${filePath}: ${err.message}`);
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Load project.json — project identity and generation defaults.
23
+ */
24
+ export function loadProjectMeta(projectRoot) {
25
+ const data = loadJsonFile(join(projectRoot, 'project.json'));
26
+ if (!data) {
27
+ return {
28
+ name: 'unknown',
29
+ display_name: 'Unknown Project',
30
+ domain: 'generic',
31
+ version: '0.0.0',
32
+ defaults: {},
33
+ };
34
+ }
35
+ return data;
36
+ }
37
+
38
+ /**
39
+ * Load constitution.json — array of rule objects.
40
+ */
41
+ export function loadConstitution(projectRoot) {
42
+ const data = loadJsonFile(join(projectRoot, 'constitution.json'));
43
+ if (!data) return { rules: [] };
44
+ if (Array.isArray(data)) return { rules: data };
45
+ if (!data.rules) throw new Error(`constitution.json must have a "rules" array`);
46
+ return data;
47
+ }
48
+
49
+ /**
50
+ * Load lanes.json — lane definitions with detection patterns.
51
+ */
52
+ export function loadLanes(projectRoot) {
53
+ const data = loadJsonFile(join(projectRoot, 'lanes.json'));
54
+ if (!data) {
55
+ return { default_lane: 'default', lanes: [] };
56
+ }
57
+ if (!data.lanes) throw new Error(`lanes.json must have a "lanes" array`);
58
+ return data;
59
+ }
60
+
61
+ /**
62
+ * Load rubric.json — scoring dimensions, thresholds, failure modes.
63
+ */
64
+ export function loadRubric(projectRoot) {
65
+ const data = loadJsonFile(join(projectRoot, 'rubric.json'));
66
+ if (!data) {
67
+ return {
68
+ dimensions: [],
69
+ thresholds: { pass: 0.7, partial: 0.5, fail_ceiling: 0.4 },
70
+ failure_to_rules: {},
71
+ };
72
+ }
73
+ return data;
74
+ }
75
+
76
+ /**
77
+ * Load terminology.json — group vocabulary and detection patterns.
78
+ */
79
+ export function loadTerminology(projectRoot) {
80
+ const data = loadJsonFile(join(projectRoot, 'terminology.json'));
81
+ if (!data) {
82
+ return {
83
+ group_label: 'group',
84
+ groups: {},
85
+ cross_group_patterns: {},
86
+ edge_defaults: {},
87
+ prompt_fallbacks: {},
88
+ };
89
+ }
90
+ return data;
91
+ }
92
+
93
+ /**
94
+ * Load all project config files at once.
95
+ */
96
+ export function loadProjectConfig(projectRoot) {
97
+ return {
98
+ meta: loadProjectMeta(projectRoot),
99
+ constitution: loadConstitution(projectRoot),
100
+ lanes: loadLanes(projectRoot),
101
+ rubric: loadRubric(projectRoot),
102
+ terminology: loadTerminology(projectRoot),
103
+ };
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,14 @@
1
+ /**
2
+ * Deprecation warning helper — fires once per flag per process.
3
+ */
4
+
5
+ const warned = new Set();
6
+
7
+ export function warnDeprecated(oldFlag, newFlag) {
8
+ const key = `${oldFlag}→${newFlag}`;
9
+ if (warned.has(key)) return;
10
+ warned.add(key);
11
+ process.stderr.write(
12
+ `\x1b[33m⚠ --${oldFlag} is deprecated and will be removed in v3.0. Use --${newFlag} instead.\x1b[0m\n`
13
+ );
14
+ }
@@ -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
+ }