@mcptoolshop/style-dataset-lab 3.2.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 +58 -1
- package/README.es.md +40 -19
- package/README.fr.md +74 -53
- package/README.hi.md +67 -46
- package/README.it.md +42 -21
- package/README.ja.md +44 -23
- package/README.md +25 -4
- package/README.pt-BR.md +42 -21
- package/README.zh.md +66 -45
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +114 -27
- 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 +82 -3
- package/lib/critique-render.js +45 -0
- 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 +69 -16
- 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 +394 -0
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +121 -16
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +67 -0
- 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 +48 -8
- package/scripts/curate.js +23 -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 +72 -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 +334 -13
- package/scripts/reingest.js +2 -2
- package/scripts/run-generate.js +3 -0
- 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
|
@@ -19,6 +19,7 @@ import { loadRun } from './runtime-runs.js';
|
|
|
19
19
|
import { getWorkflowProfile } from './workflow-profiles.js';
|
|
20
20
|
import { loadProjectConfig } from './config.js';
|
|
21
21
|
import { inputError } from './errors.js';
|
|
22
|
+
import { RULE_BASED_JUDGE, generatorModelFromManifest, warnIfSelfVerification } from './verifier.js';
|
|
22
23
|
|
|
23
24
|
// ─── Dimension catalogue ─────────────────────────────────────────
|
|
24
25
|
|
|
@@ -188,7 +189,7 @@ export function getActiveDimensions({ brief, workflow }) {
|
|
|
188
189
|
* A human or LLM fills in the actual per-image observations via
|
|
189
190
|
* `sdlab critique --run <id>` which creates a template to complete.
|
|
190
191
|
*/
|
|
191
|
-
export function critiqueCandidate({ brief, workflow, candidate, activeDimensions }) {
|
|
192
|
+
export function critiqueCandidate({ brief, workflow, candidate, activeDimensions, generatorModel = null }) {
|
|
192
193
|
const strengths = [];
|
|
193
194
|
const driftIssues = [];
|
|
194
195
|
const preserveNextPass = [];
|
|
@@ -254,6 +255,21 @@ export function critiqueCandidate({ brief, workflow, candidate, activeDimensions
|
|
|
254
255
|
filename: candidate.filename,
|
|
255
256
|
seed: candidate.seed,
|
|
256
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,
|
|
269
|
+
// EXTERNAL_VERIFIER: this rule-based engine is a different artifact class
|
|
270
|
+
// from the image generator, so it is a legitimate cross-artifact verifier.
|
|
271
|
+
judged_by_model: RULE_BASED_JUDGE,
|
|
272
|
+
generator_model: generatorModel,
|
|
257
273
|
strengths,
|
|
258
274
|
drift_issues: driftIssues,
|
|
259
275
|
preserve_next_pass: preserveNextPass,
|
|
@@ -261,16 +277,62 @@ export function critiqueCandidate({ brief, workflow, candidate, activeDimensions
|
|
|
261
277
|
};
|
|
262
278
|
}
|
|
263
279
|
|
|
280
|
+
// ─── Triage (UNCERTAINTY_GATED_HUMANS) ───────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Default drift-issue count at/above which a candidate is flagged for human
|
|
284
|
+
* attention. Tunable per invocation via `sdlab critique --triage --drift-threshold N`.
|
|
285
|
+
*/
|
|
286
|
+
export const DEFAULT_DRIFT_THRESHOLD = 3;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Split candidates into the ones a human should look at vs the ones the
|
|
290
|
+
* rule-based pass cleared — so attention gates on UNCERTAINTY, not on every item
|
|
291
|
+
* (Buçinca et al. arXiv:2410.04253). A candidate is flagged when it is
|
|
292
|
+
* `off-model` (a human/LLM downgraded its fit) OR carries >= driftThreshold
|
|
293
|
+
* drift issues. The full report is unchanged; triage is a VIEW over it.
|
|
294
|
+
*
|
|
295
|
+
* @param {Array} candidates — report.candidates
|
|
296
|
+
* @param {Object} [opts]
|
|
297
|
+
* @param {number} [opts.driftThreshold=DEFAULT_DRIFT_THRESHOLD]
|
|
298
|
+
* @returns {{ flagged: Array, suppressed: Array, driftThreshold: number }}
|
|
299
|
+
*/
|
|
300
|
+
export function triageCandidates(candidates, { driftThreshold = DEFAULT_DRIFT_THRESHOLD } = {}) {
|
|
301
|
+
const flagged = [];
|
|
302
|
+
const suppressed = [];
|
|
303
|
+
for (const c of candidates || []) {
|
|
304
|
+
const driftCount = c.drift_issues?.length ?? 0;
|
|
305
|
+
const uncertain = c.overall_fit === 'off-model' || driftCount >= driftThreshold;
|
|
306
|
+
(uncertain ? flagged : suppressed).push(c);
|
|
307
|
+
}
|
|
308
|
+
return { flagged, suppressed, driftThreshold };
|
|
309
|
+
}
|
|
310
|
+
|
|
264
311
|
// ─── Run-level recommendation ────────────────────────────────────
|
|
265
312
|
|
|
266
313
|
/**
|
|
267
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).
|
|
268
328
|
*/
|
|
269
329
|
export function recommendAction({ candidates }) {
|
|
270
330
|
if (candidates.length === 0) {
|
|
271
|
-
return { mode: 'discard_run', reason: 'No candidates in run' };
|
|
331
|
+
return { mode: 'discard_run', reason: 'No candidates in run', reviewed: false };
|
|
272
332
|
}
|
|
273
333
|
|
|
334
|
+
const anyReviewed = candidates.some(c => c.reviewed === true);
|
|
335
|
+
|
|
274
336
|
const strong = candidates.filter(c => c.overall_fit === 'strong');
|
|
275
337
|
const usable = candidates.filter(c => c.overall_fit === 'usable');
|
|
276
338
|
const offModel = candidates.filter(c => c.overall_fit === 'off-model');
|
|
@@ -281,6 +343,7 @@ export function recommendAction({ candidates }) {
|
|
|
281
343
|
mode: 'accept_one',
|
|
282
344
|
preferred_candidate: strong[0].filename,
|
|
283
345
|
reason: `${strong.length} strong candidate(s) — ${strong[0].filename} is the best fit`,
|
|
346
|
+
reviewed: anyReviewed,
|
|
284
347
|
};
|
|
285
348
|
}
|
|
286
349
|
|
|
@@ -291,7 +354,12 @@ export function recommendAction({ candidates }) {
|
|
|
291
354
|
return {
|
|
292
355
|
mode: 'refine_from_one',
|
|
293
356
|
preferred_candidate: sorted[0].filename,
|
|
294
|
-
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,
|
|
295
363
|
};
|
|
296
364
|
}
|
|
297
365
|
|
|
@@ -300,6 +368,7 @@ export function recommendAction({ candidates }) {
|
|
|
300
368
|
return {
|
|
301
369
|
mode: 'discard_run',
|
|
302
370
|
reason: 'All candidates are off-model — brief may need fundamental revision',
|
|
371
|
+
reviewed: anyReviewed,
|
|
303
372
|
};
|
|
304
373
|
}
|
|
305
374
|
|
|
@@ -307,6 +376,7 @@ export function recommendAction({ candidates }) {
|
|
|
307
376
|
return {
|
|
308
377
|
mode: 'rerun_broader',
|
|
309
378
|
reason: 'No clear winner — try broader seed range or adjusted parameters',
|
|
379
|
+
reviewed: anyReviewed,
|
|
310
380
|
};
|
|
311
381
|
}
|
|
312
382
|
|
|
@@ -356,6 +426,12 @@ export async function critiqueRun({ projectRoot, projectId, runId }) {
|
|
|
356
426
|
);
|
|
357
427
|
}
|
|
358
428
|
|
|
429
|
+
// EXTERNAL_VERIFIER: derive the generator model id from the run manifest and
|
|
430
|
+
// warn once if the rule-based judge ever coincides with it (it never does
|
|
431
|
+
// today — the rule engine is a different artifact class from the generator).
|
|
432
|
+
const generatorModel = generatorModelFromManifest(manifest);
|
|
433
|
+
warnIfSelfVerification(RULE_BASED_JUDGE, generatorModel, `run ${runId}`);
|
|
434
|
+
|
|
359
435
|
// Critique each candidate
|
|
360
436
|
const candidates = successfulOutputs.map(output =>
|
|
361
437
|
critiqueCandidate({
|
|
@@ -363,6 +439,7 @@ export async function critiqueRun({ projectRoot, projectId, runId }) {
|
|
|
363
439
|
workflow,
|
|
364
440
|
candidate: { filename: output.filename, seed: output.seed },
|
|
365
441
|
activeDimensions,
|
|
442
|
+
generatorModel,
|
|
366
443
|
})
|
|
367
444
|
);
|
|
368
445
|
|
|
@@ -377,6 +454,8 @@ export async function critiqueRun({ projectRoot, projectId, runId }) {
|
|
|
377
454
|
workflow_id: brief.workflow_id,
|
|
378
455
|
created_at: new Date().toISOString(),
|
|
379
456
|
active_dimensions: activeDimensions,
|
|
457
|
+
judged_by_model: RULE_BASED_JUDGE,
|
|
458
|
+
generator_model: generatorModel,
|
|
380
459
|
candidates,
|
|
381
460
|
recommended_action: recommended,
|
|
382
461
|
};
|
package/lib/critique-render.js
CHANGED
|
@@ -123,6 +123,51 @@ export function renderCritiqueMarkdown(report) {
|
|
|
123
123
|
return lines.join('\n');
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// ─── Triage renderer (UNCERTAINTY_GATED_HUMANS) ──────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render only the candidates that need human attention, with a contrastive
|
|
130
|
+
* lead explaining what was suppressed and why. `triage` is the output of
|
|
131
|
+
* critique-engine's triageCandidates({flagged, suppressed, driftThreshold}).
|
|
132
|
+
*/
|
|
133
|
+
export function renderCritiqueTriage(report, triage) {
|
|
134
|
+
const { flagged, suppressed, driftThreshold } = triage;
|
|
135
|
+
const total = flagged.length + suppressed.length;
|
|
136
|
+
const lines = [];
|
|
137
|
+
|
|
138
|
+
lines.push(`Critique triage: ${report.run_id}`);
|
|
139
|
+
lines.push(`Brief: ${report.brief_id} Workflow: ${report.workflow_id}`);
|
|
140
|
+
lines.push('');
|
|
141
|
+
// Contrastive framing: state the default (skip most) and why (attention gates
|
|
142
|
+
// on uncertainty, not on count). Horvitz CHI 1999 / Buçinca arXiv:2410.04253.
|
|
143
|
+
lines.push(
|
|
144
|
+
`Default: SKIP ${suppressed.length}/${total} candidate(s) the rule-based pass cleared. ` +
|
|
145
|
+
`Showing ${flagged.length} that need your attention — flagged as off-model OR ≥ ${driftThreshold} drift issues. ` +
|
|
146
|
+
`Attention gates on uncertainty, not on every item.`,
|
|
147
|
+
);
|
|
148
|
+
lines.push('');
|
|
149
|
+
|
|
150
|
+
if (flagged.length === 0) {
|
|
151
|
+
lines.push(' (nothing flagged — no candidate is off-model and none crossed the drift threshold)');
|
|
152
|
+
lines.push('');
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const c of flagged) {
|
|
157
|
+
const badge = FIT_BADGES[c.overall_fit] || '? unknown';
|
|
158
|
+
const driftCount = c.drift_issues?.length ?? 0;
|
|
159
|
+
const why = c.overall_fit === 'off-model'
|
|
160
|
+
? 'off-model'
|
|
161
|
+
: `${driftCount} drift issues (≥ ${driftThreshold})`;
|
|
162
|
+
lines.push(` ${badge} ${c.filename} (seed: ${c.seed ?? '?'}) — flagged: ${why}`);
|
|
163
|
+
if (c.drift_issues?.length > 0) {
|
|
164
|
+
lines.push(` drift: ${c.drift_issues.slice(0, 3).join('; ')}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
lines.push('');
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
126
171
|
// ─── Terminal text renderer ──────────────────────────────────────
|
|
127
172
|
|
|
128
173
|
/**
|
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;
|