@mcptoolshop/style-dataset-lab 3.1.0 → 3.3.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 (61) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.es.md +96 -63
  3. package/README.fr.md +97 -64
  4. package/README.hi.md +108 -77
  5. package/README.it.md +97 -64
  6. package/README.ja.md +105 -72
  7. package/README.md +33 -0
  8. package/README.pt-BR.md +94 -61
  9. package/README.zh.md +98 -65
  10. package/bin/sdlab.js +71 -0
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-runner.js +36 -2
  13. package/lib/adapters/comfyui-workflows.js +133 -2
  14. package/lib/adapters/diffusers-lora.js +46 -66
  15. package/lib/adapters/generic-image-caption.js +22 -50
  16. package/lib/canon-build/build.js +450 -0
  17. package/lib/canon-build/emitters/base.js +122 -0
  18. package/lib/canon-build/emitters/character.js +167 -0
  19. package/lib/canon-build/emitters/deity.js +165 -0
  20. package/lib/canon-build/emitters/faction.js +161 -0
  21. package/lib/canon-build/emitters/index.js +38 -0
  22. package/lib/canon-build/emitters/location.js +153 -0
  23. package/lib/canon-build/emitters/monster.js +131 -0
  24. package/lib/canon-build/emitters/relic.js +142 -0
  25. package/lib/canon-build/emitters/ship.js +154 -0
  26. package/lib/canon-build/emitters/species.js +165 -0
  27. package/lib/canon-build/frontmatter.js +76 -0
  28. package/lib/canon-build/lane-resolve.js +64 -0
  29. package/lib/canon-build/load-config.js +117 -0
  30. package/lib/canon-build/load-entry.js +62 -0
  31. package/lib/canon-build/load-schema.js +46 -0
  32. package/lib/captions.js +197 -0
  33. package/lib/config.js +19 -0
  34. package/lib/critique-engine.js +46 -1
  35. package/lib/critique-render.js +45 -0
  36. package/lib/freeze-events.js +109 -0
  37. package/lib/freeze-gate.js +208 -0
  38. package/lib/freeze-stamp.js +161 -0
  39. package/lib/rows.js +228 -0
  40. package/lib/run-manifest.js +308 -0
  41. package/lib/snapshot.js +7 -1
  42. package/lib/training-adapters.js +5 -0
  43. package/lib/training-packages.js +24 -18
  44. package/lib/training-profiles.js +33 -0
  45. package/lib/verifier.js +67 -0
  46. package/package.json +12 -3
  47. package/scripts/_batch_cut.py +71 -0
  48. package/scripts/_seam_cutout.py +74 -0
  49. package/scripts/canon-build.js +88 -0
  50. package/scripts/canon-drift.js +193 -0
  51. package/scripts/canon-freeze-status.js +85 -0
  52. package/scripts/canon-freeze.js +211 -0
  53. package/scripts/canon-unfreeze.js +137 -0
  54. package/scripts/critique.js +32 -7
  55. package/scripts/curate.js +33 -0
  56. package/scripts/generate-controlnet.js +12 -0
  57. package/scripts/generate-identity.js +20 -4
  58. package/scripts/generate-ipadapter.js +11 -0
  59. package/scripts/generate.js +262 -102
  60. package/scripts/qwen_generate.py +327 -0
  61. package/scripts/run-generate.js +3 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Lane resolution for canon entries (D3 + D9).
