@mcptoolshop/style-dataset-lab 3.3.0 → 3.4.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 +33 -0
- package/README.es.md +35 -25
- package/README.fr.md +38 -28
- package/README.hi.md +36 -26
- package/README.it.md +37 -27
- package/README.ja.md +40 -30
- package/README.md +15 -5
- package/README.pt-BR.md +35 -25
- package/README.zh.md +33 -23
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +10 -2
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +13 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +16 -1
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/measure.js +422 -0
- package/lib/paths.js +196 -7
- package/lib/reingest-selected.js +98 -16
- package/lib/run-manifest.js +102 -16
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/workflow-profiles.js +24 -3
- package/package.json +3 -2
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +27 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +181 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +48 -5
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
package/lib/config.js
CHANGED
|
@@ -10,6 +10,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
10
10
|
import { readFile } from 'node:fs/promises';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { inputError, runtimeError } from './errors.js';
|
|
13
|
+
import { warn } from './log.js';
|
|
13
14
|
|
|
14
15
|
function loadJsonFile(filePath) {
|
|
15
16
|
if (!existsSync(filePath)) return null;
|
|
@@ -25,6 +26,80 @@ function loadJsonFile(filePath) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Validate a user-authored regex pattern string at CONFIG LOAD TIME rather
|
|
31
|
+
* than lazily inside detectLane()/detectGroup() on whatever record happens
|
|
32
|
+
* to process first.
|
|
33
|
+
*
|
|
34
|
+
* B02: detectLane()/detectGroup() build `new RegExp(pattern)` from strings
|
|
35
|
+
* loaded verbatim from lanes.json/terminology.json across 7 construction
|
|
36
|
+
* sites, with zero validation before this. One unescaped bracket
|
|
37
|
+
* (`"[freeport"`) used to abort a whole `sdlab bind` pass with a raw
|
|
38
|
+
* `SyntaxError: Invalid regular expression` naming neither the file, the
|
|
39
|
+
* lane/group, nor the offending pattern — finding it required manual
|
|
40
|
+
* bisection of the config. This throws ONE structured error naming all
|
|
41
|
+
* three, before any record is processed.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} pattern
|
|
44
|
+
* @param {{file: string, context: string}} where
|
|
45
|
+
*/
|
|
46
|
+
function validateRegexPattern(pattern, { file, context }) {
|
|
47
|
+
try {
|
|
48
|
+
// eslint-disable-next-line no-new -- validation only, result unused
|
|
49
|
+
new RegExp(pattern);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw inputError(
|
|
52
|
+
'INPUT_BAD_REGEX',
|
|
53
|
+
`Invalid regex pattern in ${file} (${context}): "${pattern}" — ${err.message}`,
|
|
54
|
+
'Fix or escape the pattern (e.g. escape literal brackets/parens/braces) and try again.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate every id_patterns entry across all lanes in a loaded lanes.json.
|
|
61
|
+
* Fails fast on the FIRST invalid pattern (not an aggregate report) so the
|
|
62
|
+
* thrown error always names one concrete offending pattern.
|
|
63
|
+
*/
|
|
64
|
+
function validateLanePatterns(lanesData) {
|
|
65
|
+
for (const lane of lanesData.lanes || []) {
|
|
66
|
+
for (const pattern of lane.id_patterns || []) {
|
|
67
|
+
validateRegexPattern(pattern, { file: 'lanes.json', context: `lane "${lane.id}"` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate every pattern site across a loaded terminology.json: per-group
|
|
74
|
+
* id_patterns/prompt_patterns, plus the four dict/array pattern collections
|
|
75
|
+
* detectGroup() also builds RegExp from (cross_group_patterns, edge_defaults,
|
|
76
|
+
* null_faction_patterns, id_fallbacks) — the full set of 6 sites detectGroup()
|
|
77
|
+
* itself constructs (the 7th is detectLane()'s single site, covered by
|
|
78
|
+
* validateLanePatterns above).
|
|
79
|
+
*/
|
|
80
|
+
function validateTerminologyPatterns(termData) {
|
|
81
|
+
for (const [groupName, group] of Object.entries(termData.groups || {})) {
|
|
82
|
+
for (const pattern of group?.id_patterns || []) {
|
|
83
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: `group "${groupName}" id_patterns` });
|
|
84
|
+
}
|
|
85
|
+
for (const pattern of group?.prompt_patterns || []) {
|
|
86
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: `group "${groupName}" prompt_patterns` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const pattern of Object.keys(termData.cross_group_patterns || {})) {
|
|
90
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: 'cross_group_patterns' });
|
|
91
|
+
}
|
|
92
|
+
for (const pattern of Object.keys(termData.edge_defaults || {})) {
|
|
93
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: 'edge_defaults' });
|
|
94
|
+
}
|
|
95
|
+
for (const pattern of termData.null_faction_patterns || []) {
|
|
96
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: 'null_faction_patterns' });
|
|
97
|
+
}
|
|
98
|
+
for (const pattern of Object.keys(termData.id_fallbacks || {})) {
|
|
99
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: 'id_fallbacks' });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
28
103
|
/**
|
|
29
104
|
* Async-safe readFile + JSON.parse with structured error handling.
|
|
30
105
|
* Optionally validates that required top-level fields are present.
|
|
@@ -75,8 +150,12 @@ export async function readJsonFile(filePath, opts = {}) {
|
|
|
75
150
|
* Load project.json — project identity and generation defaults.
|
|
76
151
|
*/
|
|
77
152
|
export function loadProjectMeta(projectRoot) {
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
153
|
+
const filePath = join(projectRoot, 'project.json');
|
|
154
|
+
if (!existsSync(filePath)) {
|
|
155
|
+
// B05: this loader used to substitute its default with zero logging —
|
|
156
|
+
// the module didn't even import lib/log.js. A dropped/never-created
|
|
157
|
+
// project.json now names its expected path instead of degrading silently.
|
|
158
|
+
warn(`project.json not found at ${filePath} — using built-in defaults (name: "unknown")`);
|
|
80
159
|
return {
|
|
81
160
|
name: 'unknown',
|
|
82
161
|
display_name: 'Unknown Project',
|
|
@@ -85,7 +164,7 @@ export function loadProjectMeta(projectRoot) {
|
|
|
85
164
|
defaults: {},
|
|
86
165
|
};
|
|
87
166
|
}
|
|
88
|
-
return
|
|
167
|
+
return loadJsonFile(filePath);
|
|
89
168
|
}
|
|
90
169
|
|
|
91
170
|
/**
|
|
@@ -111,8 +190,14 @@ export function resolveGpuModel(projectDefaults) {
|
|
|
111
190
|
* Load constitution.json — array of rule objects.
|
|
112
191
|
*/
|
|
113
192
|
export function loadConstitution(projectRoot) {
|
|
114
|
-
const
|
|
115
|
-
if (!
|
|
193
|
+
const filePath = join(projectRoot, 'constitution.json');
|
|
194
|
+
if (!existsSync(filePath)) {
|
|
195
|
+
// B05: see loadProjectMeta above — warn once, naming the expected path,
|
|
196
|
+
// instead of silently substituting the empty-rules default.
|
|
197
|
+
warn(`constitution.json not found at ${filePath} — using built-in defaults (no rules configured)`);
|
|
198
|
+
return { rules: [] };
|
|
199
|
+
}
|
|
200
|
+
const data = loadJsonFile(filePath);
|
|
116
201
|
if (Array.isArray(data)) return { rules: data };
|
|
117
202
|
if (!data.rules) {
|
|
118
203
|
throw inputError(
|
|
@@ -128,10 +213,14 @@ export function loadConstitution(projectRoot) {
|
|
|
128
213
|
* Load lanes.json — lane definitions with detection patterns.
|
|
129
214
|
*/
|
|
130
215
|
export function loadLanes(projectRoot) {
|
|
131
|
-
const
|
|
132
|
-
if (!
|
|
216
|
+
const filePath = join(projectRoot, 'lanes.json');
|
|
217
|
+
if (!existsSync(filePath)) {
|
|
218
|
+
// B05: warn once, naming the expected path, instead of silently
|
|
219
|
+
// substituting the no-lanes default.
|
|
220
|
+
warn(`lanes.json not found at ${filePath} — using built-in defaults (no lanes configured)`);
|
|
133
221
|
return { default_lane: 'default', lanes: [] };
|
|
134
222
|
}
|
|
223
|
+
const data = loadJsonFile(filePath);
|
|
135
224
|
if (!data.lanes) {
|
|
136
225
|
throw inputError(
|
|
137
226
|
'INPUT_BAD_CONFIG',
|
|
@@ -139,6 +228,9 @@ export function loadLanes(projectRoot) {
|
|
|
139
228
|
'Top-level shape: { "default_lane": "...", "lanes": [ ... ] }.'
|
|
140
229
|
);
|
|
141
230
|
}
|
|
231
|
+
// B02: validate every id_patterns regex now, before any record is ever
|
|
232
|
+
// tested against it — see validateRegexPattern's docstring above.
|
|
233
|
+
validateLanePatterns(data);
|
|
142
234
|
return data;
|
|
143
235
|
}
|
|
144
236
|
|
|
@@ -146,23 +238,31 @@ export function loadLanes(projectRoot) {
|
|
|
146
238
|
* Load rubric.json — scoring dimensions, thresholds, failure modes.
|
|
147
239
|
*/
|
|
148
240
|
export function loadRubric(projectRoot) {
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
241
|
+
const filePath = join(projectRoot, 'rubric.json');
|
|
242
|
+
if (!existsSync(filePath)) {
|
|
243
|
+
// B05: warn once, naming the expected path, instead of silently
|
|
244
|
+
// substituting the built-in thresholds default.
|
|
245
|
+
warn(`rubric.json not found at ${filePath} — using built-in defaults (pass=0.7/partial=0.5/fail_ceiling=0.4)`);
|
|
151
246
|
return {
|
|
152
247
|
dimensions: [],
|
|
153
248
|
thresholds: { pass: 0.7, partial: 0.5, fail_ceiling: 0.4 },
|
|
154
249
|
failure_to_rules: {},
|
|
155
250
|
};
|
|
156
251
|
}
|
|
157
|
-
return
|
|
252
|
+
return loadJsonFile(filePath);
|
|
158
253
|
}
|
|
159
254
|
|
|
160
255
|
/**
|
|
161
256
|
* Load terminology.json — group vocabulary and detection patterns.
|
|
162
257
|
*/
|
|
163
258
|
export function loadTerminology(projectRoot) {
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
259
|
+
const filePath = join(projectRoot, 'terminology.json');
|
|
260
|
+
if (!existsSync(filePath)) {
|
|
261
|
+
// B05: this is the finding's own example — a dropped terminology.json
|
|
262
|
+
// used to make detectGroup() return null for every record with zero
|
|
263
|
+
// signal. Warn once, naming the expected path, instead of substituting
|
|
264
|
+
// the empty-groups default silently.
|
|
265
|
+
warn(`terminology.json not found at ${filePath} — using built-in defaults (no groups configured)`);
|
|
166
266
|
return {
|
|
167
267
|
group_label: 'group',
|
|
168
268
|
groups: {},
|
|
@@ -171,6 +271,10 @@ export function loadTerminology(projectRoot) {
|
|
|
171
271
|
prompt_fallbacks: {},
|
|
172
272
|
};
|
|
173
273
|
}
|
|
274
|
+
const data = loadJsonFile(filePath);
|
|
275
|
+
// B02: validate every pattern site now, before any record is ever tested
|
|
276
|
+
// against it — see validateRegexPattern's docstring above.
|
|
277
|
+
validateTerminologyPatterns(data);
|
|
174
278
|
return data;
|
|
175
279
|
}
|
|
176
280
|
|
package/lib/critique-engine.js
CHANGED
|
@@ -255,6 +255,17 @@ export function critiqueCandidate({ brief, workflow, candidate, activeDimensions
|
|
|
255
255
|
filename: candidate.filename,
|
|
256
256
|
seed: candidate.seed,
|
|
257
257
|
overall_fit: 'usable', // Default — human or LLM overrides
|
|
258
|
+
// H2: this is the ONLY assignment site for overall_fit anywhere in the
|
|
259
|
+
// codebase — nothing currently overrides it, so every candidate in
|
|
260
|
+
// every critique report is uniformly 'usable' by construction. That is
|
|
261
|
+
// a non-judgment ("nobody has looked at the image yet"), not a judgment
|
|
262
|
+
// ("looked, and they're all fine") — `reviewed: false` records which one
|
|
263
|
+
// this is so recommendAction() (below) and the CLI can tell the two
|
|
264
|
+
// apart instead of presenting keyword-drift tiebreaking as a considered
|
|
265
|
+
// recommendation. Flip to true at the (future, NOT built here — see the
|
|
266
|
+
// finding's explicit scoping note) point where a human or LLM judge
|
|
267
|
+
// actually assesses fit and overrides overall_fit.
|
|
268
|
+
reviewed: false,
|
|
258
269
|
// EXTERNAL_VERIFIER: this rule-based engine is a different artifact class
|
|
259
270
|
// from the image generator, so it is a legitimate cross-artifact verifier.
|
|
260
271
|
judged_by_model: RULE_BASED_JUDGE,
|
|
@@ -301,12 +312,27 @@ export function triageCandidates(candidates, { driftThreshold = DEFAULT_DRIFT_TH
|
|
|
301
312
|
|
|
302
313
|
/**
|
|
303
314
|
* Derive a run-level recommended action from candidate notes.
|
|
315
|
+
*
|
|
316
|
+
* H2: `overall_fit: 'usable'` (critiqueCandidate's default, and — today —
|
|
317
|
+
* its ONLY assignment anywhere in the codebase) means "nobody has assessed
|
|
318
|
+
* fit yet", not "assessed and found uniformly usable". Before this fix,
|
|
319
|
+
* recommendAction() could not tell those two states apart, so an unreviewed
|
|
320
|
+
* run always took the same `usable.length > 0` branch a genuinely-reviewed
|
|
321
|
+
* run would, picking a "preferred" candidate by brief-keyword drift count —
|
|
322
|
+
* not by looking at the image — and presenting it with the same confidence
|
|
323
|
+
* either way. `reviewed` on the returned recommendation lets the CLI (see
|
|
324
|
+
* scripts/critique.js) qualify or suppress the auto "Next:" suggestion when
|
|
325
|
+
* nothing has actually been reviewed, without changing `mode` /
|
|
326
|
+
* `preferred_candidate` semantics for callers that don't look at it —
|
|
327
|
+
* critique.json's shape stays backward-compatible (additive field only).
|
|
304
328
|
*/
|
|
305
329
|
export function recommendAction({ candidates }) {
|
|
306
330
|
if (candidates.length === 0) {
|
|
307
|
-
return { mode: 'discard_run', reason: 'No candidates in run' };
|
|
331
|
+
return { mode: 'discard_run', reason: 'No candidates in run', reviewed: false };
|
|
308
332
|
}
|
|
309
333
|
|
|
334
|
+
const anyReviewed = candidates.some(c => c.reviewed === true);
|
|
335
|
+
|
|
310
336
|
const strong = candidates.filter(c => c.overall_fit === 'strong');
|
|
311
337
|
const usable = candidates.filter(c => c.overall_fit === 'usable');
|
|
312
338
|
const offModel = candidates.filter(c => c.overall_fit === 'off-model');
|
|
@@ -317,6 +343,7 @@ export function recommendAction({ candidates }) {
|
|
|
317
343
|
mode: 'accept_one',
|
|
318
344
|
preferred_candidate: strong[0].filename,
|
|
319
345
|
reason: `${strong.length} strong candidate(s) — ${strong[0].filename} is the best fit`,
|
|
346
|
+
reviewed: anyReviewed,
|
|
320
347
|
};
|
|
321
348
|
}
|
|
322
349
|
|
|
@@ -327,7 +354,12 @@ export function recommendAction({ candidates }) {
|
|
|
327
354
|
return {
|
|
328
355
|
mode: 'refine_from_one',
|
|
329
356
|
preferred_candidate: sorted[0].filename,
|
|
330
|
-
reason:
|
|
357
|
+
reason: anyReviewed
|
|
358
|
+
? `${usable.length} usable candidate(s) — ${sorted[0].filename} has fewest drift issues`
|
|
359
|
+
: `${usable.length} candidate(s) at the unreviewed default "usable" — nobody has assessed fit yet. ` +
|
|
360
|
+
`${sorted[0].filename} has the fewest rule-based drift issues, which is a tiebreaker, not a ` +
|
|
361
|
+
`judgment of the image.`,
|
|
362
|
+
reviewed: anyReviewed,
|
|
331
363
|
};
|
|
332
364
|
}
|
|
333
365
|
|
|
@@ -336,6 +368,7 @@ export function recommendAction({ candidates }) {
|
|
|
336
368
|
return {
|
|
337
369
|
mode: 'discard_run',
|
|
338
370
|
reason: 'All candidates are off-model — brief may need fundamental revision',
|
|
371
|
+
reviewed: anyReviewed,
|
|
339
372
|
};
|
|
340
373
|
}
|
|
341
374
|
|
|
@@ -343,6 +376,7 @@ export function recommendAction({ candidates }) {
|
|
|
343
376
|
return {
|
|
344
377
|
mode: 'rerun_broader',
|
|
345
378
|
reason: 'No clear winner — try broader seed range or adjusted parameters',
|
|
379
|
+
reviewed: anyReviewed,
|
|
346
380
|
};
|
|
347
381
|
}
|
|
348
382
|
|
package/lib/eligibility.js
CHANGED
|
@@ -6,6 +6,28 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { detectLane, detectGroup } from './config.js';
|
|
9
|
+
import { warn } from './log.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* F5: stable, machine-readable codes — one per exclusion reason, in the
|
|
13
|
+
* exact same order/count as the human-readable `reasons` strings pushed
|
|
14
|
+
* below. Added so categorizeExclusions() can categorize by code instead
|
|
15
|
+
* of depending on prose matching (rewording a `reasons` string used to
|
|
16
|
+
* silently break categorization). Purely additive: `reasons` keeps its
|
|
17
|
+
* exact original shape and text, this is a NEW parallel field
|
|
18
|
+
* (`reason_codes` on the returned object) — any existing caller that only
|
|
19
|
+
* reads `.eligible` / `.reasons` sees no change at all.
|
|
20
|
+
*/
|
|
21
|
+
export const REASON_CODES = {
|
|
22
|
+
NO_JUDGMENT: 'no_judgment',
|
|
23
|
+
WRONG_STATUS: 'wrong_status',
|
|
24
|
+
NOT_CANON_BOUND: 'not_canon_bound',
|
|
25
|
+
UNMEASURED_PASS_COUNT: 'unmeasured_pass_count',
|
|
26
|
+
LOW_PASS_RATIO: 'low_pass_ratio',
|
|
27
|
+
EXCLUDED_LANE: 'excluded_lane',
|
|
28
|
+
RIGHTS_FILTER: 'rights_filter',
|
|
29
|
+
EXCLUDED_TAGS: 'excluded_tags',
|
|
30
|
+
};
|
|
9
31
|
|
|
10
32
|
/**
|
|
11
33
|
* Evaluate a single record against a selection profile.
|
|
@@ -13,15 +35,18 @@ import { detectLane, detectGroup } from './config.js';
|
|
|
13
35
|
* @param {Object} profile — selection profile
|
|
14
36
|
* @param {Object} lanesConfig — from lanes.json
|
|
15
37
|
* @param {Object} termConfig — from terminology.json
|
|
16
|
-
* @returns {{ eligible: boolean, reasons: string[] }}
|
|
38
|
+
* @returns {{ eligible: boolean, reasons: string[], reason_codes: string[] }}
|
|
17
39
|
*/
|
|
18
40
|
export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
|
|
19
41
|
const reasons = [];
|
|
42
|
+
// F5: parallel array — reasonCodes[i] is the stable code for reasons[i].
|
|
43
|
+
const reasonCodes = [];
|
|
20
44
|
|
|
21
45
|
// 1. Judgment required
|
|
22
46
|
if (profile.require_judgment) {
|
|
23
47
|
if (!record.judgment) {
|
|
24
48
|
reasons.push('no judgment (record has not been reviewed)');
|
|
49
|
+
reasonCodes.push(REASON_CODES.NO_JUDGMENT);
|
|
25
50
|
}
|
|
26
51
|
}
|
|
27
52
|
|
|
@@ -29,6 +54,7 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
|
|
|
29
54
|
if (profile.require_status?.length > 0 && record.judgment) {
|
|
30
55
|
if (!profile.require_status.includes(record.judgment.status)) {
|
|
31
56
|
reasons.push(`status "${record.judgment.status}" not in [${profile.require_status.join(', ')}]`);
|
|
57
|
+
reasonCodes.push(REASON_CODES.WRONG_STATUS);
|
|
32
58
|
}
|
|
33
59
|
}
|
|
34
60
|
|
|
@@ -36,14 +62,31 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
|
|
|
36
62
|
if (profile.require_canon_bound) {
|
|
37
63
|
if (!record.canon?.assertions || record.canon.assertions.length === 0) {
|
|
38
64
|
reasons.push('not canon-bound (no assertions)');
|
|
65
|
+
reasonCodes.push(REASON_CODES.NOT_CANON_BOUND);
|
|
39
66
|
}
|
|
40
67
|
}
|
|
41
68
|
|
|
42
69
|
// 4. Minimum pass ratio
|
|
43
70
|
if (profile.minimum_pass_ratio != null && record.canon?.assertion_count > 0) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
const passCount = record.canon.pass_count;
|
|
72
|
+
// pass_count missing/non-numeric while assertion_count > 0 must NOT
|
|
73
|
+
// silently compute NaN: `NaN < minimum_pass_ratio` is always false, so
|
|
74
|
+
// the exclusion would never fire and a record whose pass ratio was never
|
|
75
|
+
// actually measured would be treated as having cleared a bar it never
|
|
76
|
+
// touched. Law 2 (inclusion is explainable) requires this be its own
|
|
77
|
+
// named reason, not a silent pass.
|
|
78
|
+
if (typeof passCount !== 'number' || !Number.isFinite(passCount)) {
|
|
79
|
+
reasons.push(
|
|
80
|
+
`pass_count is missing or not a number (assertion_count is ${record.canon.assertion_count}); ` +
|
|
81
|
+
`cannot verify pass ratio, so it is excluded rather than assumed passing`
|
|
82
|
+
);
|
|
83
|
+
reasonCodes.push(REASON_CODES.UNMEASURED_PASS_COUNT);
|
|
84
|
+
} else {
|
|
85
|
+
const ratio = passCount / record.canon.assertion_count;
|
|
86
|
+
if (ratio < profile.minimum_pass_ratio) {
|
|
87
|
+
reasons.push(`pass ratio ${(ratio * 100).toFixed(0)}% below minimum ${(profile.minimum_pass_ratio * 100).toFixed(0)}%`);
|
|
88
|
+
reasonCodes.push(REASON_CODES.LOW_PASS_RATIO);
|
|
89
|
+
}
|
|
47
90
|
}
|
|
48
91
|
}
|
|
49
92
|
|
|
@@ -53,6 +96,7 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
|
|
|
53
96
|
const lane = detectLane(record.id, prompt, lanesConfig);
|
|
54
97
|
if (profile.exclude_lanes.includes(lane)) {
|
|
55
98
|
reasons.push(`lane "${lane}" is excluded`);
|
|
99
|
+
reasonCodes.push(REASON_CODES.EXCLUDED_LANE);
|
|
56
100
|
}
|
|
57
101
|
}
|
|
58
102
|
|
|
@@ -61,6 +105,7 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
|
|
|
61
105
|
const rights = record.rights_status || record.training_rights_status || null;
|
|
62
106
|
if (rights !== profile.rights_filter) {
|
|
63
107
|
reasons.push(`rights "${rights || 'unknown'}" does not match required "${profile.rights_filter}"`);
|
|
108
|
+
reasonCodes.push(REASON_CODES.RIGHTS_FILTER);
|
|
64
109
|
}
|
|
65
110
|
}
|
|
66
111
|
|
|
@@ -69,41 +114,108 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
|
|
|
69
114
|
const excluded = record.dataset_tags.filter(t => profile.exclude_tags.includes(t));
|
|
70
115
|
if (excluded.length > 0) {
|
|
71
116
|
reasons.push(`has excluded tags: ${excluded.join(', ')}`);
|
|
117
|
+
reasonCodes.push(REASON_CODES.EXCLUDED_TAGS);
|
|
72
118
|
}
|
|
73
119
|
}
|
|
74
120
|
|
|
75
121
|
return {
|
|
76
122
|
eligible: reasons.length === 0,
|
|
77
123
|
reasons,
|
|
124
|
+
reason_codes: reasonCodes,
|
|
78
125
|
};
|
|
79
126
|
}
|
|
80
127
|
|
|
81
128
|
/**
|
|
82
129
|
* Categorize exclusion reasons for a set of records.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
130
|
+
*
|
|
131
|
+
* F5: every reason is guaranteed to land in exactly one bucket — the
|
|
132
|
+
* mutually-exclusive if/else-if chain below now ends in a trailing `else`
|
|
133
|
+
* that increments `uncategorized` instead of silently matching nothing.
|
|
134
|
+
* Previously, rewording a `reasons` string in evaluateEligibility(), or
|
|
135
|
+
* adding a ninth reason with no matching branch, meant those exclusions
|
|
136
|
+
* were counted in NO category at all — a gap invisible in the output
|
|
137
|
+
* (the category sum would just quietly fall short of the true count).
|
|
138
|
+
*
|
|
139
|
+
* When an `excluded` entry carries `reason_codes` (evaluateEligibility's
|
|
140
|
+
* new parallel array — see REASON_CODES above), categorization uses the
|
|
141
|
+
* stable code directly instead of prose-matching `reasons`. Entries
|
|
142
|
+
* without `reason_codes` (older excluded.jsonl written before F5, or a
|
|
143
|
+
* caller that only forwards `reasons`) fall back to the original
|
|
144
|
+
* free-text matching, unchanged.
|
|
145
|
+
*
|
|
146
|
+
* @param {Array<{record_id: string, reasons: string[], reason_codes?: string[]}>} excluded
|
|
147
|
+
* @returns {Object} — category → count mapping (includes `uncategorized`)
|
|
85
148
|
*/
|
|
86
149
|
export function categorizeExclusions(excluded) {
|
|
87
150
|
const categories = {
|
|
88
151
|
no_judgment: 0,
|
|
89
152
|
wrong_status: 0,
|
|
90
153
|
not_canon_bound: 0,
|
|
154
|
+
unmeasured_pass_count: 0,
|
|
91
155
|
low_pass_ratio: 0,
|
|
92
156
|
excluded_lane: 0,
|
|
93
157
|
rights_filter: 0,
|
|
94
158
|
excluded_tags: 0,
|
|
159
|
+
uncategorized: 0,
|
|
95
160
|
};
|
|
161
|
+
const KNOWN_CODES = new Set(Object.values(REASON_CODES));
|
|
96
162
|
|
|
97
|
-
|
|
98
|
-
|
|
163
|
+
// F5: sample of the actual uncategorized reason strings, surfaced via
|
|
164
|
+
// warn() below so an operator sees WHAT needs a new branch/code instead
|
|
165
|
+
// of just a suspicious gap in the numbers.
|
|
166
|
+
const uncategorizedSamples = [];
|
|
167
|
+
let totalReasons = 0;
|
|
168
|
+
|
|
169
|
+
for (const { reasons, reason_codes: reasonCodes } of excluded) {
|
|
170
|
+
reasons.forEach((r, i) => {
|
|
171
|
+
totalReasons++;
|
|
172
|
+
const code = reasonCodes?.[i];
|
|
173
|
+
if (code && KNOWN_CODES.has(code)) {
|
|
174
|
+
categories[code]++;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
99
177
|
if (r.includes('no judgment')) categories.no_judgment++;
|
|
100
178
|
else if (r.includes('status')) categories.wrong_status++;
|
|
101
179
|
else if (r.includes('canon-bound')) categories.not_canon_bound++;
|
|
180
|
+
// Order matters: "pass_count missing" must be checked before the
|
|
181
|
+
// generic "pass ratio" match below, since an unmeasured pass_count
|
|
182
|
+
// is a distinct exclusion reason from a measured-and-low ratio (Law 2
|
|
183
|
+
// — inclusion must be explainable, so "never measured" cannot be
|
|
184
|
+
// folded into "measured and failed").
|
|
185
|
+
else if (r.includes('pass_count')) categories.unmeasured_pass_count++;
|
|
102
186
|
else if (r.includes('pass ratio')) categories.low_pass_ratio++;
|
|
103
187
|
else if (r.includes('lane')) categories.excluded_lane++;
|
|
104
188
|
else if (r.includes('rights')) categories.rights_filter++;
|
|
105
189
|
else if (r.includes('tags')) categories.excluded_tags++;
|
|
106
|
-
|
|
190
|
+
else {
|
|
191
|
+
categories.uncategorized++;
|
|
192
|
+
if (uncategorizedSamples.length < 10) uncategorizedSamples.push(r);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (categories.uncategorized > 0) {
|
|
198
|
+
const shown = uncategorizedSamples.slice(0, 5);
|
|
199
|
+
warn(
|
|
200
|
+
`eligibility: ${categories.uncategorized} exclusion reason(s) matched no known category ` +
|
|
201
|
+
`(first ${shown.length}: ${shown.join(' | ')})` +
|
|
202
|
+
(uncategorizedSamples.length > shown.length ? ', ...' : '') +
|
|
203
|
+
' — add a matching reason_code/branch, or these will keep landing in "uncategorized".',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// F5: defensive tripwire, not a normal-path check — the if/else-if chain
|
|
208
|
+
// above is mutually exclusive and the trailing else always catches
|
|
209
|
+
// anything unmatched, so this sum is mathematically guaranteed to equal
|
|
210
|
+
// totalReasons for any input. A mismatch would mean a future edit broke
|
|
211
|
+
// that mutual-exclusivity guarantee (e.g. turned an `else if` into a
|
|
212
|
+
// bare `if`, allowing one reason to double-count).
|
|
213
|
+
const categorizedSum = Object.values(categories).reduce((a, b) => a + b, 0);
|
|
214
|
+
if (categorizedSum !== totalReasons) {
|
|
215
|
+
warn(
|
|
216
|
+
`eligibility: categorization invariant violated — categorized ${categorizedSum} reason(s) but ` +
|
|
217
|
+
`${totalReasons} were processed. This indicates a bug in categorizeExclusions itself.`,
|
|
218
|
+
);
|
|
107
219
|
}
|
|
108
220
|
|
|
109
221
|
return categories;
|
package/lib/eval-pack.js
CHANGED
|
@@ -10,14 +10,13 @@
|
|
|
10
10
|
* 4. Subject continuity — same-subject records for identity testing
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readdir, readFile, writeFile
|
|
13
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
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
|
+
import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* Generate an eval-pack ID: eval-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -73,17 +72,18 @@ export async function buildEvalPack(projectRoot, options = {}) {
|
|
|
73
72
|
const subjectContinuity = buildSubjectContinuity(records, maxContinuity);
|
|
74
73
|
|
|
75
74
|
// Write eval pack
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// D-005
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
// SDL-M10: atomically claim the eval-pack id/dir — closes the
|
|
76
|
+
// check-then-act race the old existsSync+recursive-mkdir pattern had
|
|
77
|
+
// (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
|
|
78
|
+
const { id: evalId, dir: evalDir } = await claimIdDir(
|
|
79
|
+
join(projectRoot, 'eval-packs'),
|
|
80
|
+
generateEvalId,
|
|
81
|
+
{
|
|
82
|
+
code: 'EVAL_PACK_ID_COLLISION',
|
|
83
|
+
message: 'Could not claim a unique eval-pack ID — every candidate collided with an existing eval-pack directory.',
|
|
84
|
+
hint: 'Retry; if this persists, another sdlab process may be creating eval packs in a tight loop.',
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
87
|
|
|
88
88
|
// DB-014: compute `records_available` so the manifest reports sampled vs.
|
|
89
89
|
// available. Operators can see when they're leaving records on the table.
|
package/lib/eval-runs.js
CHANGED
|
@@ -14,6 +14,7 @@ import { loadTrainingManifest } from './training-manifests.js';
|
|
|
14
14
|
import { loadEvalPack } from './eval-pack.js';
|
|
15
15
|
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
16
16
|
import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
17
|
+
import { inputError } from './errors.js';
|
|
17
18
|
|
|
18
19
|
// DB-008: cap sample record_ids per failure bucket so scorecards stay small
|
|
19
20
|
// but the operator has enough detail to start investigation.
|
|
@@ -91,6 +92,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
|
|
|
91
92
|
|
|
92
93
|
// Load eval pack tasks
|
|
93
94
|
const evalPackDir = join(projectRoot, 'eval-packs', runManifest.eval_pack_id);
|
|
95
|
+
// F2: createEvalRun only ever stores the eval_pack_id string — it never
|
|
96
|
+
// re-verifies at scoring time that the pack directory still exists. If
|
|
97
|
+
// the pack was deleted (or moved) between run creation and scoring, this
|
|
98
|
+
// used to fall straight through to the all-four-skip / vacuous-pass path
|
|
99
|
+
// below. Fail loudly here with the exact missing path instead.
|
|
100
|
+
if (!existsSync(evalPackDir)) {
|
|
101
|
+
throw inputError(
|
|
102
|
+
'EVAL_PACK_DIR_MISSING',
|
|
103
|
+
`Eval pack directory not found: ${evalPackDir} (eval run "${evalRunId}" references eval_pack_id "${runManifest.eval_pack_id}").`,
|
|
104
|
+
'The eval pack this run was created against may have been deleted or moved. Rebuild the eval pack, or create a fresh eval run against one that still exists.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
94
107
|
const taskFiles = {
|
|
95
108
|
'lane-coverage': 'lane-coverage.jsonl',
|
|
96
109
|
'forbidden-drift': 'forbidden-drift.jsonl',
|
|
@@ -102,10 +115,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
|
|
|
102
115
|
|
|
103
116
|
// Score each task type
|
|
104
117
|
const scores = {};
|
|
118
|
+
// F2: count task files that actually resolved. Object.values({}).every(...)
|
|
119
|
+
// on an EMPTY scores object is vacuously true in JS — with zero task files
|
|
120
|
+
// found, `scores` stays `{}` and the verdict below silently computed
|
|
121
|
+
// overall_verdict:'pass', overall_score:0.000 for a model that was never
|
|
122
|
+
// evaluated against anything. resolvedTaskFiles turns that into a named,
|
|
123
|
+
// structured failure instead of a green rubber stamp.
|
|
124
|
+
let resolvedTaskFiles = 0;
|
|
105
125
|
|
|
106
126
|
for (const [taskName, fileName] of Object.entries(taskFiles)) {
|
|
107
127
|
const taskPath = join(evalPackDir, fileName);
|
|
108
128
|
if (!existsSync(taskPath)) continue;
|
|
129
|
+
resolvedTaskFiles++;
|
|
109
130
|
|
|
110
131
|
const taskEntries = (await readFile(taskPath, 'utf-8'))
|
|
111
132
|
.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
@@ -121,6 +142,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
|
|
|
121
142
|
}
|
|
122
143
|
}
|
|
123
144
|
|
|
145
|
+
// F2: belt-and-suspenders — even with the directory-existence guard above,
|
|
146
|
+
// a pack directory that exists but has had every individual task file
|
|
147
|
+
// deleted out from under it must hit the same guard, not the vacuous
|
|
148
|
+
// Object.values({}).every(...) === true path.
|
|
149
|
+
if (resolvedTaskFiles === 0) {
|
|
150
|
+
throw inputError(
|
|
151
|
+
'EVAL_PACK_NO_TASK_FILES',
|
|
152
|
+
`Eval pack "${runManifest.eval_pack_id}" at ${evalPackDir} has no resolvable task files — nothing was scored for eval run "${evalRunId}".`,
|
|
153
|
+
`Expected at least one of: ${Object.values(taskFiles).join(', ')}. Verify the eval pack was built correctly and its directory was not partially deleted.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
124
157
|
// Compute overall verdict
|
|
125
158
|
const allPassed = Object.values(scores).every(s => s.passed);
|
|
126
159
|
const overallScore = Object.values(scores).reduce((sum, s) => sum + (s.score || 0), 0) /
|