@mcptoolshop/style-dataset-lab 3.0.0 → 3.0.1

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 (88) hide show
  1. package/CHANGELOG.md +68 -15
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.md +46 -1
  5. package/SECURITY.md +6 -5
  6. package/bin/sdlab.js +281 -62
  7. package/lib/adapters/diffusers-lora.js +7 -2
  8. package/lib/adapters/generic-image-caption.js +6 -1
  9. package/lib/args.js +200 -13
  10. package/lib/batch-compiler.js +7 -3
  11. package/lib/batch-runs.js +100 -31
  12. package/lib/batch-sheet-render.js +5 -2
  13. package/lib/brief-compiler.js +38 -11
  14. package/lib/card.js +11 -4
  15. package/lib/comfyui.js +100 -6
  16. package/lib/config.js +68 -3
  17. package/lib/deprecation.js +1 -1
  18. package/lib/eval-pack.js +94 -4
  19. package/lib/eval-runs.js +42 -3
  20. package/lib/export.js +144 -34
  21. package/lib/implementation-packs.js +15 -1
  22. package/lib/log.js +41 -4
  23. package/lib/paths.js +41 -3
  24. package/lib/refine-briefs.js +16 -9
  25. package/lib/reingest-selected.js +14 -3
  26. package/lib/reingest.js +15 -1
  27. package/lib/run-summary.js +3 -2
  28. package/lib/runtime-runs.js +59 -9
  29. package/lib/selections.js +54 -2
  30. package/lib/snapshot.js +100 -7
  31. package/lib/split.js +175 -17
  32. package/lib/training-manifests.js +17 -3
  33. package/lib/training-packages.js +28 -14
  34. package/package.json +8 -2
  35. package/scripts/batch-generate.js +16 -7
  36. package/scripts/batch-sheet.js +3 -3
  37. package/scripts/batch-show.js +3 -3
  38. package/scripts/brief-compile.js +8 -8
  39. package/scripts/brief-show.js +4 -3
  40. package/scripts/canon-bind.js +7 -9
  41. package/scripts/card.js +22 -25
  42. package/scripts/compare.js +38 -33
  43. package/scripts/critique-show.js +3 -3
  44. package/scripts/critique.js +3 -3
  45. package/scripts/curate.js +91 -46
  46. package/scripts/doctor.js +3 -5
  47. package/scripts/eligibility.js +17 -14
  48. package/scripts/eval-pack.js +13 -11
  49. package/scripts/eval-run.js +30 -29
  50. package/scripts/export.js +27 -31
  51. package/scripts/generate-controlnet.js +31 -17
  52. package/scripts/generate-identity.js +37 -20
  53. package/scripts/generate-ipadapter.js +22 -16
  54. package/scripts/generate.js +68 -13
  55. package/scripts/implementation-pack.js +19 -14
  56. package/scripts/init.js +115 -17
  57. package/scripts/painterly.js +8 -10
  58. package/scripts/refine.js +3 -3
  59. package/scripts/reingest-selected.js +3 -3
  60. package/scripts/reingest.js +33 -20
  61. package/scripts/run-generate.js +4 -4
  62. package/scripts/run-list.js +3 -3
  63. package/scripts/run-show.js +3 -3
  64. package/scripts/select.js +5 -4
  65. package/scripts/selection-show.js +3 -3
  66. package/scripts/snapshot.js +22 -24
  67. package/scripts/split.js +26 -31
  68. package/scripts/training-manifest.js +29 -24
  69. package/scripts/training-package.js +24 -20
  70. package/scripts/training-profile.js +13 -8
  71. package/scripts/workflow-list.js +2 -2
  72. package/scripts/workflow-show.js +4 -3
  73. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  74. package/templates/domains/architecture/terminology.json +10 -8
  75. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  76. package/templates/domains/character-design/project.json +4 -2
  77. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  78. package/templates/domains/creature-design/project.json +4 -2
  79. package/templates/domains/creature-design/terminology.json +1 -0
  80. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  81. package/templates/domains/game-art/project.json +4 -2
  82. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  83. package/templates/domains/vehicle-mech/project.json +4 -2
  84. package/scripts/bulk-curate-wave2-5.js +0 -138
  85. package/scripts/bulk-curate-waves11-18.js +0 -413
  86. package/scripts/curate-wave25.js +0 -209
  87. package/scripts/migrate-records.js +0 -141
  88. package/templates/inputs/prompts/example-wave.json +0 -27
