@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.
- package/CHANGELOG.md +61 -0
- package/README.es.md +96 -63
- package/README.fr.md +97 -64
- package/README.hi.md +108 -77
- package/README.it.md +97 -64
- package/README.ja.md +105 -72
- package/README.md +33 -0
- package/README.pt-BR.md +94 -61
- package/README.zh.md +98 -65
- package/bin/sdlab.js +71 -0
- package/lib/adapters/ai-toolkit.js +209 -0
- package/lib/adapters/comfyui-runner.js +36 -2
- package/lib/adapters/comfyui-workflows.js +133 -2
- package/lib/adapters/diffusers-lora.js +46 -66
- package/lib/adapters/generic-image-caption.js +22 -50
- package/lib/canon-build/build.js +450 -0
- package/lib/canon-build/emitters/base.js +122 -0
- package/lib/canon-build/emitters/character.js +167 -0
- package/lib/canon-build/emitters/deity.js +165 -0
- package/lib/canon-build/emitters/faction.js +161 -0
- package/lib/canon-build/emitters/index.js +38 -0
- package/lib/canon-build/emitters/location.js +153 -0
- package/lib/canon-build/emitters/monster.js +131 -0
- package/lib/canon-build/emitters/relic.js +142 -0
- package/lib/canon-build/emitters/ship.js +154 -0
- package/lib/canon-build/emitters/species.js +165 -0
- package/lib/canon-build/frontmatter.js +76 -0
- package/lib/canon-build/lane-resolve.js +64 -0
- package/lib/canon-build/load-config.js +117 -0
- package/lib/canon-build/load-entry.js +62 -0
- package/lib/canon-build/load-schema.js +46 -0
- package/lib/captions.js +197 -0
- package/lib/config.js +19 -0
- package/lib/critique-engine.js +46 -1
- package/lib/critique-render.js +45 -0
- package/lib/freeze-events.js +109 -0
- package/lib/freeze-gate.js +208 -0
- package/lib/freeze-stamp.js +161 -0
- package/lib/rows.js +228 -0
- package/lib/run-manifest.js +308 -0
- package/lib/snapshot.js +7 -1
- package/lib/training-adapters.js +5 -0
- package/lib/training-packages.js +24 -18
- package/lib/training-profiles.js +33 -0
- package/lib/verifier.js +67 -0
- package/package.json +12 -3
- package/scripts/_batch_cut.py +71 -0
- package/scripts/_seam_cutout.py +74 -0
- package/scripts/canon-build.js +88 -0
- package/scripts/canon-drift.js +193 -0
- package/scripts/canon-freeze-status.js +85 -0
- package/scripts/canon-freeze.js +211 -0
- package/scripts/canon-unfreeze.js +137 -0
- package/scripts/critique.js +32 -7
- package/scripts/curate.js +33 -0
- package/scripts/generate-controlnet.js +12 -0
- package/scripts/generate-identity.js +20 -4
- package/scripts/generate-ipadapter.js +11 -0
- package/scripts/generate.js +262 -102
- package/scripts/qwen_generate.py +327 -0
- 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
|
+
}
|
package/lib/captions.js
ADDED
|
@@ -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
|
*/
|
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,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
|
};
|
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
|
/**
|