@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.
- package/CHANGELOG.md +73 -0
- package/README.md +138 -113
- package/bin/sdlab.js +126 -0
- package/lib/args.js +106 -0
- package/lib/card.js +145 -0
- package/lib/comfyui.js +109 -0
- package/lib/config.js +248 -0
- package/lib/deprecation.js +14 -0
- package/lib/eligibility.js +110 -0
- package/lib/eval-pack.js +283 -0
- package/lib/export.js +259 -0
- package/lib/paths.js +38 -0
- package/lib/records.js +69 -0
- package/lib/snapshot.js +211 -0
- package/lib/split.js +409 -0
- package/package.json +20 -9
- package/scripts/bulk-curate-wave2-5.js +1 -1
- package/scripts/bulk-curate-waves11-18.js +1 -1
- package/scripts/canon-bind.js +188 -338
- package/scripts/card.js +73 -0
- package/scripts/compare.js +40 -30
- package/scripts/curate-wave25.js +1 -1
- package/scripts/curate.js +34 -28
- package/scripts/doctor.js +324 -0
- package/scripts/eligibility.js +91 -0
- package/scripts/eval-pack.js +92 -0
- package/scripts/export.js +85 -0
- package/scripts/generate-controlnet.js +36 -121
- package/scripts/generate-identity.js +38 -106
- package/scripts/generate-ipadapter.js +42 -119
- package/scripts/generate.js +22 -81
- package/scripts/init.js +229 -0
- package/scripts/migrate-records.js +15 -21
- package/scripts/painterly-test.js +20 -47
- package/scripts/painterly.js +27 -106
- package/scripts/snapshot.js +132 -0
- package/scripts/split.js +154 -0
- package/templates/domains/architecture/constitution.json +97 -0
- package/templates/domains/architecture/lanes.json +41 -0
- package/templates/domains/architecture/project.json +20 -0
- package/templates/domains/architecture/rubric.json +40 -0
- package/templates/domains/architecture/terminology.json +19 -0
- package/templates/domains/character-design/constitution.json +88 -0
- package/templates/domains/character-design/lanes.json +35 -0
- package/templates/domains/character-design/project.json +17 -0
- package/templates/domains/character-design/rubric.json +38 -0
- package/templates/domains/character-design/terminology.json +21 -0
- package/templates/domains/creature-design/constitution.json +87 -0
- package/templates/domains/creature-design/lanes.json +41 -0
- package/templates/domains/creature-design/project.json +18 -0
- package/templates/domains/creature-design/rubric.json +39 -0
- package/templates/domains/creature-design/terminology.json +20 -0
- package/templates/domains/game-art/constitution.json +113 -0
- package/templates/domains/game-art/lanes.json +47 -0
- package/templates/domains/game-art/project.json +17 -0
- package/templates/domains/game-art/rubric.json +36 -0
- package/templates/domains/game-art/terminology.json +30 -0
- package/templates/domains/vehicle-mech/constitution.json +87 -0
- package/templates/domains/vehicle-mech/lanes.json +41 -0
- package/templates/domains/vehicle-mech/project.json +17 -0
- package/templates/domains/vehicle-mech/rubric.json +40 -0
- 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
|
+
}
|