@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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Training package builder.
3
+ *
4
+ * Builds trainer-ready dataset packages from training manifests.
5
+ * Adapters transform the canonical layout for specific training targets
6
+ * but never mutate inclusion or split truth.
7
+ */
8
+
9
+ import { readFile, writeFile, mkdir, readdir, symlink, copyFile } from 'node:fs/promises';
10
+ import { join, relative } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { createHash, randomBytes } from 'node:crypto';
13
+ import { loadTrainingManifest } from './training-manifests.js';
14
+ import { loadTrainingProfile } from './training-profiles.js';
15
+ import { loadSplitPartition } from './split.js';
16
+ import { loadRecord } from './records.js';
17
+ import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
+
19
+ /**
20
+ * Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
21
+ */
22
+ function generatePackageId() {
23
+ const now = new Date();
24
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
25
+ const rand = randomBytes(2).toString('hex');
26
+ return `tp-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
27
+ }
28
+
29
+ async function sha256File(filePath) {
30
+ const data = await readFile(filePath);
31
+ return createHash('sha256').update(data).digest('hex');
32
+ }
33
+
34
+ /**
35
+ * Load an adapter module by name.
36
+ */
37
+ async function loadAdapter(adapterTarget) {
38
+ const adapterPath = `./adapters/${adapterTarget}.js`;
39
+ try {
40
+ return await import(adapterPath);
41
+ } catch {
42
+ throw new Error(
43
+ `Adapter "${adapterTarget}" not found. Available: generic-image-caption, diffusers-lora`
44
+ );
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Build a training package from a manifest.
50
+ *
51
+ * @param {string} projectRoot
52
+ * @param {string} manifestId
53
+ * @param {Object} options — { copy?, adapterOverride? }
54
+ * @returns {Promise<{packageId: string, records: number, images: number}>}
55
+ */
56
+ export async function buildTrainingPackage(projectRoot, manifestId, options = {}) {
57
+ const manifest = await loadTrainingManifest(projectRoot, manifestId);
58
+ const profile = await loadTrainingProfile(projectRoot, manifest.training_profile_id);
59
+ const config = loadProjectConfig(projectRoot);
60
+ const adapterTarget = options.adapterOverride || manifest.adapter_target;
61
+ const adapter = await loadAdapter(adapterTarget);
62
+
63
+ const recordsDir = join(projectRoot, 'records');
64
+ const packageId = generatePackageId();
65
+ const packageDir = join(projectRoot, 'training', 'packages', packageId);
66
+
67
+ // Load split partitions
68
+ const partitions = {};
69
+ let totalRecords = 0;
70
+ let totalImages = 0;
71
+
72
+ for (const partition of ['train', 'val', 'test']) {
73
+ const entries = await loadSplitPartition(projectRoot, manifest.source_split_id, partition);
74
+ const records = [];
75
+
76
+ for (const entry of entries) {
77
+ const record = await loadRecord(recordsDir, entry.record_id);
78
+ if (!record) continue;
79
+
80
+ // Filter by profile's eligible lanes
81
+ const prompt = record.provenance?.prompt || '';
82
+ const lane = detectLane(record.id, prompt, config.lanes);
83
+ if (profile.eligible_lanes.length > 0 && !profile.eligible_lanes.includes(lane)) {
84
+ continue;
85
+ }
86
+
87
+ const group = detectGroup(record.id, prompt, config.terminology);
88
+ records.push({ record, lane, group });
89
+ }
90
+
91
+ partitions[partition] = records;
92
+ totalRecords += records.length;
93
+ }
94
+
95
+ // Let the adapter build the package layout
96
+ const adapterResult = await adapter.buildPackage({
97
+ packageDir,
98
+ partitions,
99
+ profile,
100
+ manifest,
101
+ config,
102
+ projectRoot,
103
+ copy: options.copy || false,
104
+ });
105
+
106
+ totalImages = adapterResult.imageCount || 0;
107
+
108
+ // Generate checksums for all files in the package
109
+ const checksumEntries = [];
110
+ const allFiles = await collectFiles(packageDir);
111
+ for (const filePath of allFiles.sort()) {
112
+ const relPath = relative(packageDir, filePath);
113
+ if (relPath === 'checksums.txt') continue;
114
+ const hash = await sha256File(filePath);
115
+ checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
116
+ }
117
+ await writeFile(join(packageDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
118
+
119
+ // Write package manifest (last — references everything)
120
+ const packageManifest = {
121
+ training_package_id: packageId,
122
+ created_at: new Date().toISOString(),
123
+ created_by: 'sdlab-training-package-v1',
124
+ training_manifest_id: manifestId,
125
+ training_profile_id: manifest.training_profile_id,
126
+ adapter_target: adapterTarget,
127
+ source_export_id: manifest.source_export_id,
128
+ source_snapshot_id: manifest.source_snapshot_id,
129
+ source_split_id: manifest.source_split_id,
130
+ config_fingerprint: manifest.config_fingerprint,
131
+ counts: {
132
+ total_records: totalRecords,
133
+ train: partitions.train.length,
134
+ val: partitions.val.length,
135
+ test: partitions.test.length,
136
+ images: totalImages,
137
+ checksums: checksumEntries.length,
138
+ },
139
+ };
140
+ await writeFile(join(packageDir, 'manifest.json'), JSON.stringify(packageManifest, null, 2) + '\n');
141
+
142
+ // Write README
143
+ const readme = generatePackageReadme(packageManifest, profile, manifest, adapterTarget);
144
+ await writeFile(join(packageDir, 'README.md'), readme);
145
+
146
+ return { packageId, records: totalRecords, images: totalImages };
147
+ }
148
+
149
+ function generatePackageReadme(pkg, profile, manifest, adapter) {
150
+ return `# Training Package: ${pkg.training_package_id}
151
+
152
+ **Profile:** ${profile.label} (${profile.profile_id})
153
+ **Adapter:** ${adapter}
154
+ **Target:** ${profile.target_family} / ${profile.asset_type}
155
+ **Created:** ${pkg.created_at}
156
+
157
+ ## Source chain
158
+
159
+ - Training manifest: \`${manifest.training_manifest_id}\`
160
+ - Export: \`${manifest.source_export_id}\`
161
+ - Snapshot: \`${manifest.source_snapshot_id}\`
162
+ - Split: \`${manifest.source_split_id}\`
163
+ - Config fingerprint: \`${manifest.config_fingerprint.slice(0, 16)}...\`
164
+
165
+ ## Counts
166
+
167
+ | Partition | Records |
168
+ |-----------|---------|
169
+ | Train | ${pkg.counts.train} |
170
+ | Val | ${pkg.counts.val} |
171
+ | Test | ${pkg.counts.test} |
172
+ | **Total** | **${pkg.counts.total_records}** |
173
+
174
+ Images: ${pkg.counts.images}
175
+
176
+ ## Checksums
177
+
178
+ All files in this package are checksummed in \`checksums.txt\` (BSD format).
179
+
180
+ ## Provenance
181
+
182
+ This package was produced by [Style Dataset Lab](https://github.com/mcp-tool-shop-org/style-dataset-lab).
183
+ Every included record has been human-reviewed, canon-bound, and verified against project constitution rules.
184
+ The dataset split ensures no subject family appears in multiple partitions.
185
+ `;
186
+ }
187
+
188
+ async function collectFiles(dir) {
189
+ const entries = await readdir(dir, { withFileTypes: true });
190
+ const files = [];
191
+ for (const entry of entries) {
192
+ const full = join(dir, entry.name);
193
+ if (entry.isDirectory()) {
194
+ files.push(...await collectFiles(full));
195
+ } else {
196
+ files.push(full);
197
+ }
198
+ }
199
+ return files;
200
+ }
201
+
202
+ /**
203
+ * List all training packages.
204
+ */
205
+ export async function listTrainingPackages(projectRoot) {
206
+ const dir = join(projectRoot, 'training', 'packages');
207
+ if (!existsSync(dir)) return [];
208
+
209
+ const entries = await readdir(dir, { withFileTypes: true });
210
+ const packages = [];
211
+
212
+ for (const entry of entries) {
213
+ if (!entry.isDirectory() || !entry.name.startsWith('tp-')) continue;
214
+ const manifestPath = join(dir, entry.name, 'manifest.json');
215
+ if (!existsSync(manifestPath)) continue;
216
+ try {
217
+ const data = JSON.parse(await readFile(manifestPath, 'utf-8'));
218
+ packages.push({
219
+ id: data.training_package_id,
220
+ created_at: data.created_at,
221
+ profile: data.training_profile_id,
222
+ adapter: data.adapter_target,
223
+ records: data.counts.total_records,
224
+ images: data.counts.images,
225
+ });
226
+ } catch { /* skip */ }
227
+ }
228
+
229
+ return packages.sort((a, b) => a.created_at.localeCompare(b.created_at));
230
+ }
231
+
232
+ /**
233
+ * Load a training package manifest.
234
+ */
235
+ export async function loadTrainingPackage(projectRoot, packageId) {
236
+ const path = join(projectRoot, 'training', 'packages', packageId, 'manifest.json');
237
+ if (!existsSync(path)) {
238
+ throw new Error(`Training package "${packageId}" not found at ${path}`);
239
+ }
240
+ return JSON.parse(await readFile(path, 'utf-8'));
241
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Training profile management.
3
+ *
4
+ * A training profile defines how a project wants to turn an export package
5
+ * into a trainable model asset. Profiles never select data directly — they
6
+ * define eligibility rules and packaging expectations.
7
+ */
8
+
9
+ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+
13
+ const REQUIRED_FIELDS = ['profile_id', 'label', 'target_family', 'asset_type', 'eligible_lanes', 'adapter_targets'];
14
+
15
+ /**
16
+ * Validate a training profile object.
17
+ * @returns {{ valid: boolean, errors: string[] }}
18
+ */
19
+ export function validateProfile(profile) {
20
+ const errors = [];
21
+ for (const field of REQUIRED_FIELDS) {
22
+ if (profile[field] === undefined || profile[field] === null) {
23
+ errors.push(`missing required field: ${field}`);
24
+ }
25
+ }
26
+ if (profile.eligible_lanes && !Array.isArray(profile.eligible_lanes)) {
27
+ errors.push('eligible_lanes must be an array');
28
+ }
29
+ if (profile.adapter_targets && !Array.isArray(profile.adapter_targets)) {
30
+ errors.push('adapter_targets must be an array');
31
+ }
32
+ if (profile.adapter_targets?.length === 0) {
33
+ errors.push('adapter_targets must have at least one target');
34
+ }
35
+ return { valid: errors.length === 0, errors };
36
+ }
37
+
38
+ /**
39
+ * Load a training profile from a project.
40
+ */
41
+ export async function loadTrainingProfile(projectRoot, profileId) {
42
+ const path = join(projectRoot, 'training', 'profiles', `${profileId}.json`);
43
+ if (!existsSync(path)) {
44
+ throw new Error(`Training profile "${profileId}" not found at ${path}`);
45
+ }
46
+ const data = JSON.parse(await readFile(path, 'utf-8'));
47
+ const { valid, errors } = validateProfile(data);
48
+ if (!valid) {
49
+ throw new Error(`Training profile "${profileId}" is invalid:\n ${errors.join('\n ')}`);
50
+ }
51
+ return data;
52
+ }
53
+
54
+ /**
55
+ * List all training profiles in a project.
56
+ */
57
+ export async function listTrainingProfiles(projectRoot) {
58
+ const dir = join(projectRoot, 'training', 'profiles');
59
+ if (!existsSync(dir)) return [];
60
+
61
+ const files = (await readdir(dir)).filter(f => f.endsWith('.json')).sort();
62
+ const profiles = [];
63
+
64
+ for (const file of files) {
65
+ try {
66
+ const data = JSON.parse(await readFile(join(dir, file), 'utf-8'));
67
+ profiles.push({
68
+ profile_id: data.profile_id,
69
+ label: data.label,
70
+ target_family: data.target_family,
71
+ asset_type: data.asset_type,
72
+ lanes: data.eligible_lanes?.length || 0,
73
+ adapters: data.adapter_targets || [],
74
+ });
75
+ } catch {
76
+ // Skip malformed
77
+ }
78
+ }
79
+
80
+ return profiles;
81
+ }
82
+
83
+ /**
84
+ * Save a training profile to a project.
85
+ */
86
+ export async function saveTrainingProfile(projectRoot, profile) {
87
+ const { valid, errors } = validateProfile(profile);
88
+ if (!valid) {
89
+ throw new Error(`Invalid training profile:\n ${errors.join('\n ')}`);
90
+ }
91
+ const dir = join(projectRoot, 'training', 'profiles');
92
+ await mkdir(dir, { recursive: true });
93
+ await writeFile(
94
+ join(dir, `${profile.profile_id}.json`),
95
+ JSON.stringify(profile, null, 2) + '\n'
96
+ );
97
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "2.0.0",
4
- "description": "Canon-bound visual dataset pipeline — define project canon, generate and curate assets, bind to constitution rules, export trustworthy training data",
3
+ "version": "2.2.0",
4
+ "description": "Visual canon and dataset production pipeline — snapshots, splits, versioned exports, eval packs, and canon-bound curation for any image production domain",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sdlab": "./bin/sdlab.js"
@@ -28,16 +28,18 @@
28
28
  ],
29
29
  "keywords": [
30
30
  "dataset",
31
- "visual",
31
+ "computer-vision",
32
32
  "training-data",
33
+ "image-dataset",
34
+ "evaluation",
35
+ "curation",
36
+ "canon",
37
+ "visual-development",
33
38
  "concept-art",
34
- "visual-canon",
35
- "art-direction",
36
- "dataset-pipeline",
37
- "comfyui",
38
- "vlm",
39
- "fine-tuning",
40
- "multimodal"
39
+ "style-guide",
40
+ "lora",
41
+ "multimodal",
42
+ "comfyui"
41
43
  ],
42
44
  "homepage": "https://mcp-tool-shop-org.github.io/style-dataset-lab/",
43
45
  "repository": {
@@ -20,93 +20,7 @@ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
20
20
  import { join } from 'path';
21
21
  import { getProjectName } from '../lib/args.js';
22
22
  import { REPO_ROOT } from '../lib/paths.js';
23
- import { loadConstitution, loadLanes, loadRubric, loadTerminology } from '../lib/config.js';
24
-
25
- // ─── Config-Driven Detection ────────────────────────────────────────
26
-
27
- /**
28
- * Detect lane from asset ID using project lane config.
29
- * Tests each lane's id_patterns as regex against the lowercase ID.
30
- * Returns the first match, or the default_lane.
31
- */
32
- function detectLane(id, prompt, lanesConfig) {
33
- const lower = id.toLowerCase();
34
- for (const lane of lanesConfig.lanes) {
35
- if (!lane.id_patterns || lane.id_patterns.length === 0) continue;
36
- for (const pattern of lane.id_patterns) {
37
- if (new RegExp(pattern).test(lower)) return lane.id;
38
- }
39
- }
40
- return lanesConfig.default_lane;
41
- }
42
-
43
- /**
44
- * Detect group (faction) from asset ID and prompt using terminology config.
45
- * Mirrors the exact detection order from the original hardcoded detectFaction():
46
- * 1. Primary ID patterns per group
47
- * 2. Cross-group patterns (e.g. ^cf_ → detect from ID suffix)
48
- * 3. Edge defaults (e.g. ^edge_ → compact)
49
- * 4. Prompt-based fallback patterns
50
- * 5. Null-faction patterns (generic rejects with no faction)
51
- * 6. ID fallback patterns (specific reject/borderline IDs)
52
- */
53
- function detectGroup(id, prompt, termConfig) {
54
- // Explicit ordering for ID-based and prompt-based detection phases.
55
- // These can differ — e.g. ID checks reach before keth, but prompt
56
- // checks veshan before keth before orryn before reach.
57
- const idOrder = termConfig.id_detection_order || Object.keys(termConfig.groups);
58
- const promptOrder = termConfig.prompt_detection_order || Object.keys(termConfig.groups);
59
-
60
- // 1. Primary ID patterns per group (id_detection_order)
61
- for (const groupName of idOrder) {
62
- const group = termConfig.groups[groupName];
63
- if (!group?.id_patterns) continue;
64
- for (const pattern of group.id_patterns) {
65
- if (new RegExp(pattern).test(id)) return groupName;
66
- }
67
- }
68
-
69
- // 2. Cross-group patterns
70
- for (const [pattern, behavior] of Object.entries(termConfig.cross_group_patterns || {})) {
71
- if (new RegExp(pattern).test(id)) {
72
- if (behavior === 'detect_from_id_suffix') {
73
- for (const groupName of idOrder) {
74
- if (id.includes(groupName)) return groupName;
75
- }
76
- }
77
- return null;
78
- }
79
- }
80
-
81
- // 3. Edge defaults
82
- for (const [pattern, groupName] of Object.entries(termConfig.edge_defaults || {})) {
83
- if (new RegExp(pattern).test(id)) return groupName;
84
- }
85
-
86
- // 4. Prompt-based fallback (prompt_detection_order)
87
- if (prompt) {
88
- const p = prompt.toLowerCase();
89
- for (const groupName of promptOrder) {
90
- const group = termConfig.groups[groupName];
91
- if (!group?.prompt_patterns || group.prompt_patterns.length === 0) continue;
92
- for (const keyword of group.prompt_patterns) {
93
- if (new RegExp(keyword).test(p)) return groupName;
94
- }
95
- }
96
- }
97
-
98
- // 5. Null-faction patterns (generic rejects — no faction)
99
- for (const pattern of termConfig.null_faction_patterns || []) {
100
- if (new RegExp(pattern).test(id)) return null;
101
- }
102
-
103
- // 6. ID fallback patterns
104
- for (const [pattern, groupName] of Object.entries(termConfig.id_fallbacks || {})) {
105
- if (new RegExp(pattern).test(id)) return groupName;
106
- }
107
-
108
- return null;
109
- }
23
+ import { loadConstitution, loadLanes, loadRubric, loadTerminology, detectLane, detectGroup } from '../lib/config.js';
110
24
 
111
25
  /**
112
26
  * Build rationale string from rule's template, interpolating variables.
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * card.js — Generate dataset cards.
5
+ *
6
+ * Usage:
7
+ * sdlab card generate --snapshot <id> --split <id> [--project <name>]
8
+ */
9
+
10
+ import { join } from 'node:path';
11
+ import { writeFile } from 'node:fs/promises';
12
+ import { getProjectName } from '../lib/args.js';
13
+ import { REPO_ROOT } from '../lib/paths.js';
14
+ import { listSnapshots } from '../lib/snapshot.js';
15
+ import { listSplits } from '../lib/split.js';
16
+ import { generateCard } from '../lib/card.js';
17
+
18
+ export async function run(argv = process.argv.slice(2)) {
19
+ const projectName = getProjectName(argv);
20
+ const projectRoot = join(REPO_ROOT, 'projects', projectName);
21
+ const subcommand = argv.find(a => !a.startsWith('--')) || 'generate';
22
+ const args = argv.filter(a => a !== subcommand);
23
+
24
+ if (subcommand === 'generate') {
25
+ // Find snapshot
26
+ let snapshotId;
27
+ const snapIdx = args.indexOf('--snapshot');
28
+ if (snapIdx >= 0) {
29
+ snapshotId = args[snapIdx + 1];
30
+ } else {
31
+ const snapshots = await listSnapshots(projectRoot);
32
+ if (snapshots.length === 0) throw new Error('No snapshots found. Run: sdlab snapshot create');
33
+ snapshotId = snapshots[snapshots.length - 1].id;
34
+ }
35
+
36
+ // Find split
37
+ let splitId;
38
+ const splitIdx = args.indexOf('--split');
39
+ if (splitIdx >= 0) {
40
+ splitId = args[splitIdx + 1];
41
+ } else {
42
+ const splits = await listSplits(projectRoot);
43
+ if (splits.length === 0) throw new Error('No splits found. Run: sdlab split build');
44
+ splitId = splits[splits.length - 1].id;
45
+ }
46
+
47
+ console.log(`\x1b[1msdlab card generate\x1b[0m — ${projectName}`);
48
+ console.log(` Snapshot: ${snapshotId}`);
49
+ console.log(` Split: ${splitId}`);
50
+ console.log('');
51
+
52
+ const { markdown, json } = await generateCard(projectRoot, snapshotId, splitId);
53
+
54
+ // Write to project root
55
+ const mdPath = join(projectRoot, 'dataset-card.md');
56
+ const jsonPath = join(projectRoot, 'dataset-card.json');
57
+ await writeFile(mdPath, markdown);
58
+ await writeFile(jsonPath, JSON.stringify(json, null, 2) + '\n');
59
+
60
+ console.log(` \x1b[32m✓\x1b[0m dataset-card.md`);
61
+ console.log(` \x1b[32m✓\x1b[0m dataset-card.json`);
62
+
63
+ } else {
64
+ throw new Error(`Unknown subcommand: ${subcommand}. Use: generate`);
65
+ }
66
+ }
67
+
68
+ if (process.argv[1] && (process.argv[1].endsWith('card.js') || process.argv[1].endsWith('card'))) {
69
+ run().catch((err) => {
70
+ console.error(err.message || err);
71
+ process.exit(1);
72
+ });
73
+ }
package/scripts/doctor.js CHANGED
@@ -33,6 +33,14 @@ const REQUIRED_DIRS = [
33
33
  'outputs/approved',
34
34
  'outputs/rejected',
35
35
  'exports',
36
+ 'snapshots',
37
+ 'splits',
38
+ 'eval-packs',
39
+ 'training/profiles',
40
+ 'training/manifests',
41
+ 'training/packages',
42
+ 'training/eval-runs',
43
+ 'training/implementations',
36
44
  ];
37
45
 
38
46
  function pass(msg) { console.log(` \x1b[32m✓\x1b[0m ${msg}`); }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * eligibility.js — Audit training/eval eligibility for all project records.
5
+ *
6
+ * Usage:
7
+ * sdlab eligibility audit [--project <name>]
8
+ */
9
+
10
+ import { readdir, readFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { getProjectName } from '../lib/args.js';
13
+ import { REPO_ROOT } from '../lib/paths.js';
14
+ import { loadProjectConfig, loadSelectionProfile } from '../lib/config.js';
15
+ import { evaluateEligibility, categorizeExclusions } from '../lib/eligibility.js';
16
+
17
+ export async function run(argv = process.argv.slice(2)) {
18
+ const projectName = getProjectName(argv);
19
+ const projectRoot = join(REPO_ROOT, 'projects', projectName);
20
+
21
+ const profileIdx = argv.indexOf('--profile');
22
+ const profileId = profileIdx >= 0 ? argv[profileIdx + 1] : null;
23
+ const profile = loadSelectionProfile(projectRoot, profileId);
24
+ const config = loadProjectConfig(projectRoot);
25
+
26
+ const recordsDir = join(projectRoot, 'records');
27
+ const files = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
28
+
29
+ console.log(`\x1b[1msdlab eligibility audit\x1b[0m — ${projectName}`);
30
+ console.log(` Profile: ${profileId || 'training-default (built-in)'}`);
31
+ console.log(` Records: ${files.length}`);
32
+ console.log('');
33
+
34
+ const eligible = [];
35
+ const excluded = [];
36
+ const nearMiss = []; // Records with exactly 1 failing reason
37
+
38
+ for (const file of files) {
39
+ const raw = await readFile(join(recordsDir, file), 'utf-8');
40
+ const record = JSON.parse(raw);
41
+
42
+ const result = evaluateEligibility(record, profile, config.lanes, config.terminology);
43
+
44
+ if (result.eligible) {
45
+ eligible.push(record.id);
46
+ } else {
47
+ excluded.push({ record_id: record.id, reasons: result.reasons });
48
+ if (result.reasons.length === 1) {
49
+ nearMiss.push({ record_id: record.id, reason: result.reasons[0] });
50
+ }
51
+ }
52
+ }
53
+
54
+ console.log(` \x1b[32m✓\x1b[0m Eligible: ${eligible.length}`);
55
+ console.log(` \x1b[31m✗\x1b[0m Excluded: ${excluded.length}`);
56
+ console.log('');
57
+
58
+ // Categorize exclusions
59
+ const categories = categorizeExclusions(excluded);
60
+ console.log(' Exclusion breakdown:');
61
+ for (const [cat, count] of Object.entries(categories)) {
62
+ if (count > 0) {
63
+ const bar = '█'.repeat(Math.min(Math.round(count / files.length * 50), 50));
64
+ console.log(` ${cat.padEnd(20)} ${String(count).padStart(4)} ${bar}`);
65
+ }
66
+ }
67
+
68
+ // Near-miss improvement opportunities
69
+ if (nearMiss.length > 0) {
70
+ console.log(`\n Improvement opportunities (${nearMiss.length} records with 1 failing check):`);
71
+ const byReason = {};
72
+ for (const nm of nearMiss) {
73
+ byReason[nm.reason] = (byReason[nm.reason] || 0) + 1;
74
+ }
75
+ for (const [reason, count] of Object.entries(byReason).sort((a, b) => b[1] - a[1]).slice(0, 5)) {
76
+ console.log(` ${count} records: ${reason}`);
77
+ }
78
+ }
79
+
80
+ console.log('');
81
+ const pct = ((eligible.length / files.length) * 100).toFixed(1);
82
+ console.log(` Eligibility rate: ${pct}% (${eligible.length}/${files.length})`);
83
+ }
84
+
85
+ // Direct execution guard
86
+ if (process.argv[1] && (process.argv[1].endsWith('eligibility.js') || process.argv[1].endsWith('eligibility'))) {
87
+ run().catch((err) => {
88
+ console.error(err.message || err);
89
+ process.exit(1);
90
+ });
91
+ }