@githolon/dsl 0.1.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 (53) hide show
  1. package/LICENSE.md +36 -0
  2. package/compile_package.mjs +50 -0
  3. package/package.json +59 -0
  4. package/src/aggregate.ts +167 -0
  5. package/src/authoring.ts +119 -0
  6. package/src/build_package.ts +636 -0
  7. package/src/certified_read.ts +313 -0
  8. package/src/codegen_dart.ts +2732 -0
  9. package/src/codegen_dot.ts +466 -0
  10. package/src/codegen_provider_dart.ts +358 -0
  11. package/src/codegen_ts.ts +365 -0
  12. package/src/codegen_usda.ts +388 -0
  13. package/src/combined.ts +195 -0
  14. package/src/compile_engine.ts +567 -0
  15. package/src/compile_package_main.ts +496 -0
  16. package/src/compose.ts +317 -0
  17. package/src/count.ts +218 -0
  18. package/src/ctx.ts +57 -0
  19. package/src/derived.ts +138 -0
  20. package/src/directive.ts +306 -0
  21. package/src/drivers.ts +95 -0
  22. package/src/emits_guard.ts +123 -0
  23. package/src/engine_entry.ts +449 -0
  24. package/src/exists.ts +170 -0
  25. package/src/extremum.ts +227 -0
  26. package/src/fields.ts +291 -0
  27. package/src/framework/bootstrap.ts +22 -0
  28. package/src/framework/disclosure.ts +108 -0
  29. package/src/framework/domain_lifecycle.ts +108 -0
  30. package/src/framework/identity.ts +537 -0
  31. package/src/framework/impure_capability.ts +643 -0
  32. package/src/framework/rbac.ts +418 -0
  33. package/src/framework/repair.ts +150 -0
  34. package/src/framework/sync_lifecycle.ts +125 -0
  35. package/src/framework/workspace_invariant.ts +128 -0
  36. package/src/framework/workspaces.ts +817 -0
  37. package/src/index.ts +317 -0
  38. package/src/manifest.ts +947 -0
  39. package/src/ops.ts +145 -0
  40. package/src/ordered_read.ts +228 -0
  41. package/src/predicate.ts +203 -0
  42. package/src/query/compile.ts +0 -0
  43. package/src/query/relations.ts +144 -0
  44. package/src/query.ts +151 -0
  45. package/src/read.ts +54 -0
  46. package/src/relation.ts +189 -0
  47. package/src/report/csv.ts +54 -0
  48. package/src/report.ts +401 -0
  49. package/src/spatial.ts +115 -0
  50. package/src/sum.ts +194 -0
  51. package/src/usd.ts +563 -0
  52. package/src/wire.ts +149 -0
  53. package/src/wire_encode.ts +250 -0