package/lib/comfyui.js CHANGED
@@ -6,9 +6,15 @@
6
6
  */
7
7
 
8
8
  import { readFile } from 'node:fs/promises';
9
+ import { info } from './log.js';
9
10
 
10
11
  const DEFAULT_URL = 'http://127.0.0.1:8188';
11
12
  const MAX_POLL_MS = 600_000; // 10 minutes
13
+ const HEARTBEAT_MS = 15_000; // emit a heartbeat at most every 15s
14
+ // PB-004: if we see an unfamiliar status shape for this many consecutive
15
+ // polls without completed/error, bail with a descriptive message rather
16
+ // than hang until MAX_POLL_MS.
17
+ const UNKNOWN_STATUS_BAIL_POLLS = 30; // ~30s of unrecognized shape
12
18
 
13
19
  /**
14
20
  * Check if ComfyUI is reachable.
@@ -48,21 +54,102 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
48
54
 
49
55
  const { prompt_id: promptId } = await res.json();
50
56
 
57
+ const MAX_CONSECUTIVE_HISTORY_FAILURES = 5;
58
+ let consecutiveHistoryFailures = 0;
59
+ // PB-001: heartbeats
60
+ let lastHeartbeatAt = Date.now();
61
+ let announcedStart = false;
62
+ let seenEntry = false;
63
+ // PB-004: count polls where entry exists but status shape is unfamiliar
64
+ let unknownStatusPolls = 0;
65
+ let lastObservedStatus = null;
51
66
  const start = Date.now();
52
67
  while (true) {
53
- if (Date.now() - start > timeoutMs) {
68
+ const now = Date.now();
69
+ if (now - start > timeoutMs) {
54
70
  throw new Error(`ComfyUI poll timeout after ${timeoutMs / 1000}s for prompt ${promptId}`);
55
71
  }
56
72
  await new Promise(r => setTimeout(r, 1000));
57
- const histRes = await fetch(`${comfyUrl}/history/${promptId}`);
58
- if (!histRes.ok) continue;
73
+ let histRes;
74
+ try {
75
+ histRes = await fetch(`${comfyUrl}/history/${promptId}`);
76
+ } catch (err) {
77
+ consecutiveHistoryFailures++;
78
+ if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
79
+ throw new Error(
80
+ `ComfyUI history endpoint unreachable after ${MAX_CONSECUTIVE_HISTORY_FAILURES} consecutive attempts for prompt ${promptId}: ${err.message}`
81
+ );
82
+ }
83
+ continue;
84
+ }
85
+ if (!histRes.ok) {
86
+ consecutiveHistoryFailures++;
87
+ if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
88
+ throw new Error(
89
+ `ComfyUI history endpoint returned ${histRes.status} ${MAX_CONSECUTIVE_HISTORY_FAILURES} times in a row for prompt ${promptId}`
90
+ );
91
+ }
92
+ continue;
93
+ }
94
+ consecutiveHistoryFailures = 0;
59
95
  const history = await histRes.json();
60
96
  const entry = history[promptId];
61
- if (!entry) continue;
62
- if (entry.status?.completed) return entry;
97
+
98
+ // PB-001: emit heartbeats so the user knows the pipeline isn't hung.
99
+ const elapsedSec = Math.round((Date.now() - start) / 1000);
100
+ const heartbeatDue = Date.now() - lastHeartbeatAt >= HEARTBEAT_MS;
101
+ if (!entry) {
102
+ // Queued on ComfyUI but not yet visible in /history.
103
+ if (!announcedStart) {
104
+ info(`ComfyUI: queued (prompt ${promptId})`);
105
+ announcedStart = true;
106
+ lastHeartbeatAt = Date.now();
107
+ } else if (heartbeatDue) {
108
+ info(`ComfyUI: queued, waiting... (${elapsedSec}s)`);
109
+ lastHeartbeatAt = Date.now();
110
+ }
111
+ continue;
112
+ }
113
+
114
+ if (!seenEntry) {
115
+ seenEntry = true;
116
+ info(`ComfyUI: generating... (prompt ${promptId})`);
117
+ lastHeartbeatAt = Date.now();
118
+ } else if (heartbeatDue) {
119
+ info(`ComfyUI: generating... (${elapsedSec}s elapsed)`);
120
+ lastHeartbeatAt = Date.now();
121
+ }
122
+
123
+ // PB-004: success signals — accept both completed===true and status_str==='success'
124
+ // (future-proof for schema drift where one may change without the other).
125
+ if (entry.status?.completed === true || entry.status?.status_str === 'success') {
126
+ info(`ComfyUI: completed in ${elapsedSec}s`);
127
+ return entry;
128
+ }
129
+
130
+ // PB-004: known failure signals — bail immediately.
63
131
  if (entry.status?.status_str === 'error') {
64
132
  throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status)}`);
65
133
  }
134
+ if (entry.status?.execution_error || entry.execution_error) {
135
+ const errObj = entry.status?.execution_error || entry.execution_error;
136
+ throw new Error(`ComfyUI execution error for prompt ${promptId}: ${JSON.stringify(errObj)}`);
137
+ }
138
+
139
+ // PB-004: the entry exists but matches neither a known success nor
140
+ // a known failure. This is the schema-drift hang scenario — count
141
+ // consecutive unknown-shape polls and bail with context.
142
+ const statusStr = entry.status?.status_str ?? null;
143
+ lastObservedStatus = entry.status ?? null;
144
+ unknownStatusPolls++;
145
+ if (unknownStatusPolls >= UNKNOWN_STATUS_BAIL_POLLS) {
146
+ throw new Error(
147
+ `ComfyUI returned an unrecognized status shape for prompt ${promptId} ` +
148
+ `after ${unknownStatusPolls} polls (status_str=${JSON.stringify(statusStr)}). ` +
149
+ `This usually means the ComfyUI history schema changed. ` +
150
+ `Observed status: ${JSON.stringify(lastObservedStatus)}`
151
+ );
152
+ }
66
153
  }
67
154
  }
68
155
 
@@ -75,7 +162,14 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
75
162
  * @returns {Promise<Buffer>}
76
163
  */
