@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.
- package/CHANGELOG.md +68 -15
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +71 -0
- package/README.md +46 -1
- package/SECURITY.md +6 -5
- package/bin/sdlab.js +281 -62
- package/lib/adapters/diffusers-lora.js +7 -2
- package/lib/adapters/generic-image-caption.js +6 -1
- package/lib/args.js +200 -13
- package/lib/batch-compiler.js +7 -3
- package/lib/batch-runs.js +100 -31
- package/lib/batch-sheet-render.js +5 -2
- package/lib/brief-compiler.js +38 -11
- package/lib/card.js +11 -4
- package/lib/comfyui.js +100 -6
- package/lib/config.js +68 -3
- package/lib/deprecation.js +1 -1
- package/lib/eval-pack.js +94 -4
- package/lib/eval-runs.js +42 -3
- package/lib/export.js +144 -34
- package/lib/implementation-packs.js +15 -1
- package/lib/log.js +41 -4
- package/lib/paths.js +41 -3
- package/lib/refine-briefs.js +16 -9
- package/lib/reingest-selected.js +14 -3
- package/lib/reingest.js +15 -1
- package/lib/run-summary.js +3 -2
- package/lib/runtime-runs.js +59 -9
- package/lib/selections.js +54 -2
- package/lib/snapshot.js +100 -7
- package/lib/split.js +175 -17
- package/lib/training-manifests.js +17 -3
- package/lib/training-packages.js +28 -14
- package/package.json +8 -2
- package/scripts/batch-generate.js +16 -7
- package/scripts/batch-sheet.js +3 -3
- package/scripts/batch-show.js +3 -3
- package/scripts/brief-compile.js +8 -8
- package/scripts/brief-show.js +4 -3
- package/scripts/canon-bind.js +7 -9
- package/scripts/card.js +22 -25
- package/scripts/compare.js +38 -33
- package/scripts/critique-show.js +3 -3
- package/scripts/critique.js +3 -3
- package/scripts/curate.js +91 -46
- package/scripts/doctor.js +3 -5
- package/scripts/eligibility.js +17 -14
- package/scripts/eval-pack.js +13 -11
- package/scripts/eval-run.js +30 -29
- package/scripts/export.js +27 -31
- package/scripts/generate-controlnet.js +31 -17
- package/scripts/generate-identity.js +37 -20
- package/scripts/generate-ipadapter.js +22 -16
- package/scripts/generate.js +68 -13
- package/scripts/implementation-pack.js +19 -14
- package/scripts/init.js +115 -17
- package/scripts/painterly.js +8 -10
- package/scripts/refine.js +3 -3
- package/scripts/reingest-selected.js +3 -3
- package/scripts/reingest.js +33 -20
- package/scripts/run-generate.js +4 -4
- package/scripts/run-list.js +3 -3
- package/scripts/run-show.js +3 -3
- package/scripts/select.js +5 -4
- package/scripts/selection-show.js +3 -3
- package/scripts/snapshot.js +22 -24
- package/scripts/split.js +26 -31
- package/scripts/training-manifest.js +29 -24
- package/scripts/training-package.js +24 -20
- package/scripts/training-profile.js +13 -8
- package/scripts/workflow-list.js +2 -2
- package/scripts/workflow-show.js +4 -3
- package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/architecture/terminology.json +10 -8
- package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/character-design/project.json +4 -2
- package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/creature-design/project.json +4 -2
- package/templates/domains/creature-design/terminology.json +1 -0
- package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/game-art/project.json +4 -2
- package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/vehicle-mech/project.json +4 -2
- package/scripts/bulk-curate-wave2-5.js +0 -138
- package/scripts/bulk-curate-waves11-18.js +0 -413
- package/scripts/curate-wave25.js +0 -209
- package/scripts/migrate-records.js +0 -141
- 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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
package/lib/deprecation.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
};
|