@mcptoolshop/style-dataset-lab 3.4.0 → 3.5.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 (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.es.md +111 -59
  3. package/README.fr.md +117 -67
  4. package/README.hi.md +109 -70
  5. package/README.it.md +111 -60
  6. package/README.ja.md +116 -76
  7. package/README.md +58 -2
  8. package/README.pt-BR.md +119 -64
  9. package/README.zh.md +108 -67
  10. package/bin/sdlab.js +50 -1
  11. package/lib/adapters/ai-toolkit.js +187 -30
  12. package/lib/adapters/comfyui-workflows.js +84 -9
  13. package/lib/asset-ingest.js +575 -0
  14. package/lib/asset-masks.js +323 -0
  15. package/lib/asset-source.js +1176 -0
  16. package/lib/canon-author.js +350 -0
  17. package/lib/canon-review-gate.js +205 -0
  18. package/lib/colab-launcher-guards.js +285 -0
  19. package/lib/export.js +1 -1
  20. package/lib/freeze-gate.js +3 -0
  21. package/lib/generated-provenance.js +178 -5
  22. package/lib/lane1-render.js +668 -0
  23. package/lib/memory-store.js +663 -0
  24. package/lib/model-licences.js +305 -0
  25. package/lib/npy-meta.js +130 -0
  26. package/lib/png-meta.js +512 -0
  27. package/lib/records.js +54 -8
  28. package/lib/reingest-selected.js +15 -1
  29. package/lib/reingest.js +27 -3
  30. package/lib/run-manifest.js +59 -11
  31. package/lib/train-runs.js +214 -0
  32. package/lib/verifier.js +3 -0
  33. package/lib/verify-chain.js +367 -0
  34. package/package.json +2 -2
  35. package/scripts/asset-ingest.js +160 -0
  36. package/scripts/canon-draft.js +82 -0
  37. package/scripts/canon-freeze.js +4 -0
  38. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  39. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  40. package/scripts/colab/colab_window.sh +587 -0
  41. package/scripts/colab/lane1_render.py +970 -0
  42. package/scripts/colab/launch_guards.py +564 -0
  43. package/scripts/colab/launch_lane1_stub.sh +309 -0
  44. package/scripts/colab/launch_lane3_stub.sh +366 -0
  45. package/scripts/colab/remote_runner.py +59 -0
  46. package/scripts/doctor.js +1 -0
  47. package/scripts/generate.js +38 -12
  48. package/scripts/verify.js +67 -0
@@ -0,0 +1,1176 @@
1
+ /**
2
+ * Asset-source contract — load and validate an `asset-source.json` manifest,
3
+ * the ingest contract of the asset lane (docs/asset-lane-design.md).
4
+ *
5
+ * One manifest per exported asset, living in the export directory. The
6
+ * manifest carries ALL semantics (channels, palettes, classes, tolerances,
7
+ * gates, acceptance); this module carries none — it proves declarations
8
+ * against bytes:
9
+ *
10
+ * 1. schema — shape, ids, the acceptance gate (verdict must be
11
+ * literally "accepted"; the Director's eye is the most
12
+ * canon-bound gate the studio has)
13
+ * 2. containment — every referenced path resolves INSIDE the manifest's
14
+ * directory (the export dir is untrusted input) and exists
15
+ * 3. encoding — a PNG's IHDR must match its declared encoding; an NPY
16
+ * header must match its declared dtype/shape. A manifest
17
+ * that "looks right" but disagrees with its bytes is the
18
+ * failure class this lane exists to stop.
19
+ * 4. categorical — indexed: PLTE ⊆ declared palette (structural chunk
20
+ * proof, the brand/E09 contract — no pixel decode);
21
+ * non-indexed + filter "nearest": EXHAUSTIVE decode proof
22
+ * (every non-transparent pixel ∈ declared palette — a
23
+ * complete proof, not a sample); non-indexed + "linear":
24
+ * no proof here — classification happens at ingest as a
25
+ * measurement with an honest unclassified share; npy:
26
+ * EXHAUSTIVE value proof against declared integer classes.
27
+ *
28
+ * All violations are collected and refused in ONE loud error (ANDON:
29
+ * nothing registers on a bad manifest), with the code chosen by the most
30
+ * meaningful violation class present.
31
+ *
32
+ * ── Schema 1.1.0 ───────────────────────────────────────────────────────
33
+ * Additive, per facet E11 Ruling 6: the sidecars that rode UNDECLARED
34
+ * beside the declared channels become declarable, so they are contained,
35
+ * proven, hashed and carried into the record instead of being invisible.
36
+ *
37
+ * - render-space `npy` — per-view owner/class maps (`owner_id_*.npy`).
38
+ * 1.0.0 refused these outright; the proof is a
39
+ * header match PLUS shape parallel to the
40
+ * render, numpy-axis-aware ([H, W], the
41
+ * transpose a render-space array gets wrong).
42
+ * - categorical `npy` — declares `classes: [{name, value}]` instead of
43
+ * an rgb `palette`, proven exhaustively.
44
+ * - `json` encoding — the measurement/camera sidecars
45
+ * (`admission_*.json`, `cam.json`). The proof is
46
+ * deliberately weak and SAID SO: parses as an
47
+ * object, declared `required_keys` present. sdlab
48
+ * does not read the values — a number in there
49
+ * means what the asset says it means.
50
+ * - `identity.subject_name` — the split engine's authored subject-family
51
+ * key. Without it lib/split.js falls back to
52
+ * id-stem guessing, which reads two ingests of
53
+ * one subject as two families and lets the same
54
+ * subject sit in train and test.
55
+ *
56
+ * Every addition is OPTIONAL, so 1.0.0 manifests validate unchanged — which
57
+ * is why this is 1.1.0 and not 2.0.0: a major bump would refuse the very
58
+ * manifests E11 ruled to be the training input. The version gate is
59
+ * minor-aware in the other direction: a manifest declaring a HIGHER minor
60
+ * than this sdlab speaks is refused loudly rather than silently stripped of
61
+ * the channels this build cannot see.
62
+ *
63
+ * ── Schema 1.2.0 ───────────────────────────────────────────────────────
64
+ * Additive, per facet E12 Ruling 10b (the style directive), Rulings 23c/23f
65
+ * (the harmonization adoption) and Rulings 20a/24b (two measured generation
66
+ * phenomena). Same discipline as 1.1.0 — every addition OPTIONAL, so 1.0.0
67
+ * and 1.1.0 manifests validate unchanged. Hence 1.2.0, not 2.0.0.
68
+ *
69
+ * - `asset.style` — THE REGISTER IS SUBJECT DATA. Ruling 10b was
70
+ * bought by a rejection: the painterly register
71
+ * two subjects earned was inherited by a third
72
+ * that should read ultra-realistic — "a style
73
+ * decision nobody made." The register is an
74
+ * authored property of the SUBJECT, declared
75
+ * here beside the palette bands, never derived
76
+ * from the dataset and never inherited.
77
+ *
78
+ * `style.lora` carries a REQUIRED `declared`
79
+ * discriminator — "none" or "card". That is the
80
+ * ruling expressed mechanically: NO-LoRA is a
81
+ * positive declaration, not the absence of a
82
+ * field. A serializer that drops nulls cannot
83
+ * turn a ruled NONE into silence, and "no
84
+ * fixture may leave the section implicit again"
85
+ * becomes something bytes can prove.
86
+ *
87
+ * - `asset.tone_transform` — a deterministic, generation-free tone map
88
+ * applied to the generated views BEFORE they
89
+ * became the projection sources these renders
90
+ * derive from (facet's Lab colour-statistics
91
+ * harmonization toward a reference view,
92
+ * adopted at Ruling 23f). Declared because this
93
+ * lane MEASURES colour — the palette gate, the
94
+ * class shares, the pre-registered ΔE
95
+ * experiment — and an undeclared upstream tone
96
+ * map is a systematic covariate sitting under
97
+ * every one of those numbers. Pooling
98
+ * harmonized-source and raw-source assets is a
99
+ * legitimate choice; doing it unknowingly is
100
+ * not.
101
+ *
102
+ * The proof is structural and the boundary is
103
+ * stated out loud, as with `json`: sdlab checks
104
+ * that `reference` names a render THIS manifest
105
+ * declares, and contains + hashes + carries the
106
+ * operands sidecar. It does NOT verify that the
107
+ * transform was applied, that the operands are
108
+ * correct, or that the arithmetic is
109
+ * reversible. It records what the asset
110
+ * declared, so a consumer can group by it and
111
+ * an audit can replay it.
112
+ *
113
+ * - `renders[].generation` — per-image generation frame + seed, because
114
+ * both are MEASURED curation axes, not trivia:
115
+ * (1) bust/crop-framed generation drifts
116
+ * register — three independent instances
117
+ * (Ruling 24b); (2) term binding is
118
+ * seed-dependent — one seed resisted a prompt
119
+ * term across three independent stems where the
120
+ * next seed bound it (Ruling 20a), and one
121
+ * view's flat-black limb reproduced three times
122
+ * at that seed and cured all three times at the
123
+ * next (Ruling 23d). A curator who cannot ask
124
+ * "which records came from a crop frame" or
125
+ * "which came from that seed" cannot act on
126
+ * either finding.
127
+ *
128
+ * `generation.frame` is a CLOSED two-value enum
129
+ * ("full" | "crop") for the same reason
130
+ * `facing` is a derived vocabulary: it is the
131
+ * axis the phenomenon was measured on, and a
132
+ * free-text field where one asset writes "bust"
133
+ * and another "head-crop" cannot be queried.
134
+ * Everything finer is the asset's own business
135
+ * and rides in `frame_detail`.
136
+ *
137
+ * ── Schema 1.3.0 ───────────────────────────────────────────────────────
138
+ * One addition, closing a defect 1.2.0 shipped with. Per facet E11 Ruling 2
139
+ * (endorsed as E11 addenda 3), a dense turnaround's renders are
140
+ * DETERMINISTIC DERIVATIONS of the accepted asset by the anchored emit path
141
+ * — not generated images. They therefore carry no `generation` block, and
142
+ * *correctly* so: "an optional block that does not apply is left out, not
143
+ * filled in."
144
+ *
145
+ * 1.2.0 could not tell that apart from an export that simply failed to
146
+ * declare its seeds, so it reported the gap on both — which would have told
147
+ * an operator to go fix an export that was right by ruling. Crying wolf on
148
+ * correct data is how a notice channel stops being read.
149
+ *
150
+ * - `asset.render_derivation` — {kind, generated, record}. `generated` is
151
+ * the load-bearing bit, and it is the same
152
+ * silence-vs-declaration principle as
153
+ * `style.lora.declared`: the manifest SAYS its
154
+ * renders are derivations rather than leaving
155
+ * the lane to infer it from an absence.
156
+ *
157
+ * When `generated: false`, a per-render
158
+ * `generation` block is REFUSED — a
159
+ * deterministic derivation has no seed of its
160
+ * own, so declaring one is a category error
161
+ * worth catching rather than recording. The
162
+ * generating provenance of the twins such a
163
+ * render was projected FROM lives on the asset
164
+ * side; it is not this render's.
165
+ *
166
+ * Optional, so 1.0.0–1.2.0 manifests validate unchanged; hence 1.3.0, not
167
+ * 2.0.0. Undeclared keeps 1.2.0's behaviour exactly — the gap notice still
168
+ * fires — because an export that declares nothing genuinely has not said
169
+ * whether its renders were generated.
170
+ */
171
+
172
+ import { readFile } from 'node:fs/promises';
173
+ import { existsSync } from 'node:fs';
174
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
175
+ import { inputError } from './errors.js';
176
+ import { SAFE_ID_RE } from './export.js';
177
+ import { parsePngMeta, decodePng } from './png-meta.js';
178
+ import { parseNpyHeader, readNpyValues, NPY_PROVABLE_DTYPES } from './npy-meta.js';
179
+ import { rgbAt, alphaAt } from './asset-masks.js';
180
+
181
+ export const ASSET_SCHEMA_VERSION = '1.3.0';
182
+ export const ASSET_MANIFEST_FILENAME = 'asset-source.json';
183
+
184
+ const ENCODINGS = new Set(['indexed', 'rgb', 'rgba', 'grayscale', 'npy', 'json']);
185
+
186
+ /**
187
+ * Schema 1.2.0. `none` is the ruled NO-LoRA register (facet E12 Ruling 10b:
188
+ * "none at all is better than making the same texture for everything"); a
189
+ * manifest must SAY it rather than omit the field, so silence can never be
190
+ * read as a decision.
191
+ */
192
+ export const LORA_DECLARATIONS = new Set(['none', 'card']);
193
+
194
+ /**
195
+ * Schema 1.2.0. The measured curation axis for the register-drift phenomenon
196
+ * (E12 Ruling 24b): the subject whole in frame, versus a sub-region of it
197
+ * generated at higher pixel density (bust, head-crop, detail crop).
198
+ */
199
+ export const GENERATION_FRAMES = new Set(['full', 'crop']);
200
+
201
+ /**
202
+ * Encodings that decode to pixels. Consumers that measure images (class
203
+ * shares at ingest) must gate on this — since 1.1.0 a channel instance can
204
+ * be an npy array or a json sidecar, and neither answers to decodePng.
205
+ */
206
+ export const PIXEL_ENCODINGS = new Set(['indexed', 'rgb', 'rgba', 'grayscale']);
207
+ const COLOR_TYPE_BY_ENCODING = { grayscale: 0, rgb: 2, indexed: 3, rgba: 6 };
208
+ const RENDER_COLOR_TYPES = new Set([2, 6]); // trainable renders: color, 8-bit
209
+
210
+ // Violation classes, ordered by which code the single thrown error carries.
211
+ // The message always lists EVERY violation; the code names the loudest class.
212
+ const CLASS_ORDER = [
213
+ 'ASSET_NOT_ACCEPTED',
214
+ 'ASSET_MANIFEST_INVALID',
215
+ 'ASSET_PATH_ESCAPE',
216
+ 'ASSET_FILE_MISSING',
217
+ 'ASSET_ENCODING_MISMATCH',
218
+ 'ASSET_PALETTE_PROOF_FAILED',
219
+ ];
220
+
221
+ function v(cls, path, message) {
222
+ return { cls, path, message };
223
+ }
224
+
225
+ function isNonEmptyString(x) {
226
+ return typeof x === 'string' && x.trim().length > 0;
227
+ }
228
+
229
+ function safeId(x) {
230
+ return typeof x === 'string' && SAFE_ID_RE.test(x);
231
+ }
232
+
233
+ /**
234
+ * Validate `asset.style` (schema 1.2.0) — the STYLE-SUPPLIED section that
235
+ * facet E12 Ruling 10b requires every fixture to carry.
236
+ *
237
+ * The block is optional (1.0.0/1.1.0 manifests validate unchanged), but it is
238
+ * ALL-OR-NOTHING once present: a style block that names a register without
239
+ * answering the LoRA question is exactly the implicit section the ruling
240
+ * forbids, so it is refused rather than half-accepted.
241
+ *
242
+ * @param {Object} style — the manifest's asset.style
243
+ * @param {Function} bad — (jsonPath, message) => void
244
+ */
245
+ function validateStyle(style, bad) {
246
+ if (typeof style !== 'object' || Array.isArray(style)) {
247
+ bad('asset.style', 'must be an object {register, lora}');
248
+ return;
249
+ }
250
+
251
+ // register — the terms the subject was ruled to read as
252
+ if (!style.register || typeof style.register !== 'object' || Array.isArray(style.register)) {
253
+ bad('asset.style.register', 'required object {terms: [string], ruling?, record?} — the register is subject data, not a studio default');
254
+ } else {
255
+ const terms = style.register.terms;
256
+ if (!Array.isArray(terms) || terms.length === 0 || !terms.every(isNonEmptyString)) {
257
+ bad('asset.style.register.terms', 'required non-empty array of non-empty strings (e.g. ["ultra-realistic", "menacing"])');
258
+ }
259
+ for (const key of ['ruling', 'record']) {
260
+ if (style.register[key] != null && !isNonEmptyString(style.register[key])) {
261
+ bad(`asset.style.register.${key}`, 'must be a non-empty string when present (the ruling that decided this register, and where it is recorded)');
262
+ }
263
+ }
264
+ }
265
+
266
+ // lora — the discriminator is the whole point: NONE is declared, not omitted
267
+ if (!style.lora || typeof style.lora !== 'object' || Array.isArray(style.lora)) {
268
+ bad('asset.style.lora',
269
+ 'required object — a style block must answer the LoRA question explicitly. Use {"declared":"none"} for the ruled no-LoRA register, or {"declared":"card","card":"<live card name>"}. Omitting it is the implicit-section failure E12 Ruling 10b forbids.');
270
+ } else if (!LORA_DECLARATIONS.has(style.lora.declared)) {
271
+ bad('asset.style.lora.declared',
272
+ `must be one of ${[...LORA_DECLARATIONS].join(', ')} — "none" is a positive declaration that this subject runs with NO LoRA, not the absence of a value`);
273
+ } else if (style.lora.declared === 'card') {
274
+ if (!isNonEmptyString(style.lora.card)) {
275
+ bad('asset.style.lora.card', 'required non-empty string when declared is "card" — the LIVE card name as it exists in the model library, not a nickname');
276
+ }
277
+ if (style.lora.weight != null && typeof style.lora.weight !== 'number') {
278
+ bad('asset.style.lora.weight', 'must be a number when present');
279
+ }
280
+ } else {
281
+ // declared: "none" — carrying a card alongside it is a contradiction, and
282
+ // silently preferring one over the other is how a register drifts back in.
283
+ if (style.lora.card != null) {
284
+ bad('asset.style.lora.card', 'must not be present when declared is "none" — a no-LoRA register that names a card contradicts itself');
285
+ }
286
+ if (style.lora.weight != null && style.lora.weight !== 0) {
287
+ bad('asset.style.lora.weight', 'must be absent or 0 when declared is "none"');
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Validate `asset.tone_transform` (schema 1.2.0) — a deterministic tone map
294
+ * applied upstream of these renders. Optional; complete once present.
295
+ *
296
+ * `reference` is checked against the manifest's own render ids: a reference
297
+ * view that this manifest does not declare is dangling provenance, and that
298
+ * IS checkable structurally. Everything semantic is the asset's.
299
+ *
300
+ * @param {Object} tt — the manifest's asset.tone_transform
301
+ * @param {Set<string>} renderIds — ids declared in renders[]
302
+ * @param {Function} bad — (jsonPath, message) => void
303
+ */
304
+ function validateToneTransform(tt, renderIds, bad) {
305
+ if (typeof tt !== 'object' || Array.isArray(tt)) {
306
+ bad('asset.tone_transform', 'must be an object');
307
+ return;
308
+ }
309
+ if (!isNonEmptyString(tt.kind)) {
310
+ bad('asset.tone_transform.kind', 'required non-empty string naming the transform (e.g. "lab-stats-transfer") — sdlab does not interpret it, it records and groups by it');
311
+ }
312
+ if (!isNonEmptyString(tt.reference)) {
313
+ bad('asset.tone_transform.reference', 'required — the render id every other view was transferred toward');
314
+ } else if (renderIds.size > 0 && !renderIds.has(tt.reference)) {
315
+ bad('asset.tone_transform.reference',
316
+ `"${tt.reference}" is not a render id declared in this manifest — a tone transform whose reference view is not in the export leaves the provenance dangling`);
317
+ }
318
+ if (!isNonEmptyString(tt.operands)) {
319
+ bad('asset.tone_transform.operands', 'required path to the recorded per-view operands — it is contained, hashed and carried so the transform can be audited or replayed');
320
+ }
321
+ for (const key of ['space', 'scope', 'record']) {
322
+ if (tt[key] != null && !isNonEmptyString(tt[key])) {
323
+ bad(`asset.tone_transform.${key}`, 'must be a non-empty string when present');
324
+ }
325
+ }
326
+ if (tt.reversible != null && typeof tt.reversible !== 'boolean') {
327
+ bad('asset.tone_transform.reversible', 'must be a boolean when present — this is the asset\'s claim, which sdlab records without verifying');
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Validate `asset.render_derivation` (schema 1.3.0) — how these renders came
333
+ * to exist. Optional; complete once present.
334
+ *
335
+ * The point of the block is `generated`. A dense turnaround's renders are
336
+ * deterministic derivations of an already-accepted asset (facet E11 Ruling
337
+ * 2), so they have no seed and no generation frame — and an export that
338
+ * leaves `generation` out for that reason is CORRECT, where an export that
339
+ * leaves it out because nobody recorded the seeds is not. Only the manifest
340
+ * can tell those apart, so it says which.
341
+ *
342
+ * @param {Object} rd — the manifest's asset.render_derivation
343
+ * @param {Function} bad — (jsonPath, message) => void
344
+ */
345
+ function validateRenderDerivation(rd, bad) {
346
+ if (typeof rd !== 'object' || Array.isArray(rd)) {
347
+ bad('asset.render_derivation', 'must be an object {kind, generated, record?}');
348
+ return;
349
+ }
350
+ if (!isNonEmptyString(rd.kind)) {
351
+ bad('asset.render_derivation.kind',
352
+ 'required non-empty string naming the path these renders came by (e.g. "emit") — sdlab does not interpret it, it records and groups by it');
353
+ }
354
+ if (typeof rd.generated !== 'boolean') {
355
+ bad('asset.render_derivation.generated',
356
+ 'required boolean — true when these renders are generated images (seeds and frames apply), false when they are deterministic derivations of an already-accepted asset (no seed of their own). This is a declaration, not an inference from whether a generation block happens to be present.');
357
+ }
358
+ if (rd.record != null && !isNonEmptyString(rd.record)) {
359
+ bad('asset.render_derivation.record', 'must be a non-empty string when present (where this derivation path is recorded)');
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Validate `renders[].generation` (schema 1.2.0) — per-image generation
365
+ * provenance for curation. Optional; complete once present.
366
+ *
367
+ * @param {Object} gen — the render's generation block
368
+ * @param {string} p — the render's json path prefix
369
+ * @param {Function} bad — (jsonPath, message) => void
370
+ */
371
+ function validateGeneration(gen, p, bad) {
372
+ if (typeof gen !== 'object' || Array.isArray(gen)) {
373
+ bad(`${p}.generation`, 'must be an object');
374
+ return;
375
+ }
376
+ if (!GENERATION_FRAMES.has(gen.frame)) {
377
+ bad(`${p}.generation.frame`,
378
+ `must be one of ${[...GENERATION_FRAMES].join(', ')} — "full" is the whole subject in frame, "crop" is a sub-region generated at higher pixel density (bust, head-crop, detail). This is the axis the register-drift phenomenon was measured on; asset-specific naming goes in frame_detail.`);
379
+ }
380
+ // Seed: integer or digit-string. A digit-string is legal because seeds can
381
+ // exceed what a JSON number holds exactly, and a seed that silently loses
382
+ // its low bits cannot be grouped on — which is the entire reason it is here.
383
+ if (gen.seed != null) {
384
+ const okInt = Number.isInteger(gen.seed) && Number.isSafeInteger(gen.seed);
385
+ const okStr = typeof gen.seed === 'string' && /^\d+$/.test(gen.seed);
386
+ if (!okInt && !okStr) {
387
+ bad(`${p}.generation.seed`, 'must be a safe integer or a string of digits (a string keeps seeds too large for exact JSON numbers groupable)');
388
+ }
389
+ }
390
+ for (const key of ['frame_detail', 'stem', 'model', 'reroll_of']) {
391
+ if (gen[key] != null && !isNonEmptyString(gen[key])) {
392
+ bad(`${p}.generation.${key}`, 'must be a non-empty string when present');
393
+ }
394
+ }
395
+ }
396
+
397
+ // ─── 1. Schema ───────────────────────────────────────────────────────
398
+
399
+ /**
400
+ * Validate the manifest's shape. Pure — no filesystem access.
401
+ * @returns {Array<{cls:string, path:string, message:string}>}
402
+ */
403
+ export function validateManifestShape(m) {
404
+ const out = [];
405
+ const bad = (path, message) => out.push(v('ASSET_MANIFEST_INVALID', path, message));
406
+
407
+ if (!m || typeof m !== 'object' || Array.isArray(m)) {
408
+ bad('$', 'manifest must be a JSON object');
409
+ return out;
410
+ }
411
+
412
+ // schema_version — major must match; a HIGHER minor is refused rather
413
+ // than accepted-and-silently-stripped. Additive minors mean a newer
414
+ // manifest's extra channels would parse as "absent" to this build, and an
415
+ // asset lane that quietly drops declared provenance is the failure class
416
+ // this module exists to stop.
417
+ if (!isNonEmptyString(m.schema_version) || !/^\d+\.\d+\.\d+$/.test(m.schema_version)) {
418
+ bad('schema_version', 'must be a semver string, e.g. "1.1.0"');
419
+ } else {
420
+ const [major, minor] = m.schema_version.split('.').map(Number);
421
+ const [ourMajor, ourMinor] = ASSET_SCHEMA_VERSION.split('.').map(Number);
422
+ if (major !== ourMajor) {
423
+ bad('schema_version', `major version ${m.schema_version} is not supported (this sdlab speaks ${ASSET_SCHEMA_VERSION})`);
424
+ } else if (minor > ourMinor) {
425
+ bad('schema_version', `${m.schema_version} declares a newer minor than this sdlab speaks (${ASSET_SCHEMA_VERSION}) — it may declare channels this build cannot see, and ingesting it would silently drop them. Upgrade sdlab.`);
426
+ }
427
+ }
428
+
429
+ // identity — optional, but the split engine's only authored subject key
430
+ if (m.identity != null) {
431
+ if (typeof m.identity !== 'object' || Array.isArray(m.identity)) {
432
+ bad('identity', 'must be an object');
433
+ } else if (m.identity.subject_name != null && !safeId(m.identity.subject_name)) {
434
+ bad('identity.subject_name', 'must match [A-Za-z0-9._-]+ — it is the split engine\'s subject-family key, and two ingests of one subject must declare the SAME value or splits will read them as two families');
435
+ }
436
+ }
437
+
438
+ // asset
439
+ if (!m.asset || typeof m.asset !== 'object') {
440
+ bad('asset', 'required object');
441
+ } else {
442
+ if (!safeId(m.asset.id)) bad('asset.id', 'required; must match [A-Za-z0-9._-]+');
443
+ for (const key of ['mesh', 'atlas']) {
444
+ const ref = m.asset[key];
445
+ if (ref != null && (typeof ref !== 'object' || !isNonEmptyString(ref.path))) {
446
+ bad(`asset.${key}`, 'when present, must be an object with a non-empty "path"');
447
+ }
448
+ }
449
+ // schema 1.2.0 — the STYLE-SUPPLIED section (E12 Ruling 10b)
450
+ if (m.asset.style != null) validateStyle(m.asset.style, bad);
451
+ // schema 1.3.0 — how these renders came to exist (E11 Ruling 2)
452
+ if (m.asset.render_derivation != null) validateRenderDerivation(m.asset.render_derivation, bad);
453
+ }
454
+
455
+ // acceptance — the door
456
+ if (!m.acceptance || typeof m.acceptance !== 'object') {
457
+ out.push(v('ASSET_NOT_ACCEPTED', 'acceptance', 'required — only accepted assets are admissible; the acceptance block records the Gate verdict'));
458
+ } else {
459
+ if (m.acceptance.verdict !== 'accepted') {
460
+ out.push(v('ASSET_NOT_ACCEPTED', 'acceptance.verdict',
461
+ `is "${m.acceptance.verdict}" — only verdict "accepted" passes the door`));
462
+ }
463
+ if (!isNonEmptyString(m.acceptance.gate)) bad('acceptance.gate', 'required non-empty string (which gate accepted this asset)');
464
+ if (!isNonEmptyString(m.acceptance.date) || Number.isNaN(Date.parse(m.acceptance.date))) {
465
+ bad('acceptance.date', 'required parseable date (ISO recommended)');
466
+ }
467
+ if (!isNonEmptyString(m.acceptance.record)) bad('acceptance.record', 'required non-empty link/path to the acceptance ruling');
468
+ }
469
+
470
+ // palette
471
+ if (!m.palette || typeof m.palette !== 'object') {
472
+ bad('palette', 'required object — bands come with the asset, never derived from the dataset');
473
+ } else {
474
+ if (typeof m.palette.min_chroma !== 'number' || m.palette.min_chroma < 0) {
475
+ bad('palette.min_chroma', 'required number >= 0 (the chroma floor is load-bearing)');
476
+ }
477
+ if (!Array.isArray(m.palette.allowed_bands) || m.palette.allowed_bands.length === 0) {
478
+ bad('palette.allowed_bands', 'required non-empty array of {name, hue_deg: [lo, hi]}');
479
+ } else {
480
+ m.palette.allowed_bands.forEach((b, i) => {
481
+ if (!b || typeof b !== 'object' || !isNonEmptyString(b.name)) {
482
+ bad(`palette.allowed_bands[${i}].name`, 'required non-empty string');
483
+ }
484
+ const ok = Array.isArray(b?.hue_deg) && b.hue_deg.length === 2 &&
485
+ b.hue_deg.every((h) => typeof h === 'number' && h >= 0 && h <= 360);
486
+ if (!ok) bad(`palette.allowed_bands[${i}].hue_deg`, 'required [lo, hi] with both in 0–360 (lo > hi wraps around 0°)');
487
+ });
488
+ }
489
+ const gate = m.palette.gate;
490
+ if (!gate || typeof gate !== 'object') {
491
+ bad('palette.gate', 'required object {max_offpalette_pct: number|null, max_offpalette_blob_px: int}');
492
+ } else {
493
+ if (!Number.isInteger(gate.max_offpalette_blob_px) || gate.max_offpalette_blob_px < 0) {
494
+ bad('palette.gate.max_offpalette_blob_px', 'required integer >= 0 (the load-bearing blob gate)');
495
+ }
496
+ if (gate.max_offpalette_pct != null && typeof gate.max_offpalette_pct !== 'number') {
497
+ bad('palette.gate.max_offpalette_pct', 'must be a number or null (null = withdrawn, diagnostic only)');
498
+ }
499
+ }
500
+ }
501
+
502
+ // channels
503
+ const channelById = new Map();
504
+ if (m.channels != null && !Array.isArray(m.channels)) {
505
+ bad('channels', 'must be an array of channel declarations');
506
+ } else {
507
+ (m.channels || []).forEach((c, i) => {
508
+ const p = `channels[${i}]`;
509
+ if (!c || typeof c !== 'object') { bad(p, 'must be an object'); return; }
510
+ if (!safeId(c.id)) bad(`${p}.id`, 'required; must match [A-Za-z0-9._-]+');
511
+ else if (channelById.has(c.id)) bad(`${p}.id`, `duplicate channel id "${c.id}"`);
512
+ else channelById.set(c.id, c);
513
+ if (c.space !== 'texture' && c.space !== 'render') bad(`${p}.space`, 'must be "texture" or "render"');
514
+ if (!ENCODINGS.has(c.encoding)) bad(`${p}.encoding`, `must be one of ${[...ENCODINGS].join(', ')}`);
515
+ if (c.space === 'texture' && !isNonEmptyString(c.path)) {
516
+ bad(`${p}.path`, 'texture-space channels declare their single file here');
517
+ }
518
+ if (c.space === 'render' && c.path != null) {
519
+ bad(`${p}.path`, 'render-space channels have per-render instances (renders[].channels), not a path here');
520
+ }
521
+ if (c.categorical) {
522
+ if (c.encoding === 'json') {
523
+ bad(`${p}`, 'json channels cannot be categorical — sdlab does not read their values, so it can prove nothing about classes');
524
+ } else if (c.encoding === 'npy') {
525
+ // Integer classes, not rgb — an owner/class array has no colors.
526
+ if (!Array.isArray(c.classes) || c.classes.length === 0) {
527
+ bad(`${p}.classes`, 'categorical npy channels must declare integer classes [{name, value: int}] (rgb palettes are for pixel encodings)');
528
+ } else {
529
+ const names = new Set();
530
+ const values = new Set();
531
+ c.classes.forEach((cls, j) => {
532
+ if (!cls || !isNonEmptyString(cls.name)) bad(`${p}.classes[${j}].name`, 'required non-empty string');
533
+ else if (names.has(cls.name)) bad(`${p}.classes[${j}].name`, `duplicate class name "${cls.name}"`);
534
+ else names.add(cls.name);
535
+ if (!Number.isInteger(cls?.value)) bad(`${p}.classes[${j}].value`, 'required integer (the value carried in the array, e.g. -1 for "unowned")');
536
+ else if (values.has(cls.value)) bad(`${p}.classes[${j}].value`, `duplicate class value ${cls.value}`);
537
+ else values.add(cls.value);
538
+ });
539
+ }
540
+ if (c.dtype != null && !NPY_PROVABLE_DTYPES.has(c.dtype)) {
541
+ bad(`${p}.dtype`, `categorical npy channels must use a dtype whose values can be read (${[...NPY_PROVABLE_DTYPES].join(', ')}); "${c.dtype}" would leave the class declaration unproven`);
542
+ }
543
+ } else {
544
+ if (!Array.isArray(c.palette) || c.palette.length === 0) {
545
+ bad(`${p}.palette`, 'categorical channels must declare their class palette [{name, rgb: [r,g,b]}]');
546
+ } else {
547
+ const names = new Set();
548
+ c.palette.forEach((cls, j) => {
549
+ if (!cls || !isNonEmptyString(cls.name)) bad(`${p}.palette[${j}].name`, 'required non-empty string');
550
+ else if (names.has(cls.name)) bad(`${p}.palette[${j}].name`, `duplicate class name "${cls.name}"`);
551
+ else names.add(cls.name);
552
+ const ok = Array.isArray(cls?.rgb) && cls.rgb.length === 3 &&
553
+ cls.rgb.every((n) => Number.isInteger(n) && n >= 0 && n <= 255);
554
+ if (!ok) bad(`${p}.palette[${j}].rgb`, 'required [r, g, b] ints 0–255');
555
+ });
556
+ }
557
+ if (c.filter !== 'nearest' && c.filter !== 'linear') {
558
+ bad(`${p}.filter`, 'categorical channels must declare filter "nearest" (exact proof) or "linear" (tolerance classification)');
559
+ }
560
+ if (c.filter === 'linear' && (typeof c.classification_tolerance !== 'number' || c.classification_tolerance < 0)) {
561
+ bad(`${p}.classification_tolerance`, 'linear categorical channels must declare a tolerance >= 0 (max RGB distance)');
562
+ }
563
+ }
564
+ }
565
+ if (c.encoding === 'npy') {
566
+ if (!isNonEmptyString(c.dtype)) bad(`${p}.dtype`, 'npy channels must declare dtype (e.g. "|b1") — proven against the header');
567
+ if (c.space === 'texture') {
568
+ // One file, one declared shape.
569
+ if (!Array.isArray(c.shape) || c.shape.length === 0 || !c.shape.every((n) => Number.isInteger(n) && n >= 0)) {
570
+ bad(`${p}.shape`, 'texture-space npy channels must declare shape (array of ints) — proven against the header');
571
+ }
572
+ } else if (c.shape != null) {
573
+ // Render-space instances are proven parallel to their own render,
574
+ // which a channel-level shape cannot express across differently
575
+ // sized renders. Declaring one invites the two to disagree.
576
+ bad(`${p}.shape`, 'render-space npy channels do not declare shape — each instance is proven [height, width] against its own render (numpy row-major)');
577
+ }
578
+ }
579
+ if (c.encoding === 'json' && c.required_keys != null) {
580
+ const ok = Array.isArray(c.required_keys) && c.required_keys.every((k) => isNonEmptyString(k));
581
+ if (!ok) bad(`${p}.required_keys`, 'when present, must be an array of non-empty top-level key names');
582
+ }
583
+ });
584
+ }
585
+
586
+ // renders
587
+ const renderIds = new Set();
588
+ if (!Array.isArray(m.renders) || m.renders.length === 0) {
589
+ bad('renders', 'required non-empty array — an asset with no renders has nothing to ingest');
590
+ } else {
591
+ m.renders.forEach((r, i) => {
592
+ const p = `renders[${i}]`;
593
+ if (!r || typeof r !== 'object') { bad(p, 'must be an object'); return; }
594
+ if (!safeId(r.id)) bad(`${p}.id`, 'required; must match [A-Za-z0-9._-]+');
595
+ else if (renderIds.has(r.id)) bad(`${p}.id`, `duplicate render id "${r.id}"`);
596
+ else renderIds.add(r.id);
597
+ if (!isNonEmptyString(r.path)) bad(`${p}.path`, 'required');
598
+ if (!r.camera || typeof r.camera !== 'object' || typeof r.camera.yaw_deg !== 'number') {
599
+ bad(`${p}.camera`, 'required object with numeric yaw_deg (elevation_deg optional, default 0)');
600
+ } else if (r.camera.elevation_deg != null && typeof r.camera.elevation_deg !== 'number') {
601
+ bad(`${p}.camera.elevation_deg`, 'must be a number when present');
602
+ }
603
+ if (!isNonEmptyString(r.silhouette_mask)) {
604
+ bad(`${p}.silhouette_mask`, 'required — the palette gate measures inside the EXACT silhouette (E08-A2)');
605
+ }
606
+ if (r.channels != null) {
607
+ if (typeof r.channels !== 'object' || Array.isArray(r.channels)) {
608
+ bad(`${p}.channels`, 'must be an object mapping channel-type id → instance path');
609
+ } else {
610
+ for (const [cid, cpath] of Object.entries(r.channels)) {
611
+ const decl = channelById.get(cid);
612
+ if (!decl) bad(`${p}.channels.${cid}`, `references undeclared channel id "${cid}"`);
613
+ else if (decl.space !== 'render') bad(`${p}.channels.${cid}`, `channel "${cid}" is ${decl.space}-space; only render-space channels have per-render instances`);
614
+ if (!isNonEmptyString(cpath)) bad(`${p}.channels.${cid}`, 'instance path must be a non-empty string');
615
+ }
616
+ }
617
+ }
618
+ if (r.loss_mask != null && !isNonEmptyString(r.loss_mask)) bad(`${p}.loss_mask`, 'must be a non-empty path when present');
619
+ if (r.facing != null && !isNonEmptyString(r.facing)) bad(`${p}.facing`, 'must be a non-empty string when present');
620
+ // schema 1.2.0 — per-image generation provenance (Rulings 20a, 24b)
621
+ // schema 1.3.0 — refused outright on a declared non-generated render:
622
+ // a deterministic derivation of an accepted asset has no seed and no
623
+ // generation frame of its own. The generating provenance of the twins
624
+ // it was projected FROM is real, but it belongs to those twins, and
625
+ // recording it here would attribute a seed to an image that no seed
626
+ // produced.
627
+ if (r.generation != null && m.asset?.render_derivation?.generated === false) {
628
+ bad(`${p}.generation`,
629
+ `declares generation provenance, but asset.render_derivation.generated is false — these renders are "${m.asset.render_derivation.kind}" derivations of an already-accepted asset and have no seed of their own. If they ARE generated images, set generated: true.`);
630
+ } else if (r.generation != null) {
631
+ validateGeneration(r.generation, p, bad);
632
+ }
633
+ // schema 1.2.0 — a render may opt OUT of the asset-level tone transform.
634
+ // Boolean only: the asset declares the transform once, and a render says
635
+ // whether it carries it. Anything richer belongs in the asset block.
636
+ if (r.tone_transform != null && typeof r.tone_transform !== 'boolean') {
637
+ bad(`${p}.tone_transform`, 'must be a boolean when present — true/false says whether THIS render carries the asset-level tone transform; the transform itself is declared once at asset.tone_transform');
638
+ }
639
+ if (r.tone_transform != null && m.asset?.tone_transform == null) {
640
+ bad(`${p}.tone_transform`, 'declares a per-render tone_transform but the manifest has no asset.tone_transform to opt in or out of');
641
+ }
642
+ if (r.pair != null) {
643
+ if (typeof r.pair !== 'object' || Array.isArray(r.pair)) bad(`${p}.pair`, 'must be an object mapping pair name → path');
644
+ else {
645
+ for (const [name, ppath] of Object.entries(r.pair)) {
646
+ if (!safeId(name)) bad(`${p}.pair.${name}`, 'pair names must match [A-Za-z0-9._-]+');
647
+ if (!isNonEmptyString(ppath)) bad(`${p}.pair.${name}`, 'pair path must be a non-empty string');
648
+ }
649
+ }
650
+ }
651
+ });
652
+ }
653
+
654
+ // schema 1.2.0 — the tone transform, validated AFTER renders so its
655
+ // reference can be checked against the ids this manifest actually declares
656
+ if (m.asset?.tone_transform != null) {
657
+ validateToneTransform(m.asset.tone_transform, renderIds, bad);
658
+ }
659
+
660
+ // captions (material, not captions)
661
+ if (m.captions != null) {
662
+ if (typeof m.captions !== 'object' || Array.isArray(m.captions)) bad('captions', 'must be an object');
663
+ else {
664
+ for (const key of ['subject', 'style_trigger', 'domain_tag']) {
665
+ if (m.captions[key] != null && !isNonEmptyString(m.captions[key])) {
666
+ bad(`captions.${key}`, 'must be a non-empty string when present');
667
+ }
668
+ }
669
+ }
670
+ }
671
+
672
+ return out;
673
+ }
674
+
675
+ // ─── 2–4. Containment, existence, encoding + categorical proofs ──────
676
+
677
+ /**
678
+ * Resolve a manifest-relative path, refusing absolute paths and escapes.
679
+ * @returns {{abs: string}|{violation: Object}}
680
+ */
681
+ function containPath(baseDir, relPath, jsonPath) {
682
+ if (isAbsolute(relPath)) {
683
+ return { violation: v('ASSET_PATH_ESCAPE', jsonPath, `"${relPath}" is absolute — manifest paths must be relative to the manifest's directory`) };
684
+ }
685
+ const abs = resolve(baseDir, relPath);
686
+ const rel = relative(resolve(baseDir), abs);
687
+ if (rel.startsWith('..') || isAbsolute(rel)) {
688
+ return { violation: v('ASSET_PATH_ESCAPE', jsonPath, `"${relPath}" resolves outside the export directory`) };
689
+ }
690
+ return { abs };
691
+ }
692
+
693
+ async function provePng(absPath, relPath, jsonPath, expectations, violations) {
694
+ let buf;
695
+ try {
696
+ buf = await readFile(absPath);
697
+ } catch (err) {
698
+ violations.push(v('ASSET_FILE_MISSING', jsonPath, `cannot read "${relPath}": ${err.message}`));
699
+ return null;
700
+ }
701
+ let meta;
702
+ try {
703
+ meta = parsePngMeta(buf, relPath);
704
+ } catch (err) {
705
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}": ${err.message}`));
706
+ return null;
707
+ }
708
+ if (meta.interlaced) {
709
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}" is interlaced — the contract requires non-interlaced PNGs`));
710
+ return null;
711
+ }
712
+ if (expectations.colorTypes && !expectations.colorTypes.has(meta.colorType)) {
713
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
714
+ `"${relPath}" has PNG color type ${meta.colorType} (${meta.colorTypeName}); ${expectations.what} requires ${expectations.describe}`));
715
+ return null;
716
+ }
717
+ return { buf, meta };
718
+ }
719
+
720
+ /**
721
+ * Prove an npy file against its declaration.
722
+ *
723
+ * @param {number[]|null} expectShape — for render-space instances, the
724
+ * [height, width] the array must be parallel to (numpy row-major, so a
725
+ * [width, height] export is a transpose and is caught here). Null for
726
+ * texture-space channels, which declare their own shape.
727
+ */
728
+ async function proveNpy(absPath, relPath, jsonPath, decl, expectShape, violations) {
729
+ let buf;
730
+ try {
731
+ buf = await readFile(absPath);
732
+ } catch (err) {
733
+ violations.push(v('ASSET_FILE_MISSING', jsonPath, `cannot read "${relPath}": ${err.message}`));
734
+ return null;
735
+ }
736
+ let header;
737
+ try {
738
+ header = parseNpyHeader(buf, relPath);
739
+ } catch (err) {
740
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}": ${err.message}`));
741
+ return null;
742
+ }
743
+
744
+ let ok = true;
745
+ if (header.dtype !== decl.dtype) {
746
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
747
+ `"${relPath}" has dtype ${header.dtype}, manifest declares ${decl.dtype}`));
748
+ ok = false;
749
+ }
750
+ if (expectShape) {
751
+ const rankOk = header.shape.length === 2 || header.shape.length === 3;
752
+ if (!rankOk || header.shape[0] !== expectShape[0] || header.shape[1] !== expectShape[1]) {
753
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
754
+ `"${relPath}" has shape [${header.shape}]; a render-space array must open [height, width] = [${expectShape[0]}, ${expectShape[1]}] to be parallel to its render (numpy is row-major — a [width, height] export is transposed)`));
755
+ ok = false;
756
+ }
757
+ } else if (JSON.stringify(header.shape) !== JSON.stringify(decl.shape)) {
758
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
759
+ `"${relPath}" has shape [${header.shape}], manifest declares [${decl.shape}]`));
760
+ ok = false;
761
+ }
762
+ return ok ? { buf, meta: header } : null;
763
+ }
764
+
765
+ /**
766
+ * Prove a json sidecar. The proof is deliberately shallow and the contract
767
+ * says so out loud: the file parses as an object and carries the keys the
768
+ * manifest declared required. sdlab does NOT read the values — a number in
769
+ * an admission sidecar means whatever the asset says it means, and pretending
770
+ * to verify semantics the lane does not own is worse than declaring the
771
+ * boundary.
772
+ */
773
+ async function proveJson(absPath, relPath, jsonPath, decl, violations) {
774
+ let buf;
775
+ try {
776
+ buf = await readFile(absPath);
777
+ } catch (err) {
778
+ violations.push(v('ASSET_FILE_MISSING', jsonPath, `cannot read "${relPath}": ${err.message}`));
779
+ return null;
780
+ }
781
+ let parsed;
782
+ try {
783
+ parsed = JSON.parse(buf.toString('utf-8'));
784
+ } catch (err) {
785
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}" is not valid JSON: ${err.message}`));
786
+ return null;
787
+ }
788
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
789
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}" must be a JSON object`));
790
+ return null;
791
+ }
792
+ const missing = (decl.required_keys || []).filter((k) => parsed[k] === undefined);
793
+ if (missing.length > 0) {
794
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
795
+ `"${relPath}" is missing declared required key(s): ${missing.join(', ')}`));
796
+ return null;
797
+ }
798
+ return { buf, meta: { keys: Object.keys(parsed) } };
799
+ }
800
+
801
+ /**
802
+ * Prove every referenced file against its declaration. Reads bytes; decodes
803
+ * pixels only where a proof requires it.
804
+ *
805
+ * @param {Object} manifest — shape-valid manifest
806
+ * @param {string} baseDir — the manifest's directory (untrusted export dir)
807
+ * @returns {Promise<{violations: Array, resolved: Object}>}
808
+ * resolved: {
809
+ * baseDir, mesh, atlas,
810
+ * textureChannels: Map<id, {relPath, abs, meta?}>,
811
+ * renders: Map<id, {render, relPath, abs, meta,
812
+ * silhouette: {relPath, abs, meta},
813
+ * instances: Map<channelId, {relPath, abs, meta}>,
814
+ * lossMask: {relPath, abs, meta}|null,
815
+ * pairs: Map<name, {relPath, abs, meta}>}>
816
+ * }
817
+ */
818
+ export async function proveFiles(manifest, baseDir) {
819
+ const violations = [];
820
+ const resolved = {
821
+ baseDir,
822
+ mesh: null,
823
+ atlas: null,
824
+ toneOperands: null,
825
+ textureChannels: new Map(),
826
+ renders: new Map(),
827
+ };
828
+ const channelById = new Map((manifest.channels || []).map((c) => [c.id, c]));
829
+
830
+ const contain = (relPath, jsonPath) => {
831
+ const r = containPath(baseDir, relPath, jsonPath);
832
+ if (r.violation) { violations.push(r.violation); return null; }
833
+ if (!existsSync(r.abs)) {
834
+ violations.push(v('ASSET_FILE_MISSING', jsonPath, `"${relPath}" does not exist in the export directory`));
835
+ return null;
836
+ }
837
+ return r.abs;
838
+ };
839
+
840
+ // mesh / atlas: existence (+ atlas PNG parse when it is a .png, so its
841
+ // dimensions land in the receipt)
842
+ for (const key of ['mesh', 'atlas']) {
843
+ const ref = manifest.asset?.[key];
844
+ if (!ref) continue;
845
+ const abs = contain(ref.path, `asset.${key}.path`);
846
+ if (!abs) continue;
847
+ let meta = null;
848
+ if (key === 'atlas' && ref.path.toLowerCase().endsWith('.png')) {
849
+ try {
850
+ meta = parsePngMeta(await readFile(abs), ref.path);
851
+ } catch (err) {
852
+ violations.push(v('ASSET_ENCODING_MISMATCH', `asset.${key}.path`, `"${ref.path}": ${err.message}`));
853
+ }
854
+ }
855
+ resolved[key] = { relPath: ref.path, abs, meta };
856
+ }
857
+
858
+ // tone-transform operands (schema 1.2.0): contained, parsed as an object,
859
+ // then hashed and carried by the ingest. The proof stops at "it is a JSON
860
+ // object that exists inside the export" — the same declared-weak boundary
861
+ // as a `json` channel. sdlab reads no operand values and verifies no
862
+ // arithmetic; it makes the declaration auditable, not true.
863
+ const ttDecl = manifest.asset?.tone_transform;
864
+ if (ttDecl?.operands) {
865
+ const abs = contain(ttDecl.operands, 'asset.tone_transform.operands');
866
+ if (abs) {
867
+ const proved = await proveJson(abs, ttDecl.operands, 'asset.tone_transform.operands', {}, violations);
868
+ if (proved) resolved.toneOperands = { relPath: ttDecl.operands, abs, meta: proved.meta };
869
+ }
870
+ }
871
+
872
+ // texture-space channels
873
+ for (const c of manifest.channels || []) {
874
+ if (c.space !== 'texture') continue;
875
+ const jsonPath = `channels(${c.id}).path`;
876
+ const abs = contain(c.path, jsonPath);
877
+ if (!abs) continue;
878
+
879
+ if (c.encoding === 'npy') {
880
+ const proved = await proveNpy(abs, c.path, jsonPath, c, null, violations);
881
+ if (!proved) continue;
882
+ resolved.textureChannels.set(c.id, { relPath: c.path, abs, meta: proved.meta });
883
+ if (c.categorical) proveCategorical(c, proved, `channels(${c.id})`, violations);
884
+ continue;
885
+ }
886
+
887
+ if (c.encoding === 'json') {
888
+ const proved = await proveJson(abs, c.path, jsonPath, c, violations);
889
+ if (!proved) continue;
890
+ resolved.textureChannels.set(c.id, { relPath: c.path, abs, meta: proved.meta });
891
+ continue;
892
+ }
893
+
894
+ const expectType = COLOR_TYPE_BY_ENCODING[c.encoding];
895
+ const proved = await provePng(abs, c.path, jsonPath, {
896
+ colorTypes: new Set([expectType]),
897
+ what: `declared encoding "${c.encoding}"`,
898
+ describe: `color type ${expectType}`,
899
+ }, violations);
900
+ if (!proved) continue;
901
+ resolved.textureChannels.set(c.id, { relPath: c.path, abs, meta: proved.meta });
902
+
903
+ // Categorical proofs on texture channels
904
+ if (c.categorical) {
905
+ proveCategorical(c, proved, `channels(${c.id})`, violations);
906
+ }
907
+ }
908
+
909
+ // renders + silhouettes + instances + loss masks + pairs
910
+ for (const r of manifest.renders || []) {
911
+ const rEntry = { render: r, relPath: r.path, abs: null, meta: null, silhouette: null, instances: new Map(), lossMask: null, pairs: new Map() };
912
+
913
+ const rAbs = contain(r.path, `renders(${r.id}).path`);
914
+ if (rAbs) {
915
+ const proved = await provePng(rAbs, r.path, `renders(${r.id}).path`, {
916
+ colorTypes: RENDER_COLOR_TYPES,
917
+ what: 'a trainable render',
918
+ describe: 'rgb (2) or rgba (6)',
919
+ }, violations);
920
+ if (proved) {
921
+ if (proved.meta.bitDepth !== 8) {
922
+ violations.push(v('ASSET_ENCODING_MISMATCH', `renders(${r.id}).path`, `"${r.path}" is ${proved.meta.bitDepth}-bit; renders must be 8-bit`));
923
+ } else {
924
+ rEntry.abs = rAbs;
925
+ rEntry.meta = proved.meta;
926
+ }
927
+ }
928
+ }
929
+
930
+ const sAbs = r.silhouette_mask ? contain(r.silhouette_mask, `renders(${r.id}).silhouette_mask`) : null;
931
+ if (sAbs) {
932
+ const proved = await provePng(sAbs, r.silhouette_mask, `renders(${r.id}).silhouette_mask`, {
933
+ colorTypes: new Set([0]),
934
+ what: 'a silhouette mask',
935
+ describe: 'grayscale (0)',
936
+ }, violations);
937
+ if (proved) {
938
+ rEntry.silhouette = { relPath: r.silhouette_mask, abs: sAbs, meta: proved.meta };
939
+ if (rEntry.meta && (proved.meta.width !== rEntry.meta.width || proved.meta.height !== rEntry.meta.height)) {
940
+ violations.push(v('ASSET_ENCODING_MISMATCH', `renders(${r.id}).silhouette_mask`,
941
+ `silhouette is ${proved.meta.width}x${proved.meta.height} but the render is ${rEntry.meta.width}x${rEntry.meta.height} — masks are parallel to renders`));
942
+ }
943
+ }
944
+ }
945
+
946
+ for (const [cid, cpath] of Object.entries(r.channels || {})) {
947
+ const decl = channelById.get(cid);
948
+ if (!decl || decl.space !== 'render') continue; // shape stage already flagged
949
+ const jsonPath = `renders(${r.id}).channels.${cid}`;
950
+ const iAbs = contain(cpath, jsonPath);
951
+ if (!iAbs) continue;
952
+
953
+ // npy instances (schema 1.1.0): proven parallel to their own render.
954
+ // The render's dims are only known if the render itself proved, so an
955
+ // unproven render skips the parallelism check rather than inventing one.
956
+ if (decl.encoding === 'npy') {
957
+ const expectShape = rEntry.meta ? [rEntry.meta.height, rEntry.meta.width] : null;
958
+ const proved = await proveNpy(iAbs, cpath, jsonPath, decl, expectShape, violations);
959
+ if (!proved) continue;
960
+ rEntry.instances.set(cid, { relPath: cpath, abs: iAbs, meta: proved.meta });
961
+ if (decl.categorical) proveCategorical(decl, proved, jsonPath, violations);
962
+ continue;
963
+ }
964
+
965
+ // json sidecars (schema 1.1.0): parse + declared keys, nothing deeper.
966
+ if (decl.encoding === 'json') {
967
+ const proved = await proveJson(iAbs, cpath, jsonPath, decl, violations);
968
+ if (!proved) continue;
969
+ rEntry.instances.set(cid, { relPath: cpath, abs: iAbs, meta: proved.meta });
970
+ continue;
971
+ }
972
+
973
+ const expectType = COLOR_TYPE_BY_ENCODING[decl.encoding];
974
+ const proved = await provePng(iAbs, cpath, jsonPath, {
975
+ colorTypes: new Set([expectType]),
976
+ what: `channel "${cid}" (declared "${decl.encoding}")`,
977
+ describe: `color type ${expectType}`,
978
+ }, violations);
979
+ if (!proved) continue;
980
+ if (rEntry.meta && (proved.meta.width !== rEntry.meta.width || proved.meta.height !== rEntry.meta.height)) {
981
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
982
+ `instance is ${proved.meta.width}x${proved.meta.height} but the render is ${rEntry.meta.width}x${rEntry.meta.height}`));
983
+ continue;
984
+ }
985
+ rEntry.instances.set(cid, { relPath: cpath, abs: iAbs, meta: proved.meta });
986
+ if (decl.categorical) {
987
+ proveCategorical(decl, proved, jsonPath, violations);
988
+ }
989
+ }
990
+
991
+ if (r.loss_mask) {
992
+ const jsonPath = `renders(${r.id}).loss_mask`;
993
+ const lAbs = contain(r.loss_mask, jsonPath);
994
+ if (lAbs) {
995
+ const proved = await provePng(lAbs, r.loss_mask, jsonPath, {
996
+ colorTypes: new Set([0]),
997
+ what: 'a loss mask (soft weights)',
998
+ describe: 'grayscale (0)',
999
+ }, violations);
1000
+ if (proved) rEntry.lossMask = { relPath: r.loss_mask, abs: lAbs, meta: proved.meta };
1001
+ }
1002
+ }
1003
+
1004
+ for (const [name, ppath] of Object.entries(r.pair || {})) {
1005
+ const jsonPath = `renders(${r.id}).pair.${name}`;
1006
+ const pAbs = contain(ppath, jsonPath);
1007
+ if (!pAbs) continue;
1008
+ const proved = await provePng(pAbs, ppath, jsonPath, {
1009
+ colorTypes: new Set([0, 2, 6]),
1010
+ what: 'a conditioning pair image',
1011
+ describe: 'grayscale, rgb, or rgba',
1012
+ }, violations);
1013
+ if (proved) rEntry.pairs.set(name, { relPath: ppath, abs: pAbs, meta: proved.meta });
1014
+ }
1015
+
1016
+ resolved.renders.set(r.id, rEntry);
1017
+ }
1018
+
1019
+ return { violations, resolved };
1020
+ }
1021
+
1022
+ /**
1023
+ * Categorical channel proofs.
1024
+ * - npy: exhaustive value scan — every element must be a declared class
1025
+ * value. The array analogue of the "nearest" pixel proof: a class map has
1026
+ * no antialiasing to forgive, so an undeclared value is a real violation.
1027
+ * - indexed: PLTE ⊆ declared palette — structural, no pixel decode
1028
+ * (the brand/E09 contract: the PLTE *is* the palette; padded or
1029
+ * undeclared entries are violations).
1030
+ * - non-indexed + filter "nearest": exhaustive decode — every
1031
+ * non-transparent pixel's color must be a declared class color.
1032
+ * - non-indexed + filter "linear": no proof (classification is an ingest
1033
+ * measurement with an unclassified share).
1034
+ */
1035
+ function proveCategorical(decl, proved, jsonPath, violations) {
1036
+ if (decl.encoding === 'npy') {
1037
+ const declaredValues = new Set(decl.classes.map((c) => c.value));
1038
+ let values;
1039
+ try {
1040
+ values = readNpyValues(proved.buf, proved.meta, jsonPath);
1041
+ } catch (err) {
1042
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, err.message));
1043
+ return;
1044
+ }
1045
+ const shown = [...declaredValues].sort((a, b) => a - b).join(', ');
1046
+ for (let i = 0; i < values.length; i++) {
1047
+ if (!declaredValues.has(values[i])) {
1048
+ violations.push(v('ASSET_PALETTE_PROOF_FAILED', jsonPath,
1049
+ `value ${values[i]} at flat index ${i} is not a declared class value (declared: ${shown}) — a categorical npy channel is proven exhaustively, not sampled`));
1050
+ return; // one offending value names the failure; no need to flood
1051
+ }
1052
+ }
1053
+ return;
1054
+ }
1055
+
1056
+ const declared = new Set(decl.palette.map((c) => c.rgb.join(',')));
1057
+
1058
+ if (decl.encoding === 'indexed') {
1059
+ const plte = proved.meta.palette || [];
1060
+ for (const entry of plte) {
1061
+ if (!declared.has(entry.join(','))) {
1062
+ violations.push(v('ASSET_PALETTE_PROOF_FAILED', jsonPath,
1063
+ `PLTE entry rgb(${entry.join(', ')}) is not in the declared class palette — E09 discipline: PLTE ⊆ declared palette, no padding`));
1064
+ }
1065
+ }
1066
+ return;
1067
+ }
1068
+
1069
+ if (decl.filter === 'nearest') {
1070
+ let img;
1071
+ try {
1072
+ img = decodePng(proved.buf, jsonPath);
1073
+ } catch (err) {
1074
+ violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, err.message));
1075
+ return;
1076
+ }
1077
+ const n = img.width * img.height;
1078
+ for (let i = 0; i < n; i++) {
1079
+ if (alphaAt(img, i) === 0) continue;
1080
+ const rgb = rgbAt(img, i);
1081
+ if (!declared.has(rgb.join(','))) {
1082
+ violations.push(v('ASSET_PALETTE_PROOF_FAILED', jsonPath,
1083
+ `pixel ${i % img.width},${Math.floor(i / img.width)} is rgb(${rgb.join(', ')}) — not a declared class color; a "nearest"-filtered categorical channel must be exact (this is an exhaustive proof, not a sample)`));
1084
+ return; // one offending color names the failure; no need to flood
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ // ─── Orchestration ───────────────────────────────────────────────────
1091
+
1092
+ function throwViolations(manifestPath, violations) {
1093
+ const order = new Map(CLASS_ORDER.map((c, i) => [c, i]));
1094
+ const sorted = [...violations].sort((a, b) => (order.get(a.cls) ?? 99) - (order.get(b.cls) ?? 99));
1095
+ const code = sorted[0].cls;
1096
+ const MAX_LINES = 20;
1097
+ const lines = sorted.slice(0, MAX_LINES).map((x) => ` - [${x.cls}] ${x.path}: ${x.message}`);
1098
+ if (sorted.length > MAX_LINES) lines.push(` … and ${sorted.length - MAX_LINES} more`);
1099
+ throw inputError(
1100
+ code,
1101
+ `${manifestPath}: ${sorted.length} contract violation(s) — nothing was registered.`,
1102
+ `Fix the manifest/export and re-run.\n${lines.join('\n')}`
1103
+ );
1104
+ }
1105
+
1106
+ /**
1107
+ * Load + fully validate an asset-source manifest. Throws one structured
1108
+ * error listing every violation; returns the manifest and resolved file map
1109
+ * on success.
1110
+ *
1111
+ * @param {string} manifestPath — absolute path to asset-source.json
1112
+ * @returns {Promise<{manifest: Object, resolved: Object, manifestRaw: Buffer}>}
1113
+ */
1114
+ export async function validateAssetSource(manifestPath) {
1115
+ if (!existsSync(manifestPath)) {
1116
+ throw inputError(
1117
+ 'ASSET_MANIFEST_NOT_FOUND',
1118
+ `No ${ASSET_MANIFEST_FILENAME} at ${manifestPath}.`,
1119
+ 'The export directory must contain the asset\'s manifest. See docs/asset-lane-design.md for the contract.'
1120
+ );
1121
+ }
1122
+ const manifestRaw = await readFile(manifestPath);
1123
+ let manifest;
1124
+ try {
1125
+ manifest = JSON.parse(manifestRaw.toString('utf-8'));
1126
+ } catch (err) {
1127
+ throw inputError('ASSET_MANIFEST_INVALID', `${manifestPath} is not valid JSON: ${err.message}`,
1128
+ 'Fix the JSON syntax and re-run.');
1129
+ }
1130
+
1131
+ const shapeViolations = validateManifestShape(manifest);
1132
+ if (shapeViolations.length > 0) {
1133
+ // Refuse before touching any referenced file: a manifest whose SHAPE is
1134
+ // wrong cannot be trusted to name files safely.
1135
+ throwViolations(manifestPath, shapeViolations);
1136
+ }
1137
+
1138
+ const { violations, resolved } = await proveFiles(manifest, dirname(manifestPath));
1139
+ if (violations.length > 0) throwViolations(manifestPath, violations);
1140
+
1141
+ return { manifest, resolved, manifestRaw };
1142
+ }
1143
+
1144
+ // ─── Derived facing vocabulary ───────────────────────────────────────
1145
+
1146
+ const FACING_TOKENS = [
1147
+ 'front', // 0°
1148
+ 'three-quarter front-right', // 45°
1149
+ 'right profile', // 90°
1150
+ 'three-quarter back-right', // 135°
1151
+ 'back', // 180°
1152
+ 'three-quarter back-left', // 225°
1153
+ 'left profile', // 270°
1154
+ 'three-quarter front-left', // 315°
1155
+ ];
1156
+
1157
+ /**
1158
+ * Deterministic facing token from camera yaw/elevation. Yaw 0 = front;
1159
+ * buckets are 45° wide, centered on the eight canonical directions;
1160
+ * |elevation| >= 30° appends an elevation clause. Camera-relative naming —
1161
+ * "right" means the camera orbited toward the subject's right side of frame.
1162
+ *
1163
+ * The manifest may override per-render (`renders[].facing`) when the asset
1164
+ * disagrees; this is the mechanical default. Per facet E01 (and Cheng et
1165
+ * al. 2024, Continuous 3D Words): captions must carry facing — a
1166
+ * front-facing phrase on a rear view makes the text fight the image.
1167
+ */
1168
+ export function facingFromCamera(camera) {
1169
+ const yaw = ((Number(camera.yaw_deg) % 360) + 360) % 360;
1170
+ const elevation = Number(camera.elevation_deg ?? 0);
1171
+ const bucket = Math.round(yaw / 45) % 8;
1172
+ let token = FACING_TOKENS[bucket];
1173
+ if (elevation >= 30) token += ', viewed from above';
1174
+ else if (elevation <= -30) token += ', viewed from below';
1175
+ return token;
1176
+ }