@githolon/dsl 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/compile_package_main.ts +256 -9
- package/src/engine_entry.ts +236 -83
- package/src/usd_layers.ts +722 -0
- package/src/usd_state.ts +505 -0
|
@@ -0,0 +1,722 @@
|
|
|
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
|
+
* USD LAYERED EMISSION + the flatten-equivalence fold — the composability spike.
|
|
10
|
+
*
|
|
11
|
+
* THE THESIS (the monoid homomorphism): Nomos's module fold and USD's layer stack
|
|
12
|
+
* are the SAME non-commutative monoid. `composeDomainModule` spread-merges a
|
|
13
|
+
* domain's ordered module list (later wins — a left-biased fold over export bags);
|
|
14
|
+
* a USD layer stack resolves the strongest opinion per attribute (stronger layer
|
|
15
|
+
* wins — a left-biased fold over opinion maps). This module makes the
|
|
16
|
+
* correspondence MECHANICAL:
|
|
17
|
+
*
|
|
18
|
+
* * `emitLayeredDomain` lowers each authored MODULE into its own `UsdLayer`
|
|
19
|
+
* (and a real `.usda` text layer standard USD tooling opens), instead of
|
|
20
|
+
* lowering only the composed result.
|
|
21
|
+
* * `flattenLayeredUsd` is the ordered opinion-map fold over those layers —
|
|
22
|
+
* and the proof obligation is BYTE-EQUALITY:
|
|
23
|
+
*
|
|
24
|
+
* JSON.stringify(flattenLayeredUsd(layers)) === emitUsdJsonForModules([composed])
|
|
25
|
+
*
|
|
26
|
+
* i.e. Φ(fold_modules(A, B)) == fold_layers(Φ(A), Φ(B)) — flatten-after-lower
|
|
27
|
+
* equals lower-after-compose, byte for byte. `nomos-compile --layered`
|
|
28
|
+
* ASSERTS this fail-closed on every layered compile (never assumed).
|
|
29
|
+
*
|
|
30
|
+
* WHERE THE TWO FOLDS COULD DIVERGE (the lemma, stated honestly): USD composes
|
|
31
|
+
* PER-ATTRIBUTE (a weaker layer's attribute shows through a stronger prim that is
|
|
32
|
+
* silent about it), while the module fold replaces a re-exported declaration
|
|
33
|
+
* WHOLESALE. The two coincide exactly when every re-declaration is TOTAL over its
|
|
34
|
+
* attribute set — which the DSL guarantees structurally for aggregates
|
|
35
|
+
* (`aggregate()` takes the full field record; there is no partial declaration) and
|
|
36
|
+
* for a directive's required attributes (target/marker/requires/payload). The gap
|
|
37
|
+
* is omit-when-empty boundaries (`reads`/`emits`) on a RE-declared directive:
|
|
38
|
+
* "omitted" means "none" to the module fold but "no opinion" (show-through) to
|
|
39
|
+
* USD. The compile-time byte-equality assertion fail-closes that case rather than
|
|
40
|
+
* silently shipping either answer.
|
|
41
|
+
*
|
|
42
|
+
* STATE vs LAW (why this is sound): law composition is this non-commutative
|
|
43
|
+
* left-biased fold (order is meaning — the layer stack); STATE merge is the
|
|
44
|
+
* commutative semilattice world (Lww/AddWins/MapOf merge drivers — order-free by
|
|
45
|
+
* construction). The domainHash is deliberately NON-homomorphic: it is sha256 over
|
|
46
|
+
* the COMPOSED canonical bytes, so identity lives on the flattened law, never on
|
|
47
|
+
* any decomposition of it. Layered emission is therefore OPT-IN and additive: the
|
|
48
|
+
* flattened `.package.usda` emission and its hash do not move by one byte.
|
|
49
|
+
*
|
|
50
|
+
* LIVRPS note: the module list maps onto the SUBLAYER arc only (local opinions per
|
|
51
|
+
* layer, stronger-over-weaker). The reference/variant arcs of `usd.ts` are the
|
|
52
|
+
* typed MONOTONIC governance lane (`mergeUsdPrim`, fail-closed rules) — a
|
|
53
|
+
* different, stricter composition than the opinion fold here, on purpose: a tenant
|
|
54
|
+
* re-authoring its own module stack is opinion strength; a foreign layer composing
|
|
55
|
+
* over deployed law is a monotonic move.
|
|
56
|
+
*
|
|
57
|
+
* BUILD-TIME ONLY (Buffer for hex): reached via `@githolon/dsl/usd-layers`, never
|
|
58
|
+
* the runtime barrel.
|
|
59
|
+
*/
|
|
60
|
+
import type { AggregateHandle } from "./aggregate.js";
|
|
61
|
+
import type { Field } from "./fields.js";
|
|
62
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
63
|
+
import type { WireDriver, WireRefMode, WireSchema } from "./wire.js";
|
|
64
|
+
import {
|
|
65
|
+
emitUsd,
|
|
66
|
+
type UsdCombined,
|
|
67
|
+
type UsdDerived,
|
|
68
|
+
type UsdDocument,
|
|
69
|
+
type UsdLayer,
|
|
70
|
+
type UsdPrim,
|
|
71
|
+
type UsdQuery,
|
|
72
|
+
} from "./usd.js";
|
|
73
|
+
|
|
74
|
+
/** The layer-file format marker (customLayerData `nomos:format`). */
|
|
75
|
+
export const NOMOS_USD_LAYER_FORMAT = "nomos.usd-layer.v1";
|
|
76
|
+
/** The layered-root format marker — the subLayers document. */
|
|
77
|
+
export const NOMOS_USD_LAYERED_PACKAGE_FORMAT = "nomos.usd-layered-package.v1";
|
|
78
|
+
|
|
79
|
+
// ── small codecs ───────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/** Lowercase hex of the UTF-8 bytes (same convention as the package envelope). */
|
|
82
|
+
function hexUtf8(text: string): string {
|
|
83
|
+
return Buffer.from(text, "utf8").toString("hex");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function unhexUtf8(hex: string): string {
|
|
87
|
+
return Buffer.from(hex, "hex").toString("utf8");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Escape a string for a double-quoted usda string literal. */
|
|
91
|
+
export function usdaString(s: string): string {
|
|
92
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A usda `string[]` literal. */
|
|
96
|
+
export function usdaStringArray(items: readonly string[]): string {
|
|
97
|
+
return `[${items.map(usdaString).join(", ")}]`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A `WireDriver` as a READABLE usda token: unit drivers are their bare serde
|
|
102
|
+
* string (`"Lww"`, `"AddWins"`, …); `MapOf` nests as `MapOf(<inner>)`. Readable on
|
|
103
|
+
* purpose — the usdcat/usdview tree is the visual proof, so the merge law of every
|
|
104
|
+
* field must be legible in it (hex would bury the point).
|
|
105
|
+
*/
|
|
106
|
+
export function driverToken(driver: WireDriver): string {
|
|
107
|
+
if (typeof driver === "string") return driver;
|
|
108
|
+
return `MapOf(${driverToken(driver.MapOf)})`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Inverse of {@link driverToken} — fail-closed on a malformed token. */
|
|
112
|
+
export function parseDriverToken(token: string): WireDriver {
|
|
113
|
+
if (token.startsWith("MapOf(") && token.endsWith(")")) {
|
|
114
|
+
return { MapOf: parseDriverToken(token.slice("MapOf(".length, -1)) };
|
|
115
|
+
}
|
|
116
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(token)) {
|
|
117
|
+
throw new Error(`usd-layers: malformed driver token '${token}'`);
|
|
118
|
+
}
|
|
119
|
+
return token as WireDriver;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A valid USD prim-name identifier for an arbitrary id (raw id rides `nomos:id`). */
|
|
123
|
+
export function primName(id: string): string {
|
|
124
|
+
const sanitized = id.replace(/[^A-Za-z0-9_]/g, "_");
|
|
125
|
+
return /^[A-Za-z_]/.test(sanitized) ? sanitized : `_${sanitized}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Field→driver map with keys sorted (the canonical schema shape `manifest.ts` emits). */
|
|
129
|
+
function sortedSchema(schema: WireSchema): WireSchema {
|
|
130
|
+
const out: WireSchema = {};
|
|
131
|
+
for (const k of Object.keys(schema).sort()) out[k] = schema[k]!;
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── per-module layer emission ────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/** One authored module of a domain's ordered module list (weak → strong). */
|
|
138
|
+
export interface LayeredModuleInput {
|
|
139
|
+
/** The layer stem (artifact basename) — usually the module file's basename. */
|
|
140
|
+
readonly name: string;
|
|
141
|
+
/** The module lowered ALONE (its own aggregates/directives/declared reads). */
|
|
142
|
+
readonly module: DomainModule;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One emitted module layer: the IR layer + its standalone `.usda` text. */
|
|
146
|
+
export interface EmittedModuleLayer {
|
|
147
|
+
readonly name: string;
|
|
148
|
+
/** The module's `UsdLayer` (path = `/Nomos/<domain>`) — the layer JSON artifact. */
|
|
149
|
+
readonly layer: UsdLayer;
|
|
150
|
+
/** Real usda text standard USD tooling opens (usdcat/usdview/pxr). */
|
|
151
|
+
readonly usda: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Lower ONE domain's ordered module list into per-module USD layers (weak → strong,
|
|
156
|
+
* module order). Each module goes through the SAME `emitUsd` lowering the canonical
|
|
157
|
+
* single-doc emission uses — Φ is one function, applied per module instead of once
|
|
158
|
+
* over the composed module. Prim paths already defined by a WEAKER module of the
|
|
159
|
+
* same stack are emitted with the USD `over` specifier (the stronger layer's
|
|
160
|
+
* opinions compose over the weaker definition); first definitions are `def`s.
|
|
161
|
+
*/
|
|
162
|
+
export function emitLayeredDomain(
|
|
163
|
+
domain: string,
|
|
164
|
+
modules: readonly LayeredModuleInput[],
|
|
165
|
+
): EmittedModuleLayer[] {
|
|
166
|
+
if (modules.length === 0) {
|
|
167
|
+
throw new Error(`usd-layers: domain '${domain}' has no modules to layer`);
|
|
168
|
+
}
|
|
169
|
+
const out: EmittedModuleLayer[] = [];
|
|
170
|
+
const seenPaths = new Set<string>();
|
|
171
|
+
for (const m of modules) {
|
|
172
|
+
const doc = emitUsd([{ path: `/Nomos/${domain}`, module: m.module }]);
|
|
173
|
+
const layer = doc.layers[0]!;
|
|
174
|
+
const overPaths = new Set<string>();
|
|
175
|
+
for (const prim of layer.prims) {
|
|
176
|
+
if (seenPaths.has(prim.path)) overPaths.add(prim.path);
|
|
177
|
+
}
|
|
178
|
+
out.push({
|
|
179
|
+
name: m.name,
|
|
180
|
+
layer,
|
|
181
|
+
usda: usdaLayerText(domain, m.name, layer, m.module, overPaths),
|
|
182
|
+
});
|
|
183
|
+
for (const prim of layer.prims) seenPaths.add(prim.path);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The ref/hasMany decoration for one aggregate (USD relationships — visual arcs). */
|
|
189
|
+
function relationLines(
|
|
190
|
+
domainScope: string,
|
|
191
|
+
agg: AggregateHandle | undefined,
|
|
192
|
+
indent: string,
|
|
193
|
+
): string[] {
|
|
194
|
+
if (agg === undefined) return [];
|
|
195
|
+
const lines: string[] = [];
|
|
196
|
+
for (const [name, field] of Object.entries(agg.fields as Record<string, Field>).sort(
|
|
197
|
+
([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
|
|
198
|
+
)) {
|
|
199
|
+
if (field.kind === "ref" && field.refAggregateId !== undefined) {
|
|
200
|
+
lines.push(
|
|
201
|
+
`${indent}custom rel nomos:ref:${name} = <${domainScope}/${primName(field.refAggregateId)}>`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const [name, field] of Object.entries(agg.hasMany as Record<string, Field>).sort(
|
|
206
|
+
([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
|
|
207
|
+
)) {
|
|
208
|
+
if (field.refAggregateId !== undefined) {
|
|
209
|
+
lines.push(
|
|
210
|
+
`${indent}custom rel nomos:hasMany:${name} = <${domainScope}/${primName(field.refAggregateId)}>`,
|
|
211
|
+
);
|
|
212
|
+
if (field.viaField !== undefined) {
|
|
213
|
+
lines.push(
|
|
214
|
+
`${indent}custom string nomos:hasMany:${name}:via = ${usdaString(field.viaField)}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return lines;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* One module layer as REAL `.usda` text: `/Nomos/<domain>` scopes, one prim per
|
|
224
|
+
* aggregate (field→merge-driver as readable `nomos:driver:<field>` attributes,
|
|
225
|
+
* `t.ref`/`t.hasMany` as USD relationships), one prim per directive, one prim per
|
|
226
|
+
* declared query/derived/combined. Nested JSON (payload schema / emits / returns)
|
|
227
|
+
* rides hex-encoded so the round-trip is byte-faithful. Re-touched prim paths use
|
|
228
|
+
* the `over` specifier; everything carries `nomos:id` so the sanitized prim name
|
|
229
|
+
* never loses the wire id.
|
|
230
|
+
*/
|
|
231
|
+
export function usdaLayerText(
|
|
232
|
+
domain: string,
|
|
233
|
+
moduleName: string,
|
|
234
|
+
layer: UsdLayer,
|
|
235
|
+
module: DomainModule,
|
|
236
|
+
overPaths: ReadonlySet<string>,
|
|
237
|
+
): string {
|
|
238
|
+
const domainScope = `/Nomos/${primName(domain)}`;
|
|
239
|
+
const aggById = new Map<string, AggregateHandle>();
|
|
240
|
+
for (const a of module.aggregates as AggregateHandle[]) aggById.set(a.id, a);
|
|
241
|
+
|
|
242
|
+
const ind2 = " ";
|
|
243
|
+
const ind3 = " ";
|
|
244
|
+
const body: string[] = [];
|
|
245
|
+
|
|
246
|
+
for (const prim of layer.prims) {
|
|
247
|
+
const id = prim.path.slice(prim.path.lastIndexOf("/") + 1);
|
|
248
|
+
const spec = overPaths.has(prim.path) ? "over" : "def";
|
|
249
|
+
body.push(`${ind2}${spec} ${usdaString(primName(id))}`, `${ind2}{`);
|
|
250
|
+
if (prim.kind === "aggregate") {
|
|
251
|
+
body.push(
|
|
252
|
+
`${ind3}custom string nomos:kind = "aggregate"`,
|
|
253
|
+
`${ind3}custom string nomos:id = ${usdaString(prim.type)}`,
|
|
254
|
+
);
|
|
255
|
+
for (const [field, driver] of Object.entries(prim.schema)) {
|
|
256
|
+
body.push(`${ind3}custom string nomos:driver:${field} = ${usdaString(driverToken(driver))}`);
|
|
257
|
+
}
|
|
258
|
+
body.push(...relationLines(domainScope, aggById.get(prim.type), ind3));
|
|
259
|
+
} else {
|
|
260
|
+
body.push(
|
|
261
|
+
`${ind3}custom string nomos:kind = "directive"`,
|
|
262
|
+
`${ind3}custom string nomos:id = ${usdaString(id)}`,
|
|
263
|
+
`${ind3}custom string nomos:target = ${usdaString(prim.target)}`,
|
|
264
|
+
`${ind3}custom string nomos:marker = ${usdaString(prim.marker)}`,
|
|
265
|
+
`${ind3}custom string[] nomos:requires = ${usdaStringArray(prim.requires)}`,
|
|
266
|
+
);
|
|
267
|
+
if (prim.reads !== undefined) {
|
|
268
|
+
body.push(`${ind3}custom string[] nomos:reads = ${usdaStringArray(prim.reads)}`);
|
|
269
|
+
}
|
|
270
|
+
if (prim.emits !== undefined) {
|
|
271
|
+
body.push(
|
|
272
|
+
`${ind3}custom string nomos:emitsHex = ${usdaString(hexUtf8(JSON.stringify(prim.emits)))}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (prim.payloadJsonSchema !== undefined) {
|
|
276
|
+
body.push(
|
|
277
|
+
`${ind3}custom string nomos:payloadJsonSchemaHex = ${usdaString(
|
|
278
|
+
hexUtf8(JSON.stringify(prim.payloadJsonSchema)),
|
|
279
|
+
)}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
body.push(`${ind2}}`, ``);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const q of layer.queries ?? []) {
|
|
287
|
+
body.push(
|
|
288
|
+
`${ind2}def ${usdaString(primName(q.id))}`,
|
|
289
|
+
`${ind2}{`,
|
|
290
|
+
`${ind3}custom string nomos:kind = "query"`,
|
|
291
|
+
`${ind3}custom string nomos:id = ${usdaString(q.id)}`,
|
|
292
|
+
`${ind3}custom string[] nomos:key = ${usdaStringArray(q.key)}`,
|
|
293
|
+
`${ind3}custom string nomos:returns = ${usdaString(q.returns)}`,
|
|
294
|
+
`${ind2}}`,
|
|
295
|
+
``,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
for (const d of layer.deriveds ?? []) {
|
|
299
|
+
body.push(
|
|
300
|
+
`${ind2}def ${usdaString(primName(d.id))}`,
|
|
301
|
+
`${ind2}{`,
|
|
302
|
+
`${ind3}custom string nomos:kind = "derived"`,
|
|
303
|
+
`${ind3}custom string nomos:id = ${usdaString(d.id)}`,
|
|
304
|
+
`${ind3}custom string nomos:of = ${usdaString(d.of)}`,
|
|
305
|
+
`${ind3}custom string nomos:returnsHex = ${usdaString(hexUtf8(JSON.stringify(d.returns)))}`,
|
|
306
|
+
`${ind2}}`,
|
|
307
|
+
``,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
for (const c of layer.combineds ?? []) {
|
|
311
|
+
body.push(
|
|
312
|
+
`${ind2}def ${usdaString(primName(c.id))}`,
|
|
313
|
+
`${ind2}{`,
|
|
314
|
+
`${ind3}custom string nomos:kind = "combined"`,
|
|
315
|
+
`${ind3}custom string nomos:id = ${usdaString(c.id)}`,
|
|
316
|
+
`${ind3}custom string nomos:of = ${usdaString(c.of)}`,
|
|
317
|
+
`${ind3}custom string nomos:refField = ${usdaString(c.refField)}`,
|
|
318
|
+
`${ind3}custom string nomos:readsFrom = ${usdaString(c.reads)}`,
|
|
319
|
+
`${ind3}custom string nomos:returnsHex = ${usdaString(hexUtf8(JSON.stringify(c.returns)))}`,
|
|
320
|
+
`${ind2}}`,
|
|
321
|
+
``,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
// Drop the trailing blank line inside the scope.
|
|
325
|
+
if (body[body.length - 1] === "") body.pop();
|
|
326
|
+
|
|
327
|
+
return [
|
|
328
|
+
`#usda 1.0`,
|
|
329
|
+
`(`,
|
|
330
|
+
` customLayerData = {`,
|
|
331
|
+
` string "nomos:format" = ${usdaString(NOMOS_USD_LAYER_FORMAT)}`,
|
|
332
|
+
` string "nomos:domain" = ${usdaString(domain)}`,
|
|
333
|
+
` string "nomos:module" = ${usdaString(moduleName)}`,
|
|
334
|
+
` }`,
|
|
335
|
+
`)`,
|
|
336
|
+
``,
|
|
337
|
+
`def Scope "Nomos"`,
|
|
338
|
+
`{`,
|
|
339
|
+
` def Scope ${usdaString(primName(domain))}`,
|
|
340
|
+
` {`,
|
|
341
|
+
` custom string nomos:kind = "domain"`,
|
|
342
|
+
` custom string nomos:domain = ${usdaString(domain)}`,
|
|
343
|
+
``,
|
|
344
|
+
...body,
|
|
345
|
+
` }`,
|
|
346
|
+
`}`,
|
|
347
|
+
``,
|
|
348
|
+
].join("\n");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The layered ROOT document: a `#usda 1.0` whose subLayers stack the module layers.
|
|
353
|
+
* INPUT order is Nomos module order (weak → strong, later wins); USD's subLayers
|
|
354
|
+
* list is STRONGEST-FIRST, so the list is emitted REVERSED. Same monoid, opposite
|
|
355
|
+
* notation — the reversal IS the correspondence, recorded in the doc string.
|
|
356
|
+
*/
|
|
357
|
+
export function layeredRootUsda(sublayerRelPathsWeakToStrong: readonly string[]): string {
|
|
358
|
+
const strongestFirst = [...sublayerRelPathsWeakToStrong].reverse();
|
|
359
|
+
return [
|
|
360
|
+
`#usda 1.0`,
|
|
361
|
+
`(`,
|
|
362
|
+
` doc = """Nomos layered domain package. subLayers are STRONGEST-FIRST (USD layer-stack`,
|
|
363
|
+
`order); the Nomos module list composes weak-to-strong (later wins) — the same`,
|
|
364
|
+
`left-biased fold, opposite list notation. Flattening this stage (usdcat --flatten)`,
|
|
365
|
+
`yields the SAME effective law as the flattened .package.usda emission."""`,
|
|
366
|
+
` customLayerData = {`,
|
|
367
|
+
` string "nomos:format" = ${usdaString(NOMOS_USD_LAYERED_PACKAGE_FORMAT)}`,
|
|
368
|
+
` }`,
|
|
369
|
+
` subLayers = [`,
|
|
370
|
+
...strongestFirst.map(
|
|
371
|
+
(p, i) => ` @${p}@${i < strongestFirst.length - 1 ? "," : ""}`,
|
|
372
|
+
),
|
|
373
|
+
` ]`,
|
|
374
|
+
`)`,
|
|
375
|
+
``,
|
|
376
|
+
].join("\n");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── the ordered opinion-map fold (flatten) ───────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/** Strongest-opinion-per-attribute merge of two prims at one path (over = stronger). */
|
|
382
|
+
function foldOpinionPrim(base: UsdPrim, over: UsdPrim): UsdPrim {
|
|
383
|
+
if (base.kind !== over.kind) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`usd-layers: opinion fold conflict at ${over.path}: a ${over.kind} prim cannot ` +
|
|
386
|
+
`override a ${base.kind} prim (structural conflict, fail-closed).`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (base.kind === "aggregate" && over.kind === "aggregate") {
|
|
390
|
+
// Per-attribute: the stronger layer's field opinions win; weaker fields show
|
|
391
|
+
// through. (Coincides with the module fold's wholesale replacement exactly when
|
|
392
|
+
// the redeclaration is total — which `aggregate()` makes structural.)
|
|
393
|
+
return {
|
|
394
|
+
kind: "aggregate",
|
|
395
|
+
path: over.path,
|
|
396
|
+
type: over.type,
|
|
397
|
+
schema: sortedSchema({ ...base.schema, ...over.schema }),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (base.kind === "directive" && over.kind === "directive") {
|
|
401
|
+
// Per-attribute show-through, rebuilt in the canonical prim key order so the
|
|
402
|
+
// flattened bytes match `encodeModuleToPrims` exactly.
|
|
403
|
+
const reads = over.reads ?? base.reads;
|
|
404
|
+
const emits = over.emits ?? base.emits;
|
|
405
|
+
const payloadJsonSchema = over.payloadJsonSchema ?? base.payloadJsonSchema;
|
|
406
|
+
return {
|
|
407
|
+
kind: "directive",
|
|
408
|
+
path: over.path,
|
|
409
|
+
target: over.target,
|
|
410
|
+
marker: over.marker,
|
|
411
|
+
requires: over.requires,
|
|
412
|
+
...(reads !== undefined ? { reads } : {}),
|
|
413
|
+
...(emits !== undefined ? { emits } : {}),
|
|
414
|
+
...(payloadJsonSchema !== undefined ? { payloadJsonSchema } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
throw new Error(`usd-layers: unhandled prim kind at ${over.path}.`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Fold id-keyed layer read declarations (later layer wins per id), sorted by id. */
|
|
421
|
+
function foldById<T extends { readonly id: string }>(
|
|
422
|
+
lists: readonly (readonly T[] | undefined)[],
|
|
423
|
+
): T[] | undefined {
|
|
424
|
+
const byId = new Map<string, T>();
|
|
425
|
+
for (const list of lists) for (const item of list ?? []) byId.set(item.id, item);
|
|
426
|
+
if (byId.size === 0) return undefined;
|
|
427
|
+
return [...byId.keys()].sort().map((id) => byId.get(id)!);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* THE ORDERED OPINION-MAP FOLD: flatten module layers (weak → strong within each
|
|
432
|
+
* stage path) into the effective single-layer-per-domain document. Strongest
|
|
433
|
+
* opinion per attribute; layer-level read declarations fold by id (later wins).
|
|
434
|
+
* Output layers/prims sorted by path — the SAME canonical shape `emitUsd` emits, so
|
|
435
|
+
* `JSON.stringify(flattenLayeredUsd(layers))` is directly byte-comparable with
|
|
436
|
+
* `emitUsdJsonForModules([composedModule, ...])`. THAT byte-equality is the
|
|
437
|
+
* homomorphism: flatten-after-lower == lower-after-compose.
|
|
438
|
+
*/
|
|
439
|
+
export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
440
|
+
const groups = new Map<string, UsdLayer[]>();
|
|
441
|
+
for (const layer of layers) {
|
|
442
|
+
const group = groups.get(layer.path);
|
|
443
|
+
if (group === undefined) groups.set(layer.path, [layer]);
|
|
444
|
+
else group.push(layer);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const out: UsdLayer[] = [...groups.keys()].sort().map((path) => {
|
|
448
|
+
const stack = groups.get(path)!;
|
|
449
|
+
const prims = new Map<string, UsdPrim>();
|
|
450
|
+
for (const layer of stack) {
|
|
451
|
+
for (const prim of layer.prims) {
|
|
452
|
+
const existing = prims.get(prim.path);
|
|
453
|
+
prims.set(prim.path, existing === undefined ? prim : foldOpinionPrim(existing, prim));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const queries = foldById<UsdQuery>(stack.map((l) => l.queries));
|
|
457
|
+
const deriveds = foldById<UsdDerived>(stack.map((l) => l.deriveds));
|
|
458
|
+
const combineds = foldById<UsdCombined>(stack.map((l) => l.combineds));
|
|
459
|
+
return {
|
|
460
|
+
path,
|
|
461
|
+
prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
|
|
462
|
+
...(queries !== undefined ? { queries } : {}),
|
|
463
|
+
...(deriveds !== undefined ? { deriveds } : {}),
|
|
464
|
+
...(combineds !== undefined ? { combineds } : {}),
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
return { layers: out };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── usda → IR (the round-trip parser for the pxr/usdcat conformance lane) ──────────
|
|
472
|
+
|
|
473
|
+
interface ParsedPrim {
|
|
474
|
+
/** Sanitized prim path, e.g. `/Nomos/guestbook/GuestbookEntry`. */
|
|
475
|
+
readonly path: string;
|
|
476
|
+
readonly attrs: Map<string, string | string[]>;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const ATTR_STRING = /^custom\s+string\s+([\w:]+)\s*=\s*"((?:[^"\\]|\\.)*)"$/;
|
|
480
|
+
const ATTR_STRING_ARRAY = /^custom\s+string\[\]\s+([\w:]+)\s*=\s*\[(.*)\]$/;
|
|
481
|
+
const PRIM_HEADER = /^(?:def|over|class)(?:\s+\w+)?\s+"([^"]+)"/;
|
|
482
|
+
|
|
483
|
+
function unescapeUsda(s: string): string {
|
|
484
|
+
return s.replace(/\\(.)/g, "$1");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parseStringArrayItems(inner: string): string[] {
|
|
488
|
+
const items: string[] = [];
|
|
489
|
+
const re = /"((?:[^"\\]|\\.)*)"/g;
|
|
490
|
+
let m: RegExpExecArray | null;
|
|
491
|
+
while ((m = re.exec(inner)) !== null) items.push(unescapeUsda(m[1]!));
|
|
492
|
+
return items;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A deliberately SMALL usda reader for the documents THIS module emits (and their
|
|
497
|
+
* usdcat-flattened images): prim tree + `custom string` / `custom string[]`
|
|
498
|
+
* attributes. Relationships and unknown lines are ignored (decoration). Not a
|
|
499
|
+
* general USD parser — the conformance lane's inverse image, nothing more.
|
|
500
|
+
*/
|
|
501
|
+
function parseUsdaPrims(text: string): ParsedPrim[] {
|
|
502
|
+
const prims: ParsedPrim[] = [];
|
|
503
|
+
const stack: ParsedPrim[] = [];
|
|
504
|
+
let pending: string | undefined;
|
|
505
|
+
let inLayerMeta = false;
|
|
506
|
+
|
|
507
|
+
for (const raw of text.split("\n")) {
|
|
508
|
+
const line = raw.trim();
|
|
509
|
+
if (stack.length === 0 && pending === undefined) {
|
|
510
|
+
if (line === "(") {
|
|
511
|
+
inLayerMeta = true;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (inLayerMeta) {
|
|
515
|
+
if (line === ")") inLayerMeta = false;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const header = PRIM_HEADER.exec(line);
|
|
520
|
+
if (header !== null) {
|
|
521
|
+
pending = header[1]!;
|
|
522
|
+
// Same-line `{` (not emitted here, but tolerated).
|
|
523
|
+
if (!line.endsWith("{")) continue;
|
|
524
|
+
}
|
|
525
|
+
if (line === "{" || (header !== null && line.endsWith("{"))) {
|
|
526
|
+
const parentPath = stack.length > 0 ? stack[stack.length - 1]!.path : "";
|
|
527
|
+
const prim: ParsedPrim = {
|
|
528
|
+
path: `${parentPath}/${pending ?? "?"}`,
|
|
529
|
+
attrs: new Map(),
|
|
530
|
+
};
|
|
531
|
+
pending = undefined;
|
|
532
|
+
stack.push(prim);
|
|
533
|
+
prims.push(prim);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (line === "}") {
|
|
537
|
+
stack.pop();
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (stack.length === 0) continue;
|
|
541
|
+
const top = stack[stack.length - 1]!;
|
|
542
|
+
const arr = ATTR_STRING_ARRAY.exec(line);
|
|
543
|
+
if (arr !== null) {
|
|
544
|
+
top.attrs.set(arr[1]!, parseStringArrayItems(arr[2]!));
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const str = ATTR_STRING.exec(line);
|
|
548
|
+
if (str !== null) {
|
|
549
|
+
top.attrs.set(str[1]!, unescapeUsda(str[2]!));
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return prims;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function attrString(prim: ParsedPrim, name: string): string {
|
|
557
|
+
const v = prim.attrs.get(name);
|
|
558
|
+
if (typeof v !== "string") {
|
|
559
|
+
throw new Error(`usd-layers: prim ${prim.path} is missing string attribute ${name}`);
|
|
560
|
+
}
|
|
561
|
+
return v;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function attrStringArray(prim: ParsedPrim, name: string): string[] {
|
|
565
|
+
const v = prim.attrs.get(name);
|
|
566
|
+
if (!Array.isArray(v)) {
|
|
567
|
+
throw new Error(`usd-layers: prim ${prim.path} is missing string[] attribute ${name}`);
|
|
568
|
+
}
|
|
569
|
+
return v;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Reconstruct the IR `UsdDocument` from usda text this module emitted — either ONE
|
|
574
|
+
* module layer or a usdcat-FLATTENED stage. Domains are identified by their scope
|
|
575
|
+
* prim (`nomos:kind = "domain"`); every child prim rebuilds from its `nomos:*`
|
|
576
|
+
* attributes into the canonical prim shape (so the result is directly comparable —
|
|
577
|
+
* `JSON.stringify`-byte-comparable — with `emitUsd` / `flattenLayeredUsd` output).
|
|
578
|
+
*/
|
|
579
|
+
export function parseUsdaDocument(text: string): UsdDocument {
|
|
580
|
+
const prims = parseUsdaPrims(text);
|
|
581
|
+
const domainByScope = new Map<string, string>();
|
|
582
|
+
for (const p of prims) {
|
|
583
|
+
if (p.attrs.get("nomos:kind") === "domain") {
|
|
584
|
+
domainByScope.set(p.path, attrString(p, "nomos:domain"));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
interface Bucket {
|
|
589
|
+
prims: UsdPrim[];
|
|
590
|
+
queries: UsdQuery[];
|
|
591
|
+
deriveds: UsdDerived[];
|
|
592
|
+
combineds: UsdCombined[];
|
|
593
|
+
}
|
|
594
|
+
const buckets = new Map<string, Bucket>();
|
|
595
|
+
const bucketFor = (domain: string): Bucket => {
|
|
596
|
+
let b = buckets.get(domain);
|
|
597
|
+
if (b === undefined) {
|
|
598
|
+
b = { prims: [], queries: [], deriveds: [], combineds: [] };
|
|
599
|
+
buckets.set(domain, b);
|
|
600
|
+
}
|
|
601
|
+
return b;
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
for (const p of prims) {
|
|
605
|
+
const kind = p.attrs.get("nomos:kind");
|
|
606
|
+
if (typeof kind !== "string" || kind === "domain") continue;
|
|
607
|
+
const scopePath = p.path.slice(0, p.path.lastIndexOf("/"));
|
|
608
|
+
const domain = domainByScope.get(scopePath);
|
|
609
|
+
if (domain === undefined) {
|
|
610
|
+
throw new Error(`usd-layers: prim ${p.path} has no enclosing nomos domain scope`);
|
|
611
|
+
}
|
|
612
|
+
const b = bucketFor(domain);
|
|
613
|
+
const id = attrString(p, "nomos:id");
|
|
614
|
+
const irPath = `/Nomos/${domain}/${id}`;
|
|
615
|
+
|
|
616
|
+
if (kind === "aggregate") {
|
|
617
|
+
const schema: WireSchema = {};
|
|
618
|
+
for (const [name, value] of p.attrs) {
|
|
619
|
+
if (name.startsWith("nomos:driver:") && typeof value === "string") {
|
|
620
|
+
schema[name.slice("nomos:driver:".length)] = parseDriverToken(value);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
b.prims.push({ kind: "aggregate", path: irPath, type: id, schema: sortedSchema(schema) });
|
|
624
|
+
} else if (kind === "directive") {
|
|
625
|
+
const reads = p.attrs.has("nomos:reads") ? attrStringArray(p, "nomos:reads") : undefined;
|
|
626
|
+
const emits = p.attrs.has("nomos:emitsHex")
|
|
627
|
+
? (JSON.parse(unhexUtf8(attrString(p, "nomos:emitsHex"))) as Record<string, { max?: number }>)
|
|
628
|
+
: undefined;
|
|
629
|
+
const payloadJsonSchema = p.attrs.has("nomos:payloadJsonSchemaHex")
|
|
630
|
+
? (JSON.parse(unhexUtf8(attrString(p, "nomos:payloadJsonSchemaHex"))) as unknown)
|
|
631
|
+
: undefined;
|
|
632
|
+
b.prims.push({
|
|
633
|
+
kind: "directive",
|
|
634
|
+
path: irPath,
|
|
635
|
+
target: attrString(p, "nomos:target"),
|
|
636
|
+
marker: attrString(p, "nomos:marker") as WireRefMode,
|
|
637
|
+
requires: attrStringArray(p, "nomos:requires"),
|
|
638
|
+
...(reads !== undefined ? { reads } : {}),
|
|
639
|
+
...(emits !== undefined ? { emits } : {}),
|
|
640
|
+
...(payloadJsonSchema !== undefined ? { payloadJsonSchema } : {}),
|
|
641
|
+
});
|
|
642
|
+
} else if (kind === "query") {
|
|
643
|
+
b.queries.push({
|
|
644
|
+
id,
|
|
645
|
+
key: attrStringArray(p, "nomos:key"),
|
|
646
|
+
returns: attrString(p, "nomos:returns"),
|
|
647
|
+
});
|
|
648
|
+
} else if (kind === "derived") {
|
|
649
|
+
b.deriveds.push({
|
|
650
|
+
id,
|
|
651
|
+
of: attrString(p, "nomos:of"),
|
|
652
|
+
returns: JSON.parse(unhexUtf8(attrString(p, "nomos:returnsHex"))) as UsdDerived["returns"],
|
|
653
|
+
});
|
|
654
|
+
} else if (kind === "combined") {
|
|
655
|
+
b.combineds.push({
|
|
656
|
+
id,
|
|
657
|
+
of: attrString(p, "nomos:of"),
|
|
658
|
+
refField: attrString(p, "nomos:refField"),
|
|
659
|
+
reads: attrString(p, "nomos:readsFrom"),
|
|
660
|
+
returns: JSON.parse(unhexUtf8(attrString(p, "nomos:returnsHex"))) as UsdCombined["returns"],
|
|
661
|
+
});
|
|
662
|
+
} else {
|
|
663
|
+
throw new Error(`usd-layers: prim ${p.path} has unknown nomos:kind '${kind}'`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const byId = <T extends { id: string }>(a: T, b: T) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
668
|
+
const byPath = (a: UsdPrim, b: UsdPrim) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
669
|
+
const layers: UsdLayer[] = [...buckets.keys()]
|
|
670
|
+
.map((domain) => {
|
|
671
|
+
const b = buckets.get(domain)!;
|
|
672
|
+
return {
|
|
673
|
+
path: `/Nomos/${domain}`,
|
|
674
|
+
prims: [...b.prims].sort(byPath),
|
|
675
|
+
...(b.queries.length > 0 ? { queries: [...b.queries].sort(byId) } : {}),
|
|
676
|
+
...(b.deriveds.length > 0 ? { deriveds: [...b.deriveds].sort(byId) } : {}),
|
|
677
|
+
...(b.combineds.length > 0 ? { combineds: [...b.combineds].sort(byId) } : {}),
|
|
678
|
+
};
|
|
679
|
+
})
|
|
680
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
681
|
+
return { layers };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── the honest TS-rendered tree (usdcat-equivalent visual) ──────────────────────────
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The usdcat-equivalent prim tree as plain text — the visual proof for environments
|
|
688
|
+
* without USD tooling (and the muted-layer diff's render). Honest about what it is:
|
|
689
|
+
* a rendering of the IR document, not a pxr traversal.
|
|
690
|
+
*/
|
|
691
|
+
export function renderUsdTree(doc: UsdDocument): string {
|
|
692
|
+
const lines: string[] = [];
|
|
693
|
+
for (const layer of doc.layers) {
|
|
694
|
+
lines.push(layer.path);
|
|
695
|
+
for (const prim of layer.prims) {
|
|
696
|
+
const name = prim.path.slice(prim.path.lastIndexOf("/") + 1);
|
|
697
|
+
if (prim.kind === "aggregate") {
|
|
698
|
+
lines.push(` ${name} [aggregate]`);
|
|
699
|
+
for (const [field, driver] of Object.entries(prim.schema)) {
|
|
700
|
+
lines.push(` .${field} : ${driverToken(driver)}`);
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
const reads = prim.reads !== undefined ? ` reads [${prim.reads.join(", ")}]` : "";
|
|
704
|
+
lines.push(
|
|
705
|
+
` ${name} [directive] ${prim.marker} -> ${prim.target} requires [${prim.requires.join(", ")}]${reads}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
for (const q of layer.queries ?? []) {
|
|
710
|
+
lines.push(` ${q.id} [query] key (${q.key.join(", ")}) -> ${q.returns}`);
|
|
711
|
+
}
|
|
712
|
+
for (const d of layer.deriveds ?? []) {
|
|
713
|
+
lines.push(` ${d.id} [derived] of ${d.of} -> ${d.returns.type}${d.returns.nullable ? "?" : ""}`);
|
|
714
|
+
}
|
|
715
|
+
for (const c of layer.combineds ?? []) {
|
|
716
|
+
lines.push(
|
|
717
|
+
` ${c.id} [combined] of ${c.of} via .${c.refField} reads ${c.reads} -> ${c.returns.type}${c.returns.nullable ? "?" : ""}`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return lines.join("\n") + "\n";
|
|
722
|
+
}
|