77
164
  export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output') {
78
- const url = `${comfyUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || '')}&type=${type}`;
165
+ const ALLOWED_TYPES = ['output', 'input', 'temp'];
166
+ if (!ALLOWED_TYPES.includes(type)) {
167
+ throw new Error(`Invalid ComfyUI image type "${type}" — must be one of: ${ALLOWED_TYPES.join(', ')}`);
168
+ }
169
+ if (typeof comfyUrl !== 'string' || comfyUrl.length === 0 || !/^https?:\/\//i.test(comfyUrl)) {
170
+ throw new Error(`Invalid comfyUrl "${comfyUrl}" — must start with http:// or https://`);
171
+ }
172
+ const url = `${comfyUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || '')}&type=${encodeURIComponent(type)}`;
79
173
  const res = await fetch(url);
80
174
  if (!res.ok) throw new Error(`Download failed: ${res.status}`);
81
175
  return Buffer.from(await res.arrayBuffer());
package/lib/config.js CHANGED
@@ -7,17 +7,70 @@
7
7
  */
8
8
 
9
9
  import { readFileSync, existsSync } from 'node:fs';
10
+ import { readFile } from 'node:fs/promises';
10
11
  import { join } from 'node:path';
12
+ import { inputError, runtimeError } from './errors.js';
11
13
 
12
14
  function loadJsonFile(filePath) {
13
15
  if (!existsSync(filePath)) return null;
14
16
  try {
15
17
  return JSON.parse(readFileSync(filePath, 'utf-8'));
16
18
  } catch (err) {
17
- throw new Error(`Failed to parse ${filePath}: ${err.message}`);
19
+ throw runtimeError(
20
+ 'INPUT_INVALID_JSON',
21
+ `Failed to parse ${filePath}: ${err.message}`,
22
+ 'Check the file for JSON syntax errors (trailing commas, unquoted keys, etc).',
23
+ err
24
+ );
18
25
  }
19
26
  }
20
27
 
28
+ /**
29
+ * Async-safe readFile + JSON.parse with structured error handling.
30
+ * Optionally validates that required top-level fields are present.
31
+ *
32
+ * @param {string} filePath — absolute path to a JSON file
33
+ * @param {{requiredFields?: string[]}} [opts]
34
+ */
35
+ export async function readJsonFile(filePath, opts = {}) {
36
+ let raw;
37
+ try {
38
+ raw = await readFile(filePath, 'utf-8');
39
+ } catch (err) {
40
+ if (err && err.code === 'ENOENT') {
41
+ throw inputError(
42
+ 'INPUT_FILE_NOT_FOUND',
43
+ `File not found: ${filePath}`,
44
+ 'Check the path and try again.'
45
+ );
46
+ }
47
+ throw runtimeError('RUNTIME_READ_FAILED', `Could not read ${filePath}: ${err.message}`, null, err);
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ } catch (err) {
53
+ throw runtimeError(
54
+ 'INPUT_INVALID_JSON',
55
+ `Failed to parse ${filePath}: ${err.message}`,
56
+ 'Check the file for JSON syntax errors.',
57
+ err
58
+ );
59
+ }
60
+ const required = opts.requiredFields || [];
61
+ if (required.length) {
62
+ const missing = required.filter((f) => parsed == null || parsed[f] === undefined);
63
+ if (missing.length) {
64
+ throw inputError(
65
+ 'INPUT_MISSING_FIELDS',
66
+ `${filePath} is missing required fields: ${missing.join(', ')}`,
67
+ `Add the fields to the file, or use a valid example as template.`
68
+ );
69
+ }
70
+ }
71
+ return parsed;
72
+ }
73
+
21
74
  /**
22
75
  * Load project.json — project identity and generation defaults.
23
76
  */
@@ -42,7 +95,13 @@ export function loadConstitution(projectRoot) {
42
95
  const data = loadJsonFile(join(projectRoot, 'constitution.json'));
43
96
  if (!data) return { rules: [] };
44
97
  if (Array.isArray(data)) return { rules: data };
45
- if (!data.rules) throw new Error(`constitution.json must have a "rules" array`);
98
+ if (!data.rules) {
99
+ throw inputError(
100
+ 'INPUT_BAD_CONFIG',
101
+ `constitution.json must have a "rules" array`,
102
+ 'Wrap your rules in { "rules": [...] } or use an array at the top level.'
103
+ );
104
+ }
46
105
  return data;
47
106
  }
48
107
 
@@ -54,7 +113,13 @@ export function loadLanes(projectRoot) {
54
113
  if (!data) {
55
114
  return { default_lane: 'default', lanes: [] };
56
115
  }
57
- if (!data.lanes) throw new Error(`lanes.json must have a "lanes" array`);
116
+ if (!data.lanes) {
117
+ throw inputError(
118
+ 'INPUT_BAD_CONFIG',
119
+ `lanes.json must have a "lanes" array`,
120
+ 'Top-level shape: { "default_lane": "...", "lanes": [ ... ] }.'
121
+ );
122
+ }
58
123
  return data;
59
124
  }
60
125
 
@@ -9,6 +9,6 @@ export function warnDeprecated(oldFlag, newFlag) {
9
9
  if (warned.has(key)) return;
10
10
  warned.add(key);
11
11
  process.stderr.write(
12
- `\x1b[33m⚠ --${oldFlag} is deprecated and will be removed in v3.0. Use --${newFlag} instead.\x1b[0m\n`
12
+ `\x1b[33m⚠ --${oldFlag} is deprecated and will be removed in v4.0. Use --${newFlag} instead.\x1b[0m\n`
13
13
  );
14
14
  }
package/lib/eval-pack.js CHANGED
@@ -16,6 +16,8 @@ import { existsSync } from 'node:fs';
16
16
  import { randomBytes } from 'node:crypto';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
18
  import { loadRecord } from './records.js';
19
+ import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
20
+ import { inputError } from './errors.js';
19
21
 
20
22
  /**
21
23
  * Generate an eval-pack ID: eval-YYYYMMDD-HHMMSS-XXXX
@@ -44,11 +46,18 @@ export async function buildEvalPack(projectRoot, options = {}) {
44
46
  const maxDrift = options.maxDrift || 20;
45
47
  const maxContinuity = options.maxContinuity || 10;
46
48
 
47
- // Load all records
49
+ // Load all records (DB-003-adjacent: skip unparseable files rather than
50
+ // aborting the entire eval-pack build — same graceful-degradation shape
51
+ // snapshot.js uses).
48
52
  const records = [];
49
53
  for (const file of files) {
50
- const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
51
- records.push(record);
54
+ try {
55
+ const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
56
+ records.push(record);
57
+ } catch {
58
+ // Skip unreadable records; eval pack is a best-effort selection and
59
+ // one bad file must not kill the whole build.
60
+ }
52
61
  }
53
62
 
54
63
  // Task 1: Lane coverage
@@ -66,19 +75,60 @@ export async function buildEvalPack(projectRoot, options = {}) {
66
75
  // Write eval pack
67
76
  const evalId = generateEvalId();
68
77
  const evalDir = join(projectRoot, 'eval-packs', evalId);
78
+ // D-005: refuse to silently overwrite on ID collision.
79
+ if (existsSync(evalDir)) {
80
+ throw inputError(
81
+ 'EVAL_PACK_ID_COLLISION',
82
+ `Eval pack directory already exists: ${evalDir}`,
83
+ 'Retry to generate a fresh eval pack ID.',
84
+ );
85
+ }
69
86
  await mkdir(evalDir, { recursive: true });
70
87
 
88
+ // DB-014: compute `records_available` so the manifest reports sampled vs.
89
+ // available. Operators can see when they're leaving records on the table.
90
+ const laneAvailable = countLaneCoverageCandidates(records, config);
91
+ const driftAvailable = countForbiddenDriftCandidates(records);
92
+ const anchorAvailable = countAnchorGoldCandidates(records, config);
93
+ const continuityAvailable = countSubjectContinuityCandidates(records);
94
+
71
95
  const manifest = {
96
+ // DB-001 / DB-006: stamp manifest schema version
97
+ schema_version: SCHEMA_VERSION,
72
98
  eval_id: evalId,
73
99
  created_at: new Date().toISOString(),
74
100
  created_by: 'sdlab-eval-v1',
75
101
  project: config.meta.name,
102
+ // D-017: capture config fingerprint so eval verdicts can detect canon drift.
103
+ config_fingerprint: computeConfigFingerprint(projectRoot),
76
104
  task_counts: {
77
105
  lane_coverage: laneCoverage.length,
78
106
  forbidden_drift: forbiddenDrift.length,
79
107
  anchor_gold: anchorGold.length,
80
108
  subject_continuity: subjectContinuity.length,
81
109
  },
110
+ // DB-014: coverage — sampled vs. available per task
111
+ records_sampled: {
112
+ lane_coverage: laneCoverage.length,
113
+ forbidden_drift: forbiddenDrift.length,
114
+ anchor_gold: anchorGold.length,
115
+ subject_continuity: subjectContinuity.length,
116
+ total: laneCoverage.length + forbiddenDrift.length +
117
+ anchorGold.length + subjectContinuity.length,
118
+ },
119
+ records_available: {
120
+ lane_coverage: laneAvailable,
121
+ forbidden_drift: driftAvailable,
122
+ anchor_gold: anchorAvailable,
123
+ subject_continuity: continuityAvailable,
124
+ total: laneAvailable + driftAvailable + anchorAvailable + continuityAvailable,
125
+ },
126
+ caps: {
127
+ max_per_lane: maxPerLane,
128
+ max_per_faction: maxPerFaction,
129
+ max_drift: maxDrift,
130
+ max_continuity: maxContinuity,
131
+ },
82
132
  total_records: laneCoverage.length + forbiddenDrift.length +
83
133
  anchorGold.length + subjectContinuity.length,
84
134
  };
@@ -95,6 +145,44 @@ export async function buildEvalPack(projectRoot, options = {}) {
95
145
  };
96
146
  }
97
147
 
148
+ /** DB-014: count approved+canon-bound records (the lane-coverage candidate pool). */
149
+ function countLaneCoverageCandidates(records, _config) {
150
+ let n = 0;
151
+ for (const r of records) {
152
+ if (r.judgment?.status === 'approved' && r.canon?.assertions?.length) n++;
153
+ }
154
+ return n;
155
+ }
156
+
157
+ /** DB-014: count records that qualify as forbidden-drift candidates. */
158
+ function countForbiddenDriftCandidates(records) {
159
+ let n = 0;
160
+ for (const r of records) {
161
+ const status = r.judgment?.status;
162
+ if (status !== 'rejected' && status !== 'borderline') continue;
163
+ const failureModes = r.judgment?.failure_modes || [];
164
+ const failedAssertions = (r.canon?.assertions || [])
165
+ .filter(a => a.verdict === 'fail').length;
166
+ if (failureModes.length > 0 || failedAssertions > 0 || r.judgment?.explanation) n++;
167
+ }
168
+ return n;
169
+ }
170
+
171
+ /** DB-014: count approved+canon-bound records (also anchor-gold pool). */
172
+ function countAnchorGoldCandidates(records, _config) {
173
+ return countLaneCoverageCandidates(records, _config);
174
+ }
175
+
176
+ /** DB-014: count named subjects (subject-continuity pool). */
177
+ function countSubjectContinuityCandidates(records) {
178
+ const subjects = new Set();
179
+ for (const r of records) {
180
+ const s = r.identity?.subject_name;
181
+ if (s) subjects.add(s);
182
+ }
183
+ return subjects.size;
184
+ }
185
+
98
186
  /**
99
187
  * Task 1: Lane coverage — best approved record per lane.
100
188
  */
@@ -279,5 +367,7 @@ export async function loadEvalPack(projectRoot, evalId) {
279
367
  if (!existsSync(path)) {
280
368
  throw new Error(`Eval pack "${evalId}" not found at ${path}`);
281
369
  }
282
- return JSON.parse(await readFile(path, 'utf-8'));
370
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
371
+ checkManifestVersion(manifest, 'eval-pack');
372
+ return manifest;
283
373
  }
package/lib/eval-runs.js CHANGED
@@ -13,6 +13,11 @@ import { randomBytes } from 'node:crypto';
13
13
  import { loadTrainingManifest } from './training-manifests.js';
14
14
  import { loadEvalPack } from './eval-pack.js';
15
15
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
16
+ import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
17
+
18
+ // DB-008: cap sample record_ids per failure bucket so scorecards stay small
19
+ // but the operator has enough detail to start investigation.
20
+ const SAMPLE_LIMIT = 5;
16
21
 
17
22
  /**
18
23
  * Generate an eval run ID: er-YYYYMMDD-HHMMSS-XXXX
@@ -42,6 +47,8 @@ export async function createEvalRun(projectRoot, manifestId, evalPackId) {
42
47
  await mkdir(evalRunDir, { recursive: true });
43
48
 
44
49
  const runManifest = {
50
+ // DB-001 / DB-006: stamp manifest schema version
51
+ schema_version: SCHEMA_VERSION,
45
52
  eval_run_id: evalRunId,
46
53
  created_at: new Date().toISOString(),
47
54
  created_by: 'sdlab-eval-run-v1',
@@ -148,16 +155,25 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
148
155
  function scoreLaneCoverage(tasks, outputMap) {
149
156
  let covered = 0;
150
157
  const lanes = new Set();
158
+ // DB-008: collect record_ids that missed coverage so the operator can drill
159
+ // down from "covered: 7/10" to "which 3 weren't covered".
160
+ const missedSamples = [];
151
161
  for (const task of tasks) {
152
162
  lanes.add(task.lane);
153
163
  const output = outputMap.get(task.record_id);
154
- if (output?.accept) covered++;
164
+ if (output?.accept) {
165
+ covered++;
166
+ } else if (missedSamples.length < SAMPLE_LIMIT) {
167
+ missedSamples.push(task.record_id);
168
+ }
155
169
  }
156
170
  const score = tasks.length > 0 ? covered / tasks.length : 0;
157
171
  return {
158
172
  task: 'lane_coverage',
159
173
  total: tasks.length,
160
174
  covered,
175
+ missed: tasks.length - covered,
176
+ missed_sample_record_ids: missedSamples,
161
177
  lanes_represented: lanes.size,
162
178
  score: +score.toFixed(3),
163
179
  passed: score >= 0.7,
@@ -168,12 +184,19 @@ function scoreForbiddenDrift(tasks, outputMap) {
168
184
  // For forbidden drift, we check that rejected/borderline records are NOT accepted
169
185
  let correctRejections = 0;
170
186
  let falseAcceptances = 0;
187
+ // DB-008: name the records that were wrongly accepted. Without this, a
188
+ // scorecard that says "false_acceptances: 3" forces the operator to diff
189
+ // outputs.jsonl against the eval pack manually.
190
+ const falseAcceptanceSamples = [];
171
191
  for (const task of tasks) {
172
192
  const output = outputMap.get(task.record_id);
173
193
  if (!output || !output.accept) {
174
194
  correctRejections++;
175
195
  } else {
176
196
  falseAcceptances++;
197
+ if (falseAcceptanceSamples.length < SAMPLE_LIMIT) {
198
+ falseAcceptanceSamples.push(task.record_id);
199
+ }
177
200
  }
178
201
  }
179
202
  const score = tasks.length > 0 ? correctRejections / tasks.length : 1;
@@ -182,6 +205,7 @@ function scoreForbiddenDrift(tasks, outputMap) {
182
205
  total: tasks.length,
183
206
  correct_rejections: correctRejections,
184
207
  false_acceptances: falseAcceptances,
208
+ false_acceptance_sample_record_ids: falseAcceptanceSamples,
185
209
  score: +score.toFixed(3),
186
210
  passed: falseAcceptances === 0,
187
211
  };
@@ -189,15 +213,23 @@ function scoreForbiddenDrift(tasks, outputMap) {
189
213
 
190
214
  function scoreAnchorGold(tasks, outputMap) {
191
215
  let matched = 0;
216
+ // DB-008: which anchor records failed to match?
217
+ const missedSamples = [];
192
218
  for (const task of tasks) {
193
219
  const output = outputMap.get(task.record_id);
194
- if (output?.accept) matched++;
220
+ if (output?.accept) {
221
+ matched++;
222
+ } else if (missedSamples.length < SAMPLE_LIMIT) {
223
+ missedSamples.push(task.record_id);
224
+ }
195
225
  }
196
226
  const score = tasks.length > 0 ? matched / tasks.length : 0;
197
227
  return {
198
228
  task: 'anchor_gold',
199
229
  total: tasks.length,
200
230
  matched,
231
+ missed: tasks.length - matched,
232
+ missed_sample_record_ids: missedSamples,
201
233
  score: +score.toFixed(3),
202
234
  passed: score >= 0.8,
203
235
  };
@@ -205,16 +237,23 @@ function scoreAnchorGold(tasks, outputMap) {
205
237
 
206
238
  function scoreSubjectContinuity(tasks, outputMap) {
207
239
  let subjectsWithCoverage = 0;
240
+ // DB-008: which named subjects lost continuity coverage?
241
+ const missedSamples = [];
208
242
  for (const task of tasks) {
209
243
  const recordIds = task.record_ids || [];
210
244
  const accepted = recordIds.filter(id => outputMap.get(id)?.accept).length;
211
- if (accepted >= 2) subjectsWithCoverage++;
245
+ if (accepted >= 2) {
246
+ subjectsWithCoverage++;
247
+ } else if (missedSamples.length < SAMPLE_LIMIT) {
248
+ missedSamples.push(task.subject_name || recordIds[0] || 'unknown');
249
+ }
212
250
  }
213
251
  const score = tasks.length > 0 ? subjectsWithCoverage / tasks.length : 0;
214
252
  return {
215
253
  task: 'subject_continuity',
216
254
  total_subjects: tasks.length,
217
255
  subjects_with_coverage: subjectsWithCoverage,
256
+ missed_subject_samples: missedSamples,
218
257
  score: +score.toFixed(3),
219
258
  passed: score >= 0.6,
220
259
  };