@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
@@ -0,0 +1,220 @@
1
+ /**
2
+ * judgment-provenance.js — tell a judgment somebody MADE from one a script MINTED.
3
+ *
4
+ * Law 2 of this product is "inclusion is explainable": every record carries a
5
+ * reason it was included or excluded. That law is only worth anything if the
6
+ * reason describes the actual image.
7
+ *
8
+ * It currently doesn't, at scale. Measured on `projects/star-freight`
9
+ * (1,182 records) on 2026-08-04:
10
+ *
11
+ * reviewer records distinct explanations max reuse
12
+ * bulk_curate_v2 566 ) 82 across both x144
13
+ * bulk_curate_v1 322 )
14
+ * wave25-script 60 8 x32
15
+ * human:mike 110 110 x1
16
+ * (no judgment) 124 — —
17
+ *
18
+ * 948 of 1,182 records — 80% — carry a rationale that was selected by matching
19
+ * the record id against a prefix regex, not by looking at the image. One string
20
+ * ("On-style gritty space costume. Faction-appropriate material vocabulary and
21
+ * wear level…") appears verbatim on 144 different images. The three scripts
22
+ * that wrote them (`bulk-curate-wave2-5.js`, `bulk-curate-waves11-18.js`,
23
+ * `curate-wave25.js`) were deleted in this repo's Stage A health pass for
24
+ * exactly this reason — but deleting the tool did not retract its output, and
25
+ * that output still flows into eligibility, snapshots, splits, exports and
26
+ * training packages.
27
+ *
28
+ * The `human:mike` row is the control that makes this a finding rather than a
29
+ * guess: 110 records, 110 distinct explanations, nothing reused. Genuine
30
+ * curation in this corpus looks like one rationale per image.
31
+ *
32
+ * ─────────────────────────────────────────────────────────────────────────
33
+ * THIS MODULE READS. IT NEVER WRITES.
34
+ *
35
+ * The Director's ruling (2026-08-04) was "mark, don't touch": make the
36
+ * distinction visible everywhere it matters, and leave the records alone. So
37
+ * there is no migration here, no backfilled flag, no rewritten judgment. The
38
+ * signal is derived at read time from data already on disk, which also means
39
+ * it cannot drift out of sync with the records and needs no re-run when they
40
+ * change.
41
+ * ─────────────────────────────────────────────────────────────────────────
42
+ */
43
+
44
+ /**
45
+ * Reviewer ids known to have been written by a bulk script rather than a
46
+ * person. Matched against `judgment.reviewer`.
47
+ *
48
+ * Deliberately a conservative allowlist of OBSERVED offenders, not a clever
49
+ * heuristic like /script|bot|auto/. A false positive here defames a real
50
+ * human judgment, which is worse than missing one: the corpus-level reuse
51
+ * statistic below catches anything this list doesn't, without needing to
52
+ * guess from a name.
53
+ */
54
+ export const SCRIPT_REVIEWER_PATTERNS = Object.freeze([
55
+ /^bulk_curate(_v\d+)?$/i,
56
+ /^wave\d+-script$/i,
57
+ ]);
58
+
59
+ /** A judgment is machine-minted, human-made, or absent. */
60
+ export const JUDGMENT_ORIGIN = Object.freeze({
61
+ HUMAN: 'human',
62
+ SCRIPT: 'script',
63
+ UNKNOWN: 'unknown',
64
+ NONE: 'none',
65
+ });
66
+
67
+ /**
68
+ * Classify a single record's judgment origin from its reviewer id.
69
+ *
70
+ * @param {object} record
71
+ * @returns {'human'|'script'|'unknown'|'none'}
72
+ */
73
+ export function classifyJudgmentOrigin(record) {
74
+ const judgment = record?.judgment;
75
+ if (!judgment || typeof judgment !== 'object') return JUDGMENT_ORIGIN.NONE;
76
+
77
+ const reviewer = judgment.reviewer;
78
+ if (typeof reviewer !== 'string' || reviewer.trim() === '') {
79
+ return JUDGMENT_ORIGIN.UNKNOWN;
80
+ }
81
+ if (SCRIPT_REVIEWER_PATTERNS.some((re) => re.test(reviewer))) {
82
+ return JUDGMENT_ORIGIN.SCRIPT;
83
+ }
84
+ // `human:<name>` is this corpus's convention for a person.
85
+ if (/^human(:|$)/i.test(reviewer)) return JUDGMENT_ORIGIN.HUMAN;
86
+
87
+ // A reviewer id we don't recognise. NOT assumed human — that assumption is
88
+ // how 948 fabricated rationales passed as curation for months.
89
+ return JUDGMENT_ORIGIN.UNKNOWN;
90
+ }
91
+
92
+ /**
93
+ * Corpus-level rationale-reuse audit.
94
+ *
95
+ * This is the evidence the reviewer-name check cannot give you, and it is the
96
+ * part that generalises: a rationale reused verbatim across many images did
97
+ * not describe any one of them, whoever or whatever wrote it. It catches a
98
+ * future bulk script under a reviewer id nobody has added to the list above.
99
+ *
100
+ * `reuseThreshold` is the count at which sharing stops being coincidence.
101
+ * Two records can legitimately share a terse reason ("out of focus"); 144
102
+ * cannot.
103
+ *
104
+ * @param {object[]} records
105
+ * @param {{reuseThreshold?: number}} [opts]
106
+ */
107
+ export function auditJudgmentProvenance(records, opts = {}) {
108
+ const reuseThreshold = opts.reuseThreshold ?? 3;
109
+
110
+ const byOrigin = { human: 0, script: 0, unknown: 0, none: 0 };
111
+ const byReviewer = new Map();
112
+ const byExplanation = new Map();
113
+
114
+ for (const record of records) {
115
+ const origin = classifyJudgmentOrigin(record);
116
+ byOrigin[origin]++;
117
+
118
+ const judgment = record?.judgment;
119
+ if (!judgment) continue;
120
+
121
+ const reviewer = typeof judgment.reviewer === 'string' ? judgment.reviewer : '(unset)';
122
+ byReviewer.set(reviewer, (byReviewer.get(reviewer) ?? 0) + 1);
123
+
124
+ const explanation = judgment.explanation;
125
+ if (typeof explanation === 'string' && explanation.trim() !== '') {
126
+ const key = explanation.trim();
127
+ if (!byExplanation.has(key)) byExplanation.set(key, []);
128
+ byExplanation.get(key).push(record.id);
129
+ }
130
+ }
131
+
132
+ const reused = [...byExplanation.entries()]
133
+ .filter(([, ids]) => ids.length >= reuseThreshold)
134
+ .map(([explanation, ids]) => ({
135
+ explanation,
136
+ count: ids.length,
137
+ sample_record_ids: ids.slice(0, 5),
138
+ }))
139
+ .sort((a, b) => b.count - a.count);
140
+
141
+ const judgedCount = byOrigin.human + byOrigin.script + byOrigin.unknown;
142
+ const reusedRecordCount = reused.reduce((sum, r) => sum + r.count, 0);
143
+
144
+ return {
145
+ total_records: records.length,
146
+ judged: judgedCount,
147
+ by_origin: byOrigin,
148
+ by_reviewer: Object.fromEntries([...byReviewer.entries()].sort((a, b) => b[1] - a[1])),
149
+ // How many judged records carry a rationale that is not unique to them.
150
+ // This is the honest headline number — it does not depend on recognising
151
+ // a reviewer id.
152
+ reused_rationale_records: reusedRecordCount,
153
+ reused_rationale_ratio: judgedCount > 0 ? reusedRecordCount / judgedCount : 0,
154
+ reuse_threshold: reuseThreshold,
155
+ // Worst offenders first, capped — this is for an operator to read, not a
156
+ // full dump of a 1,182-record corpus.
157
+ top_reused: reused.slice(0, 10),
158
+ // True when any judged record's rationale is shared at or above the
159
+ // threshold. Consumers gate their wording on this rather than re-deriving.
160
+ has_reused_rationales: reusedRecordCount > 0,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * One-line human summary for CLI output. Returns null when there is nothing
166
+ * to report, so a clean corpus prints nothing rather than a reassuring banner
167
+ * nobody reads.
168
+ *
169
+ * @param {ReturnType<typeof auditJudgmentProvenance>} audit
170
+ * @returns {string|null}
171
+ */
172
+ export function formatProvenanceWarning(audit) {
173
+ // ONLY a script-minted judgment is asserted as a Law 2 problem.
174
+ //
175
+ // Reuse alone is NOT evidence of fabrication, and an earlier revision of
176
+ // this function got that wrong. Measured on `projects/tallow-fen`: 54 of
177
+ // 293 rationales are reused, and they are legitimate — two strings, ×28 and
178
+ // ×26, each accurately describing ONE systematic defect across exactly the
179
+ // images that have it ("style on-target but the style descriptor's
180
+ // lantern-light noun literalized"). Reviewers there are `human:mike` and
181
+ // `claude:fable-5`; zero scripts. That is good curation of a bad wave.
182
+ //
183
+ // The heuristic cannot separate "144 images share a canned category blurb
184
+ // chosen by id-prefix" from "28 images share a true description of one
185
+ // shared flaw" — so it must not claim to. A gate that fires when nothing is
186
+ // wrong teaches its reader to ignore it, which is the same defect class as
187
+ // a gate that cannot fire, pointed the other way.
188
+ if (audit.by_origin.script === 0) return null;
189
+
190
+ const pct = Math.round((audit.by_origin.script / Math.max(audit.judged, 1)) * 100);
191
+ let msg =
192
+ `Judgment provenance: ${audit.by_origin.script} of ${audit.judged} judgments (${pct}%) ` +
193
+ `were written by a bulk script, not a reviewer. Those rationales were selected by ` +
194
+ `matching the record id against a pattern — they do not describe the image. ` +
195
+ `Treat them as uncurated for Law 2 purposes.`;
196
+
197
+ if (audit.reused_rationale_records > 0) {
198
+ msg += ` (${audit.reused_rationale_records} rationales are also reused verbatim across records.)`;
199
+ }
200
+ return msg;
201
+ }
202
+
203
+ /**
204
+ * Neutral, non-accusatory reuse note — for corpora with no script reviewer but
205
+ * heavy rationale reuse. Reuse there is usually a real systematic defect
206
+ * described accurately across a wave, which is worth SEEING but is not a
207
+ * finding. Returns null when there is nothing to say.
208
+ *
209
+ * @param {ReturnType<typeof auditJudgmentProvenance>} audit
210
+ * @returns {string|null}
211
+ */
212
+ export function formatReuseNote(audit) {
213
+ if (audit.by_origin.script > 0) return null; // the warning above covers it
214
+ if (audit.reused_rationale_records === 0) return null;
215
+
216
+ const pct = Math.round(audit.reused_rationale_ratio * 100);
217
+ return `${audit.reused_rationale_records} of ${audit.judged} rationales (${pct}%) are shared ` +
218
+ `across records — often one systematic defect described across the wave it affected. ` +
219
+ `Worth a glance, not necessarily a problem.`;
220
+ }
package/lib/measure.js ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Measure — orchestration for `sdlab measure`: resolve which images to
3
+ * measure, shell out to scripts/measure_image.py for the actual pixel math,
4
+ * and write the resulting numbers onto records.
5
+ *
6
+ * MEASUREMENT, NOT VERDICT (same spirit as lib/critique-engine.js's H2 fix):
7
+ * this module attaches a `measurements` block to a record. It never touches
8
+ * `judgment`, `canon`, or any pass/fail/approved/rejected field, and it
9
+ * never invents one. See scripts/measure_image.py's module docstring for
10
+ * the full port rationale (ported from the salt-road measure-palette.py /
11
+ * measure-texture.py instruments; DISTS/StyleLoss perceptual distance is
12
+ * deliberately out of scope — needs torch+piq).
13
+ *
14
+ * Python is an OPTIONAL runtime dependency of this ONE feature, not of the
15
+ * package (mirrors scripts/qwen_generate.py's relationship to the rest of
16
+ * sdlab, and tests/cli-scripts/qwen-python-parity.test.js's detection
17
+ * pattern, adopted here as findPython()/checkPythonDeps() so both
18
+ * scripts/measure.js and tests/lib-dataset/measure.test.js share one
19
+ * implementation instead of each hand-rolling their own).
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import {
24
+ existsSync, readFileSync, statSync, readdirSync, writeFileSync, mkdtempSync, rmSync,
25
+ } from 'node:fs';
26
+ import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
27
+ import { join, relative } from 'node:path';
28
+ import { tmpdir } from 'node:os';
29
+ import { getPackageRoot, resolveSafeProjectPath } from './paths.js';
30
+ import { inputError, runtimeError } from './errors.js';
31
+
32
+ // ─── Identity / provenance constants (mirrors lib/verifier.js's
33
+ // RULE_BASED_JUDGE / HUMAN_JUDGE naming discipline — "who produced this
34
+ // number" gets a stable id the same way "who judged this" does) ──────────
35
+
36
+ export const MEASURE_TOOL_ID = 'sdlab-measure-v1';
37
+ export const MEASURE_SCHEMA_VERSION = '1.0.0';
38
+
39
+ const IMAGE_EXT_RE = /\.(png|jpe?g)$/i;
40
+
41
+ // Ported verbatim from measure-palette.py's saturated-pixel gate — see
42
+ // scripts/measure_image.py's module docstring for the "ignore greys/darks"
43
+ // rationale. Kept in sync with the Python-side defaults by hand (both files
44
+ // document the same origin; there is no cross-language pinning contract
45
+ // here the way there is for comfy_workflow_sha, since these are STATIC
46
+ // defaults, not per-run computed values).
47
+ const DEFAULT_SAT_MIN = 0.22;
48
+ const DEFAULT_VAL_MIN = 0.18;
49
+ const DEFAULT_HUE_TOLERANCE_DEG = 20;
50
+
51
+ const REQUIRED_PY_MODULES = ['PIL', 'numpy', 'scipy'];
52
+
53
+ const MEASURE_SCRIPT = join(getPackageRoot(), 'scripts', 'measure_image.py');
54
+
55
+ // ─── Python environment detection ──────────────────────────────────────
56
+
57
+ /**
58
+ * Locate a working Python interpreter. Precedence mirrors the rest of this
59
+ * codebase's env-var-override convention (paths.js's SDLAB_ROOT resolution:
60
+ * cheapest/most-explicit signal first): SDLAB_PYTHON env var if set, else
61
+ * try "python" then "python3" on PATH. Returns the command string to spawn,
62
+ * or null if nothing worked.
63
+ * @param {NodeJS.ProcessEnv} [env]
64
+ * @returns {string|null}
65
+ */
66
+ export function findPython(env = process.env) {
67
+ const candidates = env.SDLAB_PYTHON ? [env.SDLAB_PYTHON] : ['python', 'python3'];
68
+ for (const cmd of candidates) {
69
+ const res = spawnSync(cmd, ['--version'], { encoding: 'utf-8' });
70
+ if (res.status === 0) return cmd;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Probe whether `pythonCmd` has the packages measure_image.py needs.
77
+ * Reports EVERY missing module in one pass (not just the first import
78
+ * failure) so the resulting error names all of them at once.
79
+ * @param {string} pythonCmd
80
+ * @param {{modules?: string[]}} [opts] — override the probed module list (tests use this)
81
+ * @returns {{ok: boolean, missing: string[]}}
82
+ */
83
+ export function checkPythonDeps(pythonCmd, { modules = REQUIRED_PY_MODULES } = {}) {
84
+ const probe =
85
+ 'import json\n' +
86
+ 'mods = {}\n' +
87
+ `for m in ${JSON.stringify(modules)}:\n` +
88
+ ' try:\n' +
89
+ ' __import__(m)\n' +
90
+ ' mods[m] = True\n' +
91
+ ' except ImportError:\n' +
92
+ ' mods[m] = False\n' +
93
+ 'print(json.dumps(mods))\n';
94
+ const res = spawnSync(pythonCmd, ['-c', probe], { encoding: 'utf-8' });
95
+ if (res.status !== 0) {
96
+ return { ok: false, missing: [...modules], raw: (res.stderr || res.error?.message || 'probe failed').trim() };
97
+ }
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse((res.stdout || '').trim());
101
+ } catch {
102
+ return { ok: false, missing: [...modules], raw: (res.stdout || '').trim() };
103
+ }
104
+ const missing = modules.filter((m) => !parsed[m]);
105
+ return { ok: missing.length === 0, missing };
106
+ }
107
+
108
+ /**
109
+ * Throwing convenience wrapper for the CLI boundary: locate Python and
110
+ * verify its deps, or fail with a clear, actionable SdlabError naming
111
+ * exactly what is missing. This is the ONLY function in this module that
112
+ * throws on a missing/incomplete Python environment — findPython() and
113
+ * checkPythonDeps() stay pure so tests can drive every branch directly.
114
+ * @param {NodeJS.ProcessEnv} [env]
115
+ * @returns {string} the python command to use
116
+ */
117
+ export function assertMeasurementPrereqs(env = process.env) {
118
+ const python = findPython(env);
119
+ if (!python) {
120
+ throw runtimeError(
121
+ 'RUNTIME_PYTHON_MISSING',
122
+ 'Python was not found on PATH (tried "python", "python3").',
123
+ 'sdlab measure needs Python 3.9+ with Pillow, numpy, and scipy for image measurement ' +
124
+ '(see scripts/measure_image.py). Install Python and ensure it is on PATH, or set ' +
125
+ 'SDLAB_PYTHON to an explicit interpreter path.'
126
+ );
127
+ }
128
+ const deps = checkPythonDeps(python);
129
+ if (!deps.ok) {
130
+ throw runtimeError(
131
+ 'RUNTIME_PYTHON_DEPS_MISSING',
132
+ `Python found (${python}) but missing required package(s): ${deps.missing.join(', ')}.`,
133
+ `Install them: pip install pillow numpy scipy${deps.raw ? ` (probe said: ${deps.raw})` : ''}`
134
+ );
135
+ }
136
+ return python;
137
+ }
138
+
139
+ // ─── Target resolution ──────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Minimal `*`/`?` glob-to-RegExp. No npm glob dependency exists in this
143
+ * package (checked before writing this — lib/ and scripts/ have none) and
144
+ * this file may not add one (package.json is outside this feature's
145
+ * exclusive file ownership), so record-id matching gets a small hand-rolled
146
+ * matcher instead of a library.
147
+ */
148
+ function globToRegExp(pattern) {
149
+ let out = '';
150
+ for (const ch of pattern) {
151
+ if (ch === '*') out += '.*';
152
+ else if (ch === '?') out += '.';
153
+ else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
154
+ }
155
+ return new RegExp(`^${out}$`);
156
+ }
157
+
158
+ /**
159
+ * Resolve the `<dir-or-record-glob>` positional into a concrete list of
160
+ * {id, recordPath, imagePath} targets.
161
+ *
162
+ * - If it resolves to an existing directory (inside the project — see
163
+ * resolveSafeProjectPath), every *.png/*.jpg/*.jpeg inside is paired
164
+ * with records/<stem>.json by filename stem. Images with no matching
165
+ * record are reported in `skipped`, never silently dropped (mirrors
166
+ * lib/runtime-runs.js listRuns()'s B01 fix: always report a count,
167
+ * detail under --verbose).
168
+ * - Otherwise it is treated as a glob PATTERN matched against record ids
169
+ * under <project>/records/*.json; each match's image comes from that
170
+ * record's own `asset_path` (a trusted, already-on-disk field — joined
171
+ * plainly, same as curate.js does with record.asset_path, not run
172
+ * through resolveSafeProjectPath a second time).
173
+ *
174
+ * @param {string} projectRoot
175
+ * @param {string} targetArg
176
+ * @returns {{targets: Array<{id:string, recordPath:string, imagePath:string}>, skipped: Array<{id:string, reason:string}>}}
177
+ */
178
+ export function resolveMeasureTargets(projectRoot, targetArg) {
179
+ if (!targetArg) {
180
+ throw inputError(
181
+ 'INPUT_MISSING_ARGS',
182
+ 'sdlab measure requires a <dir-or-record-glob> positional argument.',
183
+ 'Example: sdlab measure outputs/approved --project salt-road, or ' +
184
+ 'sdlab measure "styleset_p*" --project salt-road (quote glob patterns so your shell does not expand them).'
185
+ );
186
+ }
187
+
188
+ const recordsDir = join(projectRoot, 'records');
189
+ // Security boundary FIRST: whether targetArg turns out to be a directory
190
+ // or a glob pattern, an escape attempt (`..`, an absolute path outside
191
+ // the project) is rejected before either interpretation is considered.
192
+ const asPath = resolveSafeProjectPath(projectRoot, targetArg, { flagName: 'target' });
193
+
194
+ const targets = [];
195
+ const skipped = [];
196
+ const isDir = existsSync(asPath) && statSync(asPath).isDirectory();
197
+
198
+ if (isDir) {
199
+ const files = readdirSync(asPath, { withFileTypes: true })
200
+ .filter((e) => e.isFile() && IMAGE_EXT_RE.test(e.name))
201
+ .map((e) => e.name)
202
+ .sort();
203
+ for (const file of files) {
204
+ const id = file.replace(IMAGE_EXT_RE, '');
205
+ const recordPath = join(recordsDir, `${id}.json`);
206
+ if (existsSync(recordPath)) {
207
+ targets.push({ id, recordPath, imagePath: join(asPath, file) });
208
+ } else {
209
+ skipped.push({ id, reason: `no matching record at records/${id}.json` });
210
+ }
211
+ }
212
+ } else {
213
+ const re = globToRegExp(targetArg);
214
+ const files = existsSync(recordsDir) ? readdirSync(recordsDir).filter((f) => f.endsWith('.json')) : [];
215
+ for (const file of files) {
216
+ const id = file.slice(0, -'.json'.length);
217
+ if (!re.test(id)) continue;
218
+ const recordPath = join(recordsDir, file);
219
+ const record = JSON.parse(readFileSync(recordPath, 'utf-8'));
220
+ if (!record.asset_path) {
221
+ skipped.push({ id, reason: 'record has no asset_path' });
222
+ continue;
223
+ }
224
+ targets.push({ id, recordPath, imagePath: join(projectRoot, record.asset_path) });
225
+ }
226
+ }
227
+
228
+ if (targets.length === 0) {
229
+ throw inputError(
230
+ 'INPUT_NO_MATCHES',
231
+ `No records matched "${targetArg}" under ${recordsDir}.`,
232
+ isDir
233
+ ? 'The directory exists but no image inside it has a matching records/<id>.json.'
234
+ : 'Check the glob pattern (quote it so your shell does not expand it), or pass an existing directory of images instead.'
235
+ );
236
+ }
237
+
238
+ return { targets, skipped };
239
+ }
240
+
241
+ // ─── Anchors ─────────────────────────────────────────────────────────────
242
+
243
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
244
+
245
+ /**
246
+ * Load a palette anchor config from `--anchors <path>`.
247
+ *
248
+ * constitution.json carries no hex/numeric palette data for either salt-road
249
+ * or star-freight (checked directly before building this — both files are
250
+ * qualitative rule descriptions with rationale templates, not colour
251
+ * values; the actual hex anchors salt-road uses live in prose in
252
+ * canon/salt-road-art-contract.md, not in machine-readable config). So this
253
+ * is a real `--anchors` flag, not a schema invented to route around a
254
+ * missing feature.
255
+ *
256
+ * Unlike --source/--outputs elsewhere in this codebase (which legitimately
257
+ * reference shared cross-project directories, hence their REPO_ROOT-scoped
258
+ * containment), --anchors is scoped to the PROJECT root: anchor definitions
259
+ * are project-specific canon data, same as constitution.json/lanes.json.
260
+ *
261
+ * @param {string|undefined} anchorsFlag — raw --anchors value, or undefined
262
+ * @param {string} projectRoot
263
+ * @returns {{anchors: Array<{name:string,hex:string}>, hue_tolerance_deg: number, sat_min: number, val_min: number, source: string|null}}
264
+ */
265
+ export function loadAnchors(anchorsFlag, projectRoot) {
266
+ if (!anchorsFlag) {
267
+ return {
268
+ anchors: [],
269
+ hue_tolerance_deg: DEFAULT_HUE_TOLERANCE_DEG,
270
+ sat_min: DEFAULT_SAT_MIN,
271
+ val_min: DEFAULT_VAL_MIN,
272
+ source: null,
273
+ };
274
+ }
275
+
276
+ const absPath = resolveSafeProjectPath(projectRoot, anchorsFlag, { flagName: 'anchors' });
277
+ if (!existsSync(absPath)) {
278
+ throw inputError('INPUT_ANCHORS_NOT_FOUND', `--anchors file not found: ${absPath}`);
279
+ }
280
+ let data;
281
+ try {
282
+ data = JSON.parse(readFileSync(absPath, 'utf-8'));
283
+ } catch (err) {
284
+ throw inputError('INPUT_INVALID_JSON', `--anchors file is not valid JSON: ${absPath} (${err.message})`);
285
+ }
286
+ const anchors = Array.isArray(data) ? data : data.anchors;
287
+ if (!Array.isArray(anchors) || anchors.length === 0) {
288
+ throw inputError(
289
+ 'INPUT_BAD_ANCHORS',
290
+ `${absPath} must contain a non-empty "anchors" array of {name, hex}.`,
291
+ 'Example: { "anchors": [{"name":"ochre-warm","hex":"#c9a877"}] }'
292
+ );
293
+ }
294
+ for (const a of anchors) {
295
+ if (!a || typeof a.name !== 'string' || !a.name || typeof a.hex !== 'string' || !HEX_RE.test(a.hex)) {
296
+ throw inputError(
297
+ 'INPUT_BAD_ANCHORS',
298
+ `Invalid anchor entry in ${absPath}: ${JSON.stringify(a)}`,
299
+ 'Each anchor needs a "name" string and a "hex" color like "#c9a877".'
300
+ );
301
+ }
302
+ }
303
+ return {
304
+ anchors,
305
+ hue_tolerance_deg: data.hue_tolerance_deg ?? DEFAULT_HUE_TOLERANCE_DEG,
306
+ sat_min: data.sat_min ?? DEFAULT_SAT_MIN,
307
+ val_min: data.val_min ?? DEFAULT_VAL_MIN,
308
+ source: relative(projectRoot, absPath),
309
+ };
310
+ }
311
+
312
+ // ─── Python invocation ───────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Spawn scripts/measure_image.py once for a whole batch of images (one
316
+ * process, not one-per-image — keeps interpreter-startup overhead off the
317
+ * per-image cost). Writes the request to a temp file, mirroring
318
+ * scripts/qwen_generate.py's `--wave <path>` handoff shape.
319
+ *
320
+ * @param {string} pythonCmd
321
+ * @param {{images: Array<{id:string,path:string}>, anchors?: Array, hue_tolerance_deg?: number, sat_min?: number, val_min?: number}} request
322
+ * @returns {{schema_version, tool, results: Array, errors: Array, ok_count: number, error_count: number}}
323
+ */
324
+ export function runPythonMeasurement(pythonCmd, request) {
325
+ const dir = mkdtempSync(join(tmpdir(), 'sdlab-measure-'));
326
+ try {
327
+ const requestPath = join(dir, 'request.json');
328
+ const outPath = join(dir, 'result.json');
329
+ writeFileSync(requestPath, JSON.stringify(request, null, 2));
330
+
331
+ const res = spawnSync(pythonCmd, [MEASURE_SCRIPT, '--request', requestPath, '--out', outPath], {
332
+ encoding: 'utf-8',
333
+ });
334
+
335
+ if (!existsSync(outPath)) {
336
+ throw runtimeError(
337
+ 'RUNTIME_MEASURE_FAILED',
338
+ `measure_image.py did not produce a result (exit ${res.status ?? 'unknown'}).`,
339
+ (res.stderr || res.stdout || '').trim().slice(0, 2000) || 'Run with --debug for detail.'
340
+ );
341
+ }
342
+ return JSON.parse(readFileSync(outPath, 'utf-8'));
343
+ } finally {
344
+ rmSync(dir, { recursive: true, force: true });
345
+ }
346
+ }
347
+
348
+ // ─── Record writing (atomic) ─────────────────────────────────────────────
349
+
350
+ /**
351
+ * Temp-file-in-same-dir + rename — the exact pattern documented in
352
+ * lib/runtime-runs.js's atomicWriteJson (that function is module-private
353
+ * there, and is duplicated ad hoc in lib/batch-runs.js and
354
+ * lib/reingest-selected.js too — this file cannot import a shared version
355
+ * because none is exported anywhere in the codebase; extracting one would
356
+ * mean editing those files, which sit outside this feature's exclusive file
357
+ * ownership). Same tmp-name scheme as all three existing copies, for
358
+ * consistency: `${targetPath}.tmp-${pid}-${timestamp}`.
359
+ */
360
+ async function atomicWriteJson(targetPath, obj) {
361
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
362
+ const body = JSON.stringify(obj, null, 2) + '\n';
363
+ try {
364
+ await writeFile(tmpPath, body);
365
+ await rename(tmpPath, targetPath);
366
+ } catch (err) {
367
+ try {
368
+ await unlink(tmpPath);
369
+ } catch {
370
+ /* ignore */
371
+ }
372
+ throw err;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Build the `measurements` block and (unless dryRun) write it onto each
378
+ * matched record. Idempotent by design — an unchanged image + unchanged
379
+ * code reproduces the exact same palette/texture numbers on every run;
380
+ * only `measured_at` moves forward, the same way re-running `sdlab curate`
381
+ * or `sdlab bind` overwrites the prior judgment/binding rather than
382
+ * appending to a history log (see the sdlab compensators table).
383
+ *
384
+ * @param {Array<{id:string, recordPath:string}>} targets
385
+ * @param {{results: Array, errors: Array}} pyResult
386
+ * @param {{anchorsSource: string|null, dryRun: boolean}} opts
387
+ * @returns {Promise<{applied: Array<{id:string, measurements:Object}>, failed: Array<{id:string, error:string}>}>}
388
+ */
389
+ export async function applyMeasurements(targets, pyResult, { anchorsSource = null, dryRun = false } = {}) {
390
+ const byId = new Map((pyResult.results || []).map((r) => [r.id, r]));
391
+ const errById = new Map((pyResult.errors || []).map((e) => [e.id, e]));
392
+ const applied = [];
393
+ const failed = [];
394
+
395
+ for (const t of targets) {
396
+ const ok = byId.get(t.id);
397
+ if (!ok) {
398
+ const err = errById.get(t.id);
399
+ failed.push({ id: t.id, error: err?.error || 'no result returned for this image' });
400
+ continue;
401
+ }
402
+
403
+ const measurements = {
404
+ measured_at: new Date().toISOString(),
405
+ tool: MEASURE_TOOL_ID,
406
+ schema_version: MEASURE_SCHEMA_VERSION,
407
+ anchors_source: anchorsSource,
408
+ image: ok.image,
409
+ palette: ok.palette,
410
+ texture: ok.texture,
411
+ };
412
+ applied.push({ id: t.id, measurements });
413
+
414
+ if (!dryRun) {
415
+ const record = JSON.parse(await readFile(t.recordPath, 'utf-8'));
416
+ record.measurements = measurements;
417
+ await atomicWriteJson(t.recordPath, record);
418
+ }
419
+ }
420
+
421
+ return { applied, failed };
422
+ }