3
+ *
4
+ * The build config declares, per schema, how to resolve an entry's lane:
5
+ * - { source: "constant", value: "creature" } → always that value
6
+ * - { source: "field", field: "visual.art_lane" } → read that dotted path
7
+ * from the entry's frontmatter
8
+ *
9
+ * Unmapped entries throw a structured error so an operator can fix the
10
+ * source of truth rather than letting the build silently drop a row.
11
+ */
12
+
13
+ import { inputError } from '../errors.js';
14
+ import { getByPath } from './load-config.js';
15
+
16
+ /**
17
+ * Resolve the lane for a single entry.
18
+ *
19
+ * @param {Object} entry — loaded canon entry ({ frontmatter, filePath, ... })
20
+ * @param {string} schemaName — e.g. "monster.schema.json"
21
+ * @param {Object} schemaToLane — config.schema_to_lane rule object
22
+ * @returns {string} resolved lane
23
+ * @throws SdlabError CANON_LANE_UNMAPPED when resolution fails
24
+ */
25
+ export function resolveLane(entry, schemaName, schemaToLane) {
26
+ const rule = schemaToLane[schemaName];
27
+ if (!rule) {
28
+ throw inputError(
29
+ 'CANON_LANE_UNMAPPED',
30
+ `canon-build config has no schema_to_lane rule for "${schemaName}" (entry ${entry.filePath})`,
31
+ `Add a rule to canon-build/config.json — e.g. { "source": "field", "field": "visual.art_lane" } or { "source": "constant", "value": "<lane>" }.`,
32
+ );
33
+ }
34
+
35
+ if (rule.source === 'constant') {
36
+ return rule.value;
37
+ }
38
+
39
+ if (rule.source === 'field') {
40
+ const value = getByPath(entry.frontmatter, rule.field);
41
+ if (value === undefined || value === null || value === '') {
42
+ throw inputError(
43
+ 'CANON_LANE_UNMAPPED',
44
+ `entry ${entry.filePath} has no value at "${rule.field}" (required to resolve lane for schema "${schemaName}")`,
45
+ `Add ${rule.field} to the entry's frontmatter, or switch the config rule to source: "constant" if the schema should use a fixed lane.`,
46
+ );
47
+ }
48
+ if (typeof value !== 'string') {
49
+ throw inputError(
50
+ 'CANON_LANE_UNMAPPED',
51
+ `entry ${entry.filePath} value at "${rule.field}" is not a string (got ${typeof value})`,
52
+ 'Lane resolution expects a string. Check the schema definition for this field.',
53
+ );
54
+ }
55
+ return value;
56
+ }
57
+
58
+ // load-config.js already rejects unknown sources; this is defense-in-depth.
59
+ throw inputError(
60
+ 'CANON_LANE_UNMAPPED',
61
+ `schema_to_lane rule for "${schemaName}" has unsupported source "${rule.source}"`,
62
+ 'Valid sources: "field" or "constant".',
63
+ );
64
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * canon-build/config.json loader + validator.
3
+ *
4
+ * The build config declares:
5
+ * - canon_root: absolute (or project-relative) path to the canon directory
6
+ * - schema_dir: absolute path to the schemas directory
7
+ * - entity_dirs: schema filename → subdirectory under canon_root
8
+ * - schema_to_lane: per-schema lane resolution (D3 / D9)
9
+ * - context_limits: per-schema max_lines (default 300)
10
+ * - profile_id: training profile id that supplies trigger + caption strategy
11
+ * (canon-build delegates to captions.js just like the adapters do).
12
+ * - freeze_watch_defaults: per-schema default watch_fields for the freeze
13
+ * witness chain (D5). Consumed by lib/freeze-stamp.js:resolveWatchFields.
14
+ */
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+ import { isAbsolute, resolve } from 'node:path';
18
+ import { existsSync } from 'node:fs';
19
+ import { inputError } from '../errors.js';
20
+
21
+ /**
22
+ * Load and validate a canon-build config.
23
+ *
24
+ * @param {string} configPath — absolute path to canon-build/config.json
25
+ * @param {string} projectRoot — for resolving project-relative paths
26
+ */
27
+ export async function loadBuildConfig(configPath, projectRoot) {
28
+ if (!existsSync(configPath)) {
29
+ throw inputError(
30
+ 'CANON_BUILD_CONFIG_NOT_FOUND',
31
+ `canon-build config not found at ${configPath}`,
32
+ `Create the file with a project_id, canon_root, schema_dir, entity_dirs, and schema_to_lane map. See the handbook page "Canon Build" for the schema.`,
33
+ );
34
+ }
35
+
36
+ const raw = await readFile(configPath, 'utf-8');
37
+ let config;
38
+ try {
39
+ config = JSON.parse(raw);
40
+ } catch (err) {
41
+ throw inputError(
42
+ 'CANON_BUILD_CONFIG_INVALID_JSON',
43
+ `canon-build config at ${configPath} is not valid JSON (${err.message})`,
44
+ 'Fix the JSON syntax; this file is the lane-resolution source of truth for the build.',
45
+ );
46
+ }
47
+
48
+ const errors = [];
49
+
50
+ if (!config.project_id) errors.push('missing required field: project_id');
51
+ if (!config.canon_root) errors.push('missing required field: canon_root');
52
+ if (!config.schema_dir) errors.push('missing required field: schema_dir');
53
+ if (!config.entity_dirs || typeof config.entity_dirs !== 'object') {
54
+ errors.push('missing or invalid entity_dirs (expected object mapping schema filename to subdirectory)');
55
+ }
56
+ if (!config.schema_to_lane || typeof config.schema_to_lane !== 'object') {
57
+ errors.push('missing or invalid schema_to_lane (expected object)');
58
+ } else {
59
+ for (const [schemaName, rule] of Object.entries(config.schema_to_lane)) {
60
+ if (!rule || typeof rule !== 'object') {
61
+ errors.push(`schema_to_lane["${schemaName}"] must be an object`);
62
+ continue;
63
+ }
64
+ if (rule.source === 'field') {
65
+ if (!rule.field || typeof rule.field !== 'string') {
66
+ errors.push(`schema_to_lane["${schemaName}"].source="field" requires a "field" string (dot-path)`);
67
+ }
68
+ } else if (rule.source === 'constant') {
69
+ if (!rule.value || typeof rule.value !== 'string') {
70
+ errors.push(`schema_to_lane["${schemaName}"].source="constant" requires a "value" string`);
71
+ }
72
+ } else {
73
+ errors.push(`schema_to_lane["${schemaName}"].source must be "field" or "constant" (got "${rule.source}")`);
74
+ }
75
+ }
76
+ }
77
+
78
+ if (errors.length) {
79
+ throw inputError(
80
+ 'CANON_BUILD_CONFIG_INVALID',
81
+ `canon-build config at ${configPath} is invalid:\n ${errors.join('\n ')}`,
82
+ 'Fix the listed fields and retry.',
83
+ );
84
+ }
85
+
86
+ // Resolve relative paths against the project root.
87
+ const canonRoot = isAbsolute(config.canon_root) ? config.canon_root : resolve(projectRoot, config.canon_root);
88
+ const schemaDir = isAbsolute(config.schema_dir) ? config.schema_dir : resolve(projectRoot, config.schema_dir);
89
+
90
+ return {
91
+ project_id: config.project_id,
92
+ canon_root: canonRoot,
93
+ schema_dir: schemaDir,
94
+ entity_dirs: config.entity_dirs,
95
+ schema_to_lane: config.schema_to_lane,
96
+ context_limits: config.context_limits || {},
97
+ profile_id: config.profile_id || null,
98
+ freeze_watch_defaults: config.freeze_watch_defaults || {},
99
+ // Preserve raw text for hashing — any change to the config invalidates cache.
100
+ raw_text: raw,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Read a nested value from an object via a dot-path (e.g. "visual.art_lane").
106
+ * Returns undefined if any segment is missing.
107
+ */
108
+ export function getByPath(obj, path) {
109
+ if (!obj || !path) return undefined;
110
+ const parts = path.split('.');
111
+ let cur = obj;
112
+ for (const p of parts) {
113
+ if (cur == null || typeof cur !== 'object') return undefined;
114
+ cur = cur[p];
115
+ }
116
+ return cur;
117
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Canon entry loader.
3
+ *
4
+ * Reads a canon MD file, splits frontmatter from body, returns a normalized
5
+ * entry object. Validation against the schema happens later in the
6
+ * orchestrator (after the schema has been loaded).
7
+ */
8
+
9
+ import { readFile, readdir } from 'node:fs/promises';
10
+ import { join, extname, basename } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { parseFrontmatter } from './frontmatter.js';
13
+
14
+ /**
15
+ * Load a single canon entry file.
16
+ *
17
+ * @param {string} filePath — absolute path to a .md file
18
+ * @returns {Promise<{ frontmatter: Object, body: string, rawYaml: string, rawText: string, filePath: string }>}
19
+ */
20
+ export async function loadCanonEntry(filePath) {
21
+ const text = await readFile(filePath, 'utf-8');
22
+ const parsed = parseFrontmatter(text, filePath);
23
+ return {
24
+ frontmatter: parsed.frontmatter,
25
+ body: parsed.body,
26
+ rawYaml: parsed.rawYaml,
27
+ rawText: text,
28
+ filePath,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Load all canon entries in a directory.
34
+ *
35
+ * Non-.md files are skipped; .md files that fail to parse throw with the
36
+ * filename included so the operator can fix the offending entry.
37
+ *
38
+ * @param {string} dir — absolute path to a canon-type directory
39
+ * @returns {Promise<Array<{frontmatter, body, rawYaml, rawText, filePath}>>}
40
+ */
41
+ export async function loadCanonEntriesInDir(dir) {
42
+ if (!existsSync(dir)) return [];
43
+ const files = await readdir(dir);
44
+ const entries = [];
45
+ for (const file of files.sort()) {
46
+ if (extname(file) !== '.md') continue;
47
+ const filePath = join(dir, file);
48
+ const entry = await loadCanonEntry(filePath);
49
+ entries.push(entry);
50
+ }
51
+ return entries;
52
+ }
53
+
54
+ /**
55
+ * Derive an entity id from a canon entry.
56
+ * Prefers frontmatter.id; falls back to filename stem for entries that
57
+ * haven't added the id field yet (validation will reject those downstream).
58
+ */
59
+ export function entryId(entry) {
60
+ if (entry.frontmatter?.id) return entry.frontmatter.id;
61
+ return basename(entry.filePath, '.md');
62
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Schema loader for canon-build.
3
+ *
4
+ * Reads a JSON Schema file and returns { schema, version }.
5
+ *
6
+ * `version` resolution (per post-research D7):
7
+ * 1. If the schema's top-level `version` field is a string, use it.
8
+ * 2. Otherwise fall back to SHA-256 of the schema file contents, prefixed
9
+ * with `content-sha:`. Any textual change (including whitespace)
10
+ * invalidates the fallback. Documented as a defensive bridge for
11
+ * projects whose schemas haven't been version-stamped yet.
12
+ */
13
+
14
+ import { readFile } from 'node:fs/promises';
15
+ import { createHash } from 'node:crypto';
16
+ import { inputError } from '../errors.js';
17
+
18
+ /**
19
+ * Load and resolve a single schema file.
20
+ *
21
+ * @param {string} schemaPath — absolute path to <name>.schema.json
22
+ * @returns {Promise<{ schema: Object, version: string, fileBytes: Buffer }>}
23
+ */
24
+ export async function loadSchema(schemaPath) {
25
+ const buf = await readFile(schemaPath);
26
+ let schema;
27
+ try {
28
+ schema = JSON.parse(buf.toString('utf-8'));
29
+ } catch (err) {
30
+ throw inputError(
31
+ 'CANON_SCHEMA_INVALID_JSON',
32
+ `Schema file is not valid JSON: ${schemaPath} (${err.message})`,
33
+ 'Validate the schema file with a JSON linter; malformed schemas cannot be used to validate canon entries.',
34
+ );
35
+ }
36
+
37
+ let version;
38
+ if (typeof schema.version === 'string' && schema.version.length > 0) {
39
+ version = schema.version;
40
+ } else {
41
+ const hash = createHash('sha256').update(buf).digest('hex');
42
+ version = `content-sha:${hash}`;
43
+ }
44
+
45
+ return { schema, version, fileBytes: buf };
46
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Caption building — shared between training adapters.
3
+ *
4
+ * Research-backed strategies for producing LoRA-training captions from
5
+ * approved canon records. Previously, both adapters carried near-duplicate
6
+ * buildCaption stubs that spliced `provenance.prompt` into the caption —
7
+ * a prompt-bleed antipattern that bakes generation-time style vocabulary
8
+ * (e.g. "oil painting, semi-realistic painterly, muted dusty palette...")
9
+ * back into the trigger token and corrupts downstream training.
10
+ *
11
+ * Strategies (from `profile.caption_strategy`):
12
+ * - 'legacy' (default when unset): preserves prior behavior exactly —
13
+ * trigger, group, lane, provenance.prompt[0..200]. Kept so existing
14
+ * training manifests reproduce bit-identical.
15
+ * - 'structured-metadata': trigger, faction, group, lane,
16
+ * judgment.explanation. No generation prompt. Research-backed for
17
+ * SDXL-style LoRAs where the trigger should absorb the style and
18
+ * captions describe scene content as comma-separated tags.
19
+ * - 'flux-natural-language': trigger + subject noun phrase + scene as
20
+ * a natural-language description. Recommended when
21
+ * `profile.target_family === 'flux'` because Flux's T5 text encoder
22
+ * holds identity across natural-language prompts much better than
23
+ * SDXL's CLIP does on comma-separated tags. See research notes.
24
+ *
25
+ * References:
26
+ * - Apatero LoRA Best Practices 2025 — trigger-first tag ordering
27
+ * - alvdansen HF blog — caption format variety
28
+ * - Ye et al. 2025 (arXiv 2510.09475) — multi-token DreamBooth+LoRA
29
+ * - Pelayo Arbues — Flux caption template (NL + scene variations)
30
+ * - Civitai Flux Character Caption Diary (article 6868) — natural
31
+ * language outperforms booru-style tags on Flux
32
+ */
33
+
34
+ /**
35
+ * Derive the style trigger token from a profile.
36
+ *
37
+ * If `profile.trigger_override` is a non-empty string, use it verbatim.
38
+ * Otherwise fall back to the hyphenated-id derivation:
39
+ * `character-style-lora` → `character_style_lora`.
40
+ *
41
+ * `trigger_override` exists so profile IDs can stay descriptive (e.g.
42
+ * `character-style-lora-flux`) while triggers stay compact, spec-compliant
43
+ * (`^[a-z0-9_]+$`), and game-prefixed. Validation of the override value
44
+ * (format + generic-suffix deny-list) lives in training-profiles.js so
45
+ * captions are never the gate for a malformed trigger.
46
+ *
47
+ * @param {{profile_id?: string, trigger_override?: string}} profile
48
+ * @returns {string|null}
49
+ */
50
+ export function deriveStyleTrigger(profile) {
51
+ if (!profile) return null;
52
+ if (typeof profile.trigger_override === 'string' && profile.trigger_override.length > 0) {
53
+ return profile.trigger_override;
54
+ }
55
+ if (!profile.profile_id) return null;
56
+ return profile.profile_id.replace(/-/g, '_');
57
+ }
58
+
59
+ /**
60
+ * Build a training caption from a record + context + profile.
61
+ *
62
+ * @param {Object} record — approved canon record (see schema in projects/**)
63
+ * @param {string|null} lane — partition lane (costume, equipment, ...)
64
+ * @param {string|null} group — partition group (faction, region, ...)
65
+ * @param {Object} profile — loaded training profile JSON
66
+ * @returns {string}
67
+ */
68
+ export function buildCaption(record, lane, group, profile) {
69
+ const strategy = profile?.caption_strategy || 'legacy';
70
+
71
+ switch (strategy) {
72
+ case 'structured-metadata':
73
+ return buildStructuredMetadataCaption(record, lane, group, profile);
74
+
75
+ case 'flux-natural-language':
76
+ return buildFluxNaturalLanguageCaption(record, lane, group, profile);
77
+
78
+ case 'legacy':
79
+ default:
80
+ return buildLegacyCaption(record, lane, group, profile);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Legacy caption — preserved verbatim from pre-refactor adapters so existing
86
+ * training manifests remain reproducible.
87
+ *
88
+ * Shape: `<trigger>, <group>, <lane>, <provenance.prompt sliced to 200>`
89
+ *
90
+ * WARNING: This strategy splices the raw generation prompt into the caption,
91
+ * which causes prompt bleed during training. Kept for backward compatibility
92
+ * only. New profiles should use `structured-metadata`.
93
+ */
94
+ function buildLegacyCaption(record, lane, group, profile) {
95
+ const parts = [];
96
+
97
+ if (profile?.prompt_strategy === 'trigger-word' && profile?.profile_id) {
98
+ parts.push(profile.profile_id.replace(/-/g, '_'));
99
+ }
100
+
101
+ if (group) parts.push(group);
102
+ if (lane) parts.push(lane);
103
+
104
+ const prompt = record?.provenance?.prompt;
105
+ if (prompt) {
106
+ parts.push(prompt.slice(0, 200));
107
+ } else if (record?.id) {
108
+ parts.push(record.id.replace(/_/g, ' '));
109
+ }
110
+
111
+ return parts.join(', ');
112
+ }
113
+
114
+ /**
115
+ * Structured-metadata caption.
116
+ *
117
+ * Builds captions from clean, canon-anchored fields only. Never touches
118
+ * `provenance.prompt` (which contains style vocabulary that causes bleed).
119
+ *
120
+ * Order follows the research rule that earlier tokens are weighted higher:
121
+ * 1. Style trigger (profile-derived) — absorbs the house style
122
+ * 2. Faction / group (canon-derived) — primary discriminator
123
+ * 3. Lane — category context
124
+ * 4. Scene description from judgment — short human scene notes
125
+ *
126
+ * Dedupes faction vs group when they're the same value. Trims empty
127
+ * segments. No segment exceeds a reasonable bound individually; full
128
+ * caption is expected to stay well under the 75-token practical cap.
129
+ */
130
+ function buildStructuredMetadataCaption(record, lane, group, profile) {
131
+ const parts = [];
132
+
133
+ const trigger = deriveStyleTrigger(profile);
134
+ if (trigger) parts.push(trigger);
135
+
136
+ const faction = record?.canon?.faction;
137
+ if (faction) parts.push(`${faction} faction`);
138
+
139
+ if (group && group !== faction) parts.push(group);
140
+
141
+ if (lane) parts.push(lane);
142
+
143
+ const scene = record?.judgment?.explanation;
144
+ if (scene) parts.push(scene.trim());
145
+
146
+ return parts.join(', ');
147
+ }
148
+
149
+ /**
150
+ * Flux natural-language caption.
151
+ *
152
+ * Flux's T5 text encoder holds identity across long, natural-language
153
+ * prompts where SDXL's CLIP loses identity on comma-separated tag lists.
154
+ * For Flux LoRAs the recommended caption shape is a full sentence:
155
+ * trigger-style descriptor, then a subject noun phrase, then a short
156
+ * scene sentence.
157
+ *
158
+ * Template (from the research): `[Trigger] style, a [Subject], [Scene].`
159
+ *
160
+ * Unlike structured-metadata, this strategy is prose-shaped — sentences
161
+ * with articles ("a", "the") rather than bare comma-joined tags. But it
162
+ * still uses only clean canon fields (faction, lane, judgment.explanation).
163
+ * Never touches `provenance.prompt`.
164
+ *
165
+ * Example (anchor_03_officer_v3):
166
+ * "character_style_lora style, a compact faction costume, Steel-blue
167
+ * uniform, high collar, ship corridor. Clean and institutional."
168
+ *
169
+ * Richer captions (hair / eyes / clothing / pose breakouts) require
170
+ * structured visual fields on the canon record that don't exist yet.
171
+ * This strategy is the floor — on-contract Flux captions from the fields
172
+ * we have. Expand the canon schema first, then enrich this strategy.
173
+ */
174
+ function buildFluxNaturalLanguageCaption(record, lane, group, profile) {
175
+ const segments = [];
176
+
177
+ const trigger = deriveStyleTrigger(profile);
178
+ if (trigger) segments.push(`${trigger} style`);
179
+
180
+ const faction = record?.canon?.faction;
181
+ const subjectParts = [];
182
+ if (faction) subjectParts.push(`${faction} faction`);
183
+ if (lane) {
184
+ subjectParts.push(lane);
185
+ } else if (group && group !== faction) {
186
+ subjectParts.push(group);
187
+ }
188
+
189
+ if (subjectParts.length > 0) {
190
+ segments.push(`a ${subjectParts.join(' ')}`);
191
+ }
192
+
193
+ const scene = record?.judgment?.explanation;
194
+ if (scene) segments.push(scene.trim());
195
+
196
+ return segments.join(', ');
197
+ }
package/lib/config.js CHANGED
@@ -88,6 +88,25 @@ export function loadProjectMeta(projectRoot) {
88
88
  return data;
89
89
  }
90
90
 
91
+ /**
92
+ * Default GPU label written into provenance when a project doesn't pin one.
93
+ * This rig is the Robot rig (Omen 45L, RTX 5090 32GB); older records say
94
+ * "RTX 5080" because the generator hardcoded the prior card.
95
+ */
96
+ export const DEFAULT_GPU_MODEL = 'RTX 5090';
97
+
98
+ /**
99
+ * Resolve the GPU label for a generation provenance record.
100
+ * Precedence: project.json `defaults.gpu_model` → DEFAULT_GPU_MODEL.
101
+ *
102
+ * @param {Object} [projectDefaults] — the `defaults` block from project.json
103
+ * @returns {string}
104
+ */
105
+ export function resolveGpuModel(projectDefaults) {
106
+ const v = projectDefaults?.gpu_model;
107
+ return typeof v === 'string' && v.trim() ? v.trim() : DEFAULT_GPU_MODEL;
108
+ }
109
+
91
110
  /**
92
111
  * Load constitution.json — array of rule objects.
93
112
  */
@@ -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,10 @@ 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
+ // EXTERNAL_VERIFIER: this rule-based engine is a different artifact class
259
+ // from the image generator, so it is a legitimate cross-artifact verifier.
260
+ judged_by_model: RULE_BASED_JUDGE,
261
+ generator_model: generatorModel,
257
262
  strengths,
258
263
  drift_issues: driftIssues,
259
264
  preserve_next_pass: preserveNextPass,
@@ -261,6 +266,37 @@ export function critiqueCandidate({ brief, workflow, candidate, activeDimensions
261
266
  };
262
267
  }
263
268
 
269
+ // ─── Triage (UNCERTAINTY_GATED_HUMANS) ───────────────────────────
270
+
271
+ /**
272
+ * Default drift-issue count at/above which a candidate is flagged for human
273
+ * attention. Tunable per invocation via `sdlab critique --triage --drift-threshold N`.
274
+ */
275
+ export const DEFAULT_DRIFT_THRESHOLD = 3;
276
+
277
+ /**
278
+ * Split candidates into the ones a human should look at vs the ones the
279
+ * rule-based pass cleared — so attention gates on UNCERTAINTY, not on every item
280
+ * (Buçinca et al. arXiv:2410.04253). A candidate is flagged when it is
281
+ * `off-model` (a human/LLM downgraded its fit) OR carries >= driftThreshold
282
+ * drift issues. The full report is unchanged; triage is a VIEW over it.
283
+ *
284
+ * @param {Array} candidates — report.candidates
285
+ * @param {Object} [opts]
286
+ * @param {number} [opts.driftThreshold=DEFAULT_DRIFT_THRESHOLD]
287
+ * @returns {{ flagged: Array, suppressed: Array, driftThreshold: number }}
288
+ */
289
+ export function triageCandidates(candidates, { driftThreshold = DEFAULT_DRIFT_THRESHOLD } = {}) {
290
+ const flagged = [];
291
+ const suppressed = [];
292
+ for (const c of candidates || []) {
293
+ const driftCount = c.drift_issues?.length ?? 0;
294
+ const uncertain = c.overall_fit === 'off-model' || driftCount >= driftThreshold;
295
+ (uncertain ? flagged : suppressed).push(c);
296
+ }
297
+ return { flagged, suppressed, driftThreshold };
298
+ }
299
+
264
300
  // ─── Run-level recommendation ────────────────────────────────────
265
301
 
266
302
  /**
@@ -356,6 +392,12 @@ export async function critiqueRun({ projectRoot, projectId, runId }) {
356
392
  );
357
393
  }
358
394
 
395
+ // EXTERNAL_VERIFIER: derive the generator model id from the run manifest and
396
+ // warn once if the rule-based judge ever coincides with it (it never does
397
+ // today — the rule engine is a different artifact class from the generator).
398
+ const generatorModel = generatorModelFromManifest(manifest);
399
+ warnIfSelfVerification(RULE_BASED_JUDGE, generatorModel, `run ${runId}`);
400
+
359
401
  // Critique each candidate
360
402
  const candidates = successfulOutputs.map(output =>
361
403
  critiqueCandidate({
@@ -363,6 +405,7 @@ export async function critiqueRun({ projectRoot, projectId, runId }) {
363
405
  workflow,
364
406
  candidate: { filename: output.filename, seed: output.seed },
365
407
  activeDimensions,
408
+ generatorModel,
366
409
  })
367
410
  );
368
411
 
@@ -377,6 +420,8 @@ export async function critiqueRun({ projectRoot, projectId, runId }) {
377
420
  workflow_id: brief.workflow_id,
378
421
  created_at: new Date().toISOString(),
379
422
  active_dimensions: activeDimensions,
423
+ judged_by_model: RULE_BASED_JUDGE,
424
+ generator_model: generatorModel,
380
425
  candidates,
381
426
  recommended_action: recommended,
382
427
  };
@@ -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
  /**