package/src/usd.ts ADDED
@@ -0,0 +1,563 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * TS → USD — the UNIFIED composed-IR document (stage 1, increment 1).
10
+ *
11
+ * Two proven-but-isolated pieces become ONE here:
12
+ * - `manifest.ts` (#136): encodes a `DomainModule` to a canonical flat manifest —
13
+ * aggregates with field→Driver schemas + directive contracts (target / marker /
14
+ * requiresCapability / payload-shape), hashed via `canonicalJson` / `domainHash`.
15
+ * - `compose.ts` (#137): the typed, MONOTONIC layer composition — `requires`/`scope`
16
+ * rules, `covers()`, fail-closed `CompositionViolation`, composed hash.
17
+ *
18
+ * The bridge is an OpenUSD-shaped IR document: a real (synthetic) `DomainModule`
19
+ * encodes into composable typed prims arranged under a layer PATH; multiple layers
20
+ * COMPOSE via the very same typed rules (`compose.ts`'s `mergeRequiresScope`, reused
21
+ * — never re-implemented); the composed stage FLATTENS and HASHES. This is the
22
+ * foundation of the certified-IR document the gate will later seal.
23
+ *
24
+ * SYNTHETIC framework units only — paths like `/Nomos/Prelude` and `/Sample/...`;
25
+ * no tenant/business domain is named or imported. Build-time only (imports
26
+ * `node:crypto` transitively via `manifest.js`); reachable via the `@githolon/dsl/usd`
27
+ * subpath, NOT the runtime `index.ts` barrel.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import {
31
+ canonicalJson,
32
+ domainManifest,
33
+ type CanonicalProjectionReturn,
34
+ } from "./manifest.js";
35
+ import {
36
+ mergeRequiresScope,
37
+ CompositionViolation,
38
+ type ComposeOptions,
39
+ } from "./compose.js";
40
+ import type { WireSchema, WireRefMode } from "./wire.js";
41
+ import type { DomainModule } from "./codegen_dart.js";
42
+
43
+ /** The referential marker of a directive prim (kernel `RefMode` wire string). */
44
+ export type RefMode = WireRefMode;
45
+
46
+ /**
47
+ * One prim in the USD IR — a discriminated union UNIFYING #136's manifest content
48
+ * with #137's composable typed-rule fields.
49
+ *
50
+ * - `aggregate`: an encoded aggregate, carrying its field→`WireDriver` schema (the
51
+ * EXACT `manifest.ts` `encodeKernelSchema` output). Aggregates do not compose-merge
52
+ * (an aggregate is a single-source schema); they pass through by path.
53
+ * - `directive`: a composable law unit. `requires` is the capability set (#137:
54
+ * add/narrow, never silently remove); `scope` is the optional composable scope
55
+ * path (#137: narrow-not-widen). `payloadRef` is the certified-artifact handle
56
+ * — left `undefined` here (stage 2).
57
+ */
58
+ export type UsdPrim =
59
+ | {
60
+ readonly kind: "aggregate";
61
+ /** e.g. `/Sample/SampleThingAggregate`. */
62
+ readonly path: string;
63
+ /** The aggregate type id, e.g. `SampleThingAggregate`. */
64
+ readonly type: string;
65
+ /** The encoded field→Driver schema (reused from `manifest.ts`). */
66
+ readonly schema: WireSchema;
67
+ }
68
+ | {
69
+ readonly kind: "directive";
70
+ /** e.g. `/Sample/createThing`. */
71
+ readonly path: string;
72
+ /** The target aggregate type id. */
73
+ readonly target: string;
74
+ readonly marker: RefMode;
75
+ /** Capability ids that MUST hold (a stronger layer may only ADD). */
76
+ readonly requires: string[];
77
+ /**
78
+ * The composable scope path (#137 narrow-not-widen). `undefined` when the DSL
79
+ * declares NO static scope today — we do NOT fabricate one.
80
+ */
81
+ readonly scope?: string;
82
+ /**
83
+ * The directive's DECLARED read boundary (sorted ref types) — the "IR for law
84
+ * boundaries" carried into the USD IR so `usdHash` reflects it. OMITTED
85
+ * (`undefined`) when the directive declares no reads, so boundary-free prims are
86
+ * byte-identical to before this field existed.
87
+ */
88
+ readonly reads?: string[];
89
+ /**
90
+ * The directive's DECLARED emit boundary: event type → `{ max? }`. OMITTED
91
+ * (`undefined`) when no emits are declared (omit-when-empty hash invariance).
92
+ */
93
+ readonly emits?: Record<string, { max?: number }>;
94
+ /**
95
+ * Standard JSON Schema for the directive payload, emitted from TS/Zod into the
96
+ * IR so cross-language tooling validates against the same schema source.
97
+ */
98
+ readonly payloadJsonSchema?: unknown;
99
+ /** Certified-artifact payload reference — `undefined` for now (stage 2). */
100
+ readonly payloadRef?: string;
101
+ };
102
+
103
+ /** One captured query declaration on a USD layer (read-side closure). `key` is the
104
+ * index column order, PRESERVED (not sorted). Mirrors `manifest.ts`'s `CanonicalQuery`. */
105
+ export interface UsdQuery {
106
+ readonly id: string;
107
+ readonly key: string[];
108
+ readonly returns: string;
109
+ }
110
+
111
+ /** One captured derived read projection on a USD layer. */
112
+ export interface UsdDerived {
113
+ readonly id: string;
114
+ readonly of: string;
115
+ readonly returns: CanonicalProjectionReturn;
116
+ }
117
+
118
+ /** One captured combined read projection on a USD layer. */
119
+ export interface UsdCombined {
120
+ readonly id: string;
121
+ readonly of: string;
122
+ readonly refField: string;
123
+ readonly reads: string;
124
+ readonly returns: CanonicalProjectionReturn;
125
+ }
126
+
127
+ /** One USD layer: a stage path + its prims (canonically sorted by prim path). */
128
+ export interface UsdLayer {
129
+ readonly path: string;
130
+ readonly prims: UsdPrim[];
131
+ /**
132
+ * The layer's NAMED, INDEXED read declarations (read-side closure step 1), SORTED
133
+ * by id. OMITTED (`undefined`) when the module declares no query, so a query-free
134
+ * layer is byte-identical to before this field existed (omit-when-empty hash
135
+ * invariance). Carried straight from the module's canonical manifest.
136
+ */
137
+ readonly queries?: UsdQuery[];
138
+ /** The layer's engine-projected derived read fields, sorted by id. */
139
+ readonly deriveds?: UsdDerived[];
140
+ /** The layer's engine-projected combined read fields, sorted by id. */
141
+ readonly combineds?: UsdCombined[];
142
+ /**
143
+ * The REFERENCE composition arc (LIVRPS: References sit just below Local). Paths of
144
+ * OTHER layers in the SAME document whose effective prims compose into THIS layer
145
+ * UNDER (weaker than) its own local prims — local is the stronger opinion. A
146
+ * first-class declared arc recorded in the IR so the composed identity (`usdHash`)
147
+ * reflects WHY a prim is present. OMITTED (`undefined`) when a layer references
148
+ * nothing, so a reference-free layer is byte-identical to before this field existed
149
+ * (omit-when-empty hash invariance — same discipline as `queries`/`reads`/`emits`).
150
+ */
151
+ readonly references?: string[];
152
+ /**
153
+ * The VARIANT composition arc (LIVRPS: Variants sit ABOVE References, BELOW Local —
154
+ * stronger than referenced prims, weaker than this layer's own local prims). A layer
155
+ * may declare named variant SETS; each set is a family of named ALTERNATIVES, each
156
+ * alternative ("variant") holding its OWN prims. WHICH variant of each set composes is
157
+ * NOT baked into the document — it is supplied as `ComposeOptions.variantSelection`
158
+ * (setName → chosen variantName) at flatten time, so the composed identity is a
159
+ * function of `(doc, selection)`.
160
+ *
161
+ * Shape: setName → variantName → that variant's prims. A reserved variant name `"*"`
162
+ * within a set declares that set's DEFAULT variant: when the selection names no choice
163
+ * for the set, the `"*"` variant's prims compose; with neither a selection nor a `"*"`
164
+ * default, the set contributes NOTHING. A selection naming an unknown set or an unknown
165
+ * variant fails closed (`variant-unresolved`) — an explicit selection that cannot be
166
+ * resolved is never silently a no-op.
167
+ *
168
+ * OMITTED (`undefined`) when a layer declares no variant set, so a variant-free layer
169
+ * is byte-identical to before this field existed (omit-when-empty hash invariance —
170
+ * same discipline as `queries`/`references`/`reads`/`emits`).
171
+ */
172
+ readonly variantSets?: Record<string, Record<string, UsdPrim[]>>;
173
+ }
174
+
175
+ /** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
176
+ const DEFAULT_VARIANT = "*";
177
+
178
+ /** The unified USD IR document: ordered (strength) layers. */
179
+ export interface UsdDocument {
180
+ readonly layers: UsdLayer[];
181
+ }
182
+
183
+ /** Sort prims by their `path` (canonical structure). */
184
+ function sortPrims(prims: UsdPrim[]): UsdPrim[] {
185
+ return [...prims].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
186
+ }
187
+
188
+ /**
189
+ * Encode ONE `DomainModule` into the `UsdPrim`s sitting under `layerPath`. Reuses
190
+ * `manifest.ts`'s `domainManifest` (and therefore its `encodeKernelSchema` driver encoding)
191
+ * — no driver-encoding logic is duplicated here. Aggregate prims carry the exact
192
+ * encoded schema; directive prims map `requiresCapability` → `requires: [cap]`, leave
193
+ * `scope`/`payloadRef` `undefined` (no static scope today; payload is stage 2).
194
+ */
195
+ function encodeModuleToPrims(layerPath: string, mod: DomainModule): UsdPrim[] {
196
+ const manifest = domainManifest(mod);
197
+ const prims: UsdPrim[] = [];
198
+
199
+ for (const agg of manifest.aggregates) {
200
+ prims.push({
201
+ kind: "aggregate",
202
+ path: `${layerPath}/${agg.id}`,
203
+ type: agg.id,
204
+ schema: agg.schema,
205
+ });
206
+ }
207
+
208
+ for (const d of manifest.directives) {
209
+ prims.push({
210
+ kind: "directive",
211
+ path: `${layerPath}/${d.id}`,
212
+ target: d.target,
213
+ marker: d.marker,
214
+ requires: [d.requiresCapability],
215
+ // Carry the declared law boundary straight from the canonical manifest, which
216
+ // already applies omit-when-empty: `reads`/`emits` are present here ONLY when the
217
+ // directive declared them, so a boundary-free prim is byte-identical to before.
218
+ ...(d.reads !== undefined ? { reads: d.reads } : {}),
219
+ ...(d.emits !== undefined ? { emits: d.emits } : {}),
220
+ payloadJsonSchema: d.payloadJsonSchema,
221
+ // `scope`/`payloadRef` deliberately omitted (undefined): no static scope in
222
+ // the DSL today, payload is the stage-2 certified artifact.
223
+ });
224
+ }
225
+
226
+ return sortPrims(prims);
227
+ }
228
+
229
+ /**
230
+ * Encode each `{ path, module }` into a `UsdLayer` of prims under that path, returning
231
+ * the canonical `UsdDocument`: layers SORTED by path, prims SORTED by path.
232
+ */
233
+ export function emitUsd(
234
+ layers: { path: string; module: DomainModule }[],
235
+ ): UsdDocument {
236
+ const out: UsdLayer[] = layers
237
+ .map((l) => {
238
+ // Carry the domain's declared queries straight from the canonical manifest,
239
+ // which already applies omit-when-empty: `queries` is present here ONLY when the
240
+ // module declared them, so a query-free layer is byte-identical to before.
241
+ const manifest = domainManifest(l.module);
242
+ return {
243
+ path: l.path,
244
+ prims: encodeModuleToPrims(l.path, l.module),
245
+ ...(manifest.queries !== undefined ? { queries: manifest.queries } : {}),
246
+ ...(manifest.deriveds !== undefined ? { deriveds: manifest.deriveds } : {}),
247
+ ...(manifest.combineds !== undefined ? { combineds: manifest.combineds } : {}),
248
+ };
249
+ })
250
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
251
+ return { layers: out };
252
+ }
253
+
254
+ /** Two aggregate prims at the same path are identical iff same type + same schema. */
255
+ function aggregateEqual(
256
+ a: Extract<UsdPrim, { kind: "aggregate" }>,
257
+ b: Extract<UsdPrim, { kind: "aggregate" }>,
258
+ ): boolean {
259
+ return a.type === b.type && canonicalJson(a.schema) === canonicalJson(b.schema);
260
+ }
261
+
262
+ /**
263
+ * Merge a stronger `over` prim onto a `base` prim at the SAME path, per the #137
264
+ * typed rules — reusing `compose.ts`'s `mergeRequiresScope` for directive prims
265
+ * (requires add/narrow-not-remove; scope narrow-not-widen; widen/remove need
266
+ * `authority`). An uncertified payload would always be refused — but `payloadRef`
267
+ * is unset here (stage 2), so there is nothing to refuse yet.
268
+ *
269
+ * Mixing prim KINDS at one path, or redefining an aggregate's schema across layers,
270
+ * is not a typed monotonic move — it is a structural conflict, surfaced fail-closed.
271
+ */
272
+ function mergeUsdPrim(base: UsdPrim, over: UsdPrim, opts: ComposeOptions): UsdPrim {
273
+ if (base.kind !== over.kind) {
274
+ throw new Error(
275
+ `USD composition conflict at ${over.path}: cannot merge a ${base.kind} prim ` +
276
+ `with a ${over.kind} prim.`,
277
+ );
278
+ }
279
+
280
+ if (base.kind === "aggregate" && over.kind === "aggregate") {
281
+ // Aggregates are single-source schemas: an identical re-declaration is a no-op
282
+ // pass-through; a divergent one is a structural conflict (not a monotonic move).
283
+ if (!aggregateEqual(base, over)) {
284
+ throw new Error(
285
+ `USD composition conflict at ${over.path}: aggregate schema redefined ` +
286
+ `across layers.`,
287
+ );
288
+ }
289
+ return over;
290
+ }
291
+
292
+ // Both directive prims — apply the shared typed requires+scope rule core (#137).
293
+ if (base.kind === "directive" && over.kind === "directive") {
294
+ if (base.target !== over.target) {
295
+ throw new Error(
296
+ `USD composition conflict at ${over.path}: directive target changed from ` +
297
+ `"${base.target}" to "${over.target}".`,
298
+ );
299
+ }
300
+ if (base.marker !== over.marker) {
301
+ throw new Error(
302
+ `USD composition conflict at ${over.path}: directive marker changed from ` +
303
+ `"${base.marker}" to "${over.marker}".`,
304
+ );
305
+ }
306
+ if (
307
+ base.payloadJsonSchema !== undefined &&
308
+ over.payloadJsonSchema !== undefined &&
309
+ canonicalJson(base.payloadJsonSchema) !== canonicalJson(over.payloadJsonSchema)
310
+ ) {
311
+ throw new Error(
312
+ `USD composition conflict at ${over.path}: directive payload schema changed.`,
313
+ );
314
+ }
315
+ const merged = mergeRequiresScope(over.path, base, over, opts);
316
+ const out: Extract<UsdPrim, { kind: "directive" }> = {
317
+ kind: "directive",
318
+ path: over.path,
319
+ target: over.target,
320
+ marker: over.marker,
321
+ requires: merged.requires,
322
+ ...(over.payloadJsonSchema !== undefined
323
+ ? { payloadJsonSchema: over.payloadJsonSchema }
324
+ : base.payloadJsonSchema !== undefined
325
+ ? { payloadJsonSchema: base.payloadJsonSchema }
326
+ : {}),
327
+ ...(merged.scope !== undefined ? { scope: merged.scope } : {}),
328
+ // Preserve the declared law boundary across a monotonic merge (omit-when-empty):
329
+ // the stronger layer's declaration wins when present, else the base's carries
330
+ // through; a boundary-free pair stays byte-identical to before this field existed.
331
+ ...(over.reads !== undefined
332
+ ? { reads: over.reads }
333
+ : base.reads !== undefined
334
+ ? { reads: base.reads }
335
+ : {}),
336
+ ...(over.emits !== undefined
337
+ ? { emits: over.emits }
338
+ : base.emits !== undefined
339
+ ? { emits: base.emits }
340
+ : {}),
341
+ // payloadRef stays unset (stage 2).
342
+ };
343
+ return out;
344
+ }
345
+
346
+ // Unreachable (kinds proven equal above) — exhaustiveness guard.
347
+ throw new Error(`USD composition: unhandled prim kind at ${over.path}.`);
348
+ }
349
+
350
+ /** Canonicalize a standalone prim — sort a directive's `requires`. */
351
+ function canonicalizeStandalone(prim: UsdPrim): UsdPrim {
352
+ if (prim.kind === "directive") {
353
+ return { ...prim, requires: [...prim.requires].sort() };
354
+ }
355
+ return prim;
356
+ }
357
+
358
+ /**
359
+ * Fold a stream of prims (already in weak→strong order) into an effective prim map
360
+ * keyed by path: a path's first contributor passes through (canonicalized); each
361
+ * later (stronger) contributor MERGES per the #137 typed rules (fail-closed
362
+ * `CompositionViolation` on any rule it cannot satisfy).
363
+ */
364
+ function foldPrims(
365
+ acc: Map<string, UsdPrim>,
366
+ prims: Iterable<UsdPrim>,
367
+ opts: ComposeOptions,
368
+ ): void {
369
+ for (const prim of prims) {
370
+ const existing = acc.get(prim.path);
371
+ if (existing === undefined) {
372
+ acc.set(prim.path, canonicalizeStandalone(prim));
373
+ } else {
374
+ acc.set(prim.path, mergeUsdPrim(existing, prim, opts));
375
+ }
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Resolve ONE layer's EFFECTIVE prims (the REFERENCE arc, LIVRPS: References below
381
+ * Local). Each referenced layer's effective prims are composed FIRST (weaker base),
382
+ * in declared `references[]` order, then THIS layer's own LOCAL prims fold OVER them
383
+ * (stronger) via the same typed `mergeUsdPrim`. Resolution is deterministic and
384
+ * DETECTS CYCLES (A→B→A) via the `stack` of layer paths currently being resolved,
385
+ * failing closed with a `CompositionViolation`. `byPath` indexes the document's
386
+ * layers; a reference to an unknown layer path also fails closed.
387
+ *
388
+ * VARIANTS (LIVRPS: Variants sit ABOVE References, BELOW Local) resolve AFTER the
389
+ * reference fold and BEFORE the local fold: the SELECTED variant of each declared set
390
+ * (or its `"*"` default, else nothing) folds OVER the referenced base (variants are
391
+ * stronger than references) and UNDER the local prims (local is the strongest), all
392
+ * through the same monotonic `mergeUsdPrim`. The selection is `opts.variantSelection`,
393
+ * an explicit flatten input — so the composed identity reflects it. A selection naming
394
+ * an unknown set or variant fails closed (`variant-unresolved`).
395
+ */
396
+ function resolveLayerEffective(
397
+ layer: UsdLayer,
398
+ byPath: Map<string, UsdLayer>,
399
+ opts: ComposeOptions,
400
+ stack: string[],
401
+ ): UsdPrim[] {
402
+ if (stack.includes(layer.path)) {
403
+ throw new CompositionViolation(
404
+ "reference-cycle",
405
+ layer.path,
406
+ `reference cycle detected: ${[...stack, layer.path].join(" -> ")}`,
407
+ );
408
+ }
409
+
410
+ const acc = new Map<string, UsdPrim>();
411
+
412
+ // References first (weaker), in declared order — each resolved recursively so
413
+ // transitive references compose and cycles are caught at any depth.
414
+ if (layer.references !== undefined) {
415
+ const nextStack = [...stack, layer.path];
416
+ for (const refPath of layer.references) {
417
+ const refLayer = byPath.get(refPath);
418
+ if (refLayer === undefined) {
419
+ throw new CompositionViolation(
420
+ "reference-unresolved",
421
+ layer.path,
422
+ `references unknown layer path "${refPath}"`,
423
+ );
424
+ }
425
+ foldPrims(acc, resolveLayerEffective(refLayer, byPath, opts, nextStack), opts);
426
+ }
427
+ }
428
+
429
+ // Variants fold OVER references, UNDER local (LIVRPS variant strength). The selected
430
+ // variant of each declared set — or its `"*"` default — contributes its prims; an
431
+ // unselected, default-less set contributes nothing. A selection for an unknown set or
432
+ // an unknown variant fails closed.
433
+ foldPrims(acc, selectedVariantPrims(layer, opts), opts);
434
+
435
+ // Local prims fold OVER the referenced + variant base (strongest opinion wins).
436
+ foldPrims(acc, layer.prims, opts);
437
+
438
+ return [...acc.keys()].sort().map((path) => acc.get(path)!);
439
+ }
440
+
441
+ /**
442
+ * The prims contributed by THIS layer's variant sets under the active selection
443
+ * (`opts.variantSelection`). For each declared set, in canonical (sorted) set-name
444
+ * order: if the selection names a variant, that variant's prims are used (unknown set
445
+ * or unknown variant → `variant-unresolved`, fail-closed); otherwise the set's `"*"`
446
+ * DEFAULT variant is used when declared, else the set contributes nothing. The
447
+ * selection is also validated against the layer's declared sets so a selection naming
448
+ * a set this layer does not declare fails closed too.
449
+ *
450
+ * Returns the contributed prims in canonical order (set-name, then prim-path) so the
451
+ * fold input is deterministic — the fold itself is order-stable for non-overlapping
452
+ * paths, and any overlap within the variant arc is resolved by the same monotonic rule.
453
+ */
454
+ function selectedVariantPrims(layer: UsdLayer, opts: ComposeOptions): UsdPrim[] {
455
+ const sets = layer.variantSets;
456
+ const selection = opts.variantSelection ?? {};
457
+
458
+ if (sets === undefined) return [];
459
+
460
+ const out: UsdPrim[] = [];
461
+ for (const setName of Object.keys(sets).sort()) {
462
+ const variants = sets[setName]!;
463
+ const chosen = selection[setName];
464
+ let variantPrims: UsdPrim[] | undefined;
465
+ if (chosen !== undefined) {
466
+ // A selection naming a variant THIS layer's set does not hold fails closed — an
467
+ // explicit choice that cannot bind to a declared alternative is never silently
468
+ // dropped to the default / nothing.
469
+ variantPrims = variants[chosen];
470
+ if (variantPrims === undefined) {
471
+ throw new CompositionViolation(
472
+ "variant-unresolved",
473
+ layer.path,
474
+ `variant set "${setName}" has no variant named "${chosen}"`,
475
+ );
476
+ }
477
+ } else {
478
+ // No explicit choice: the declared default variant, else nothing.
479
+ variantPrims = variants[DEFAULT_VARIANT];
480
+ }
481
+ if (variantPrims !== undefined) {
482
+ for (const prim of sortPrims(variantPrims)) out.push(prim);
483
+ }
484
+ }
485
+ return out;
486
+ }
487
+
488
+ /**
489
+ * Validate the WHOLE selection against the document: each selected set name MUST be
490
+ * declared by at least ONE layer. A selection naming a set NO layer declares fails
491
+ * closed (`variant-unresolved`) — an explicit choice that can never bind is never a
492
+ * silent no-op. (The per-variant check — an unknown variant WITHIN a declared set —
493
+ * is enforced per-layer in `selectedVariantPrims`, where the set is in scope.) This is
494
+ * document-scoped because the selection is a single global input to the flatten, while
495
+ * a given set may be declared on only some of the document's layers.
496
+ */
497
+ function assertSelectionResolvable(doc: UsdDocument, opts: ComposeOptions): void {
498
+ const selection = opts.variantSelection;
499
+ if (selection === undefined) return;
500
+ const declared = new Set<string>();
501
+ for (const layer of doc.layers) {
502
+ if (layer.variantSets !== undefined) {
503
+ for (const setName of Object.keys(layer.variantSets)) declared.add(setName);
504
+ }
505
+ }
506
+ for (const setName of Object.keys(selection)) {
507
+ if (!declared.has(setName)) {
508
+ throw new CompositionViolation(
509
+ "variant-unresolved",
510
+ setName,
511
+ `selection names variant set "${setName}" not declared by any layer`,
512
+ );
513
+ }
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Fold the document's layers IN ORDER into the effective flattened prim set.
519
+ *
520
+ * Per layer, the REFERENCE arc resolves FIRST (weakest), then the VARIANT arc (the
521
+ * selected variant of each declared set per `opts.variantSelection`, stronger than
522
+ * references, weaker than local), then this layer's LOCAL prims (strongest) fold OVER
523
+ * both (LIVRPS), producing the layer's effective prim set; resolution is deterministic
524
+ * and fails closed on cycles / unknown reference paths / unresolved variant selections.
525
+ * Then the existing SUBLAYER fold composes those per-layer
526
+ * effective prim sets in `layers[]` (strength) order, unchanged: a prim present in
527
+ * only one layer passes through; a prim a later layer contributes at a path an earlier
528
+ * layer already defined is MERGED per the #137 typed rules (fail-closed
529
+ * `CompositionViolation`). Returns the prim set SORTED by path.
530
+ */
531
+ export function flattenUsd(
532
+ doc: UsdDocument,
533
+ opts: ComposeOptions = {},
534
+ ): UsdPrim[] {
535
+ // Fail closed on a selection naming a set NO layer declares (document-scoped — the
536
+ // selection is a single global input; a set may live on only some layers).
537
+ assertSelectionResolvable(doc, opts);
538
+
539
+ const byPath = new Map<string, UsdLayer>();
540
+ for (const layer of doc.layers) byPath.set(layer.path, layer);
541
+
542
+ const acc = new Map<string, UsdPrim>();
543
+
544
+ for (const layer of doc.layers) {
545
+ const effective = resolveLayerEffective(layer, byPath, opts, []);
546
+ foldPrims(acc, effective, opts);
547
+ }
548
+
549
+ return [...acc.keys()]
550
+ .sort()
551
+ .map((path) => acc.get(path)!);
552
+ }
553
+
554
+ /**
555
+ * The composed-IR identity: sha256 hex of `canonicalJson(flattenUsd(doc, opts))`.
556
+ * Extends #136 (the manifest hash) to a flattened MULTI-LAYER stage, reusing the
557
+ * same byte-stable canonicalizer.
558
+ */
559
+ export function usdHash(doc: UsdDocument, opts: ComposeOptions = {}): string {
560
+ return createHash("sha256")
561
+ .update(canonicalJson(flattenUsd(doc, opts)), "utf8")
562
+ .digest("hex");
563
+ }