@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. 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 data = loadJsonFile(join(projectRoot, 'project.json'));
79
- if (!data) {
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 data;
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 data = loadJsonFile(join(projectRoot, 'constitution.json'));
115
- if (!data) return { rules: [] };
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 data = loadJsonFile(join(projectRoot, 'lanes.json'));
132
- if (!data) {
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 data = loadJsonFile(join(projectRoot, 'rubric.json'));
150
- if (!data) {
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 data;
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 data = loadJsonFile(join(projectRoot, 'terminology.json'));
165
- if (!data) {
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
 
@@ -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: `${usable.length} usable candidate(s) — ${sorted[0].filename} has fewest drift issues`,
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
  };
@@ -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
  /**
@@ -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 ratio = record.canon.pass_count / record.canon.assertion_count;
45
- if (ratio < profile.minimum_pass_ratio) {
46
- reasons.push(`pass ratio ${(ratio * 100).toFixed(0)}% below minimum ${(profile.minimum_pass_ratio * 100).toFixed(0)}%`);
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
- * @param {Array<{record_id: string, reasons: string[]}>} excluded
84
- * @returns {Object} — category → count mapping
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
- for (const { reasons } of excluded) {
98
- for (const r of reasons) {
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;