@githolon/dsl 0.2.0 → 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.
@@ -0,0 +1,505 @@
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 STATE EMISSION — projection folds as TIME SAMPLES (spike 2: scrub the ledger).
10
+ *
11
+ * THE THESIS: everything a githolon computes is a DETERMINISTIC FOLD over an ordered
12
+ * intent chain, so the projection emits as a USD STATE layer whose timeCode axis IS
13
+ * the chain: frame t = the read model after folding intents 0..t. Scrubbing the
14
+ * timeline in usdview is replaying the chain; state-at-t is a frame; and because the
15
+ * fold is deterministic, the SAME chain emits the SAME bytes — visualization is a
16
+ * pure function of the ledger.
17
+ *
18
+ * TIMECODE LAW: `t` is the CHAIN INDEX (0-based position of the sealed intent on
19
+ * `main`), NEVER wall clock. Wall-clock time rides INSIDE intents (caller-stamped
20
+ * ISO strings / captured HLCs) and may appear as attribute VALUES; it never becomes
21
+ * the time axis. The axis is causal order — the only order the kernel certifies.
22
+ *
23
+ * LAYERING (the spike-1 composition, extended): the law layers of
24
+ * `usd_layers.ts` lower WHAT MAY HAPPEN (`/Nomos/<domain>` — aggregates, merge
25
+ * drivers, directives); this module lowers WHAT HAS HAPPENED (`/NomosState/<domain>`
26
+ * — one prim per aggregate INSTANCE, folded fields as sampled attributes). The
27
+ * scrub stage stacks the state layer OVER the law layers — disjoint scopes today
28
+ * (state never re-touches law paths), but the stack order states the relationship:
29
+ * state is the stronger, later word, and a future state layer that DID carry
30
+ * opinions about law paths would compose by exactly these rules.
31
+ *
32
+ * TWO TIERS, one emitter:
33
+ * * AUTO-LAYOUT (abstract domains): instances are placed on a deterministic grid
34
+ * in first-appearance order, jittered by an FNV-1a hash of the aggregate id —
35
+ * SEEDED BY THE ID, so the same chain lays out the same picture everywhere.
36
+ * Geometry is synthetic (a gprim exists so usdview shows something).
37
+ * * SPATIAL (domains with positional fields): `xformOp:translate` is sampled FROM
38
+ * THE FOLDED FIELDS — the geometry is the domain's own truth, genuinely 3D, and
39
+ * a moved asset MOVES as you scrub.
40
+ *
41
+ * Everything is emitted SAMPLE-ON-CHANGE (USD holds the previous sample, exactly the
42
+ * fold's semantics: state persists until a later intent changes it) and in sorted
43
+ * order (types, ids, fields) so the bytes are canonical.
44
+ *
45
+ * BUILD-TIME ONLY (sibling of `usd_layers.ts`): reached via `@githolon/dsl/usd-state`,
46
+ * never the runtime barrel. The deployable package bytes and the domainHash do not
47
+ * move — state emission reads the PROJECTION, it never enters the law.
48
+ */
49
+ import { primName, usdaString, usdaStringArray } from "./usd_layers.js";
50
+
51
+ /** The state-layer format marker (customLayerData `nomos:format`). */
52
+ export const NOMOS_USD_STATE_LAYER_FORMAT = "nomos.usd-state-layer.v1";
53
+ /** The scrub-stage root format marker — state stacked over the law layers. */
54
+ export const NOMOS_USD_SCRUB_STAGE_FORMAT = "nomos.usd-scrub-stage.v1";
55
+
56
+ // ── input shapes (REAL projection reads — never hand-simulated state) ────────────────
57
+
58
+ /**
59
+ * One projection row as the read engine returns it (`query_by_id` / `query`):
60
+ * `type` + `id` + the partially folded `data`. The recorder reads these BETWEEN
61
+ * intents from the engine's projection — the emitter only ever renders real folds.
62
+ */
63
+ export interface StateRow {
64
+ readonly type: string;
65
+ readonly id: string;
66
+ readonly data: Readonly<Record<string, unknown>>;
67
+ }
68
+
69
+ /** The projection after folding chain prefix 0..t (t = the intent's chain index). */
70
+ export interface StateFrame {
71
+ /** The CHAIN INDEX of the intent whose fold this frame shows — the timeCode. */
72
+ readonly t: number;
73
+ /** The sealed intent's id (provenance label, rides the Chain prim). */
74
+ readonly intentId: string;
75
+ /** `domain/directiveId` — what happened at this frame (the scrub legend). */
76
+ readonly directive: string;
77
+ /** Every tracked aggregate instance's rows AFTER this fold. */
78
+ readonly rows: readonly StateRow[];
79
+ }
80
+
81
+ /** Field kinds from the read manifest (`aggregateFieldKinds`) — drives attr typing. */
82
+ export type StateFieldKinds = Readonly<
83
+ Record<string, Readonly<Record<string, { readonly kind: string }>>>
84
+ >;
85
+
86
+ /** Positions from folded fields (the spatial tier). Field values must fold numeric. */
87
+ export interface SpatialMapping {
88
+ readonly x: string;
89
+ readonly y: string;
90
+ readonly z?: string;
91
+ /** Uniform scale applied to the folded values (e.g. decimetres → metres). */
92
+ readonly scale?: number;
93
+ }
94
+
95
+ export interface StateLayerOptions {
96
+ readonly domain: string;
97
+ readonly frames: readonly StateFrame[];
98
+ /** Read-manifest field kinds: `set` fields emit `string[]`, `int` fields emit `int`. */
99
+ readonly fieldKinds?: StateFieldKinds;
100
+ /** Present ⇒ spatial tier (translate sampled from folds); absent ⇒ seeded auto-layout. */
101
+ readonly spatial?: SpatialMapping;
102
+ }
103
+
104
+ // ── deterministic layout (seeded by the aggregate id hash — never Math.random) ───────
105
+
106
+ /** FNV-1a 32-bit over UTF-16 code units — the layout seed for one aggregate id. */
107
+ export function fnv1a32(s: string): number {
108
+ let h = 0x811c9dc5;
109
+ for (let i = 0; i < s.length; i++) {
110
+ h ^= s.charCodeAt(i);
111
+ h = Math.imul(h, 0x01000193) >>> 0;
112
+ }
113
+ return h >>> 0;
114
+ }
115
+
116
+ /**
117
+ * The AUTO-LAYOUT position for instance `rank` of `n` (first-appearance order,
118
+ * id-tiebroken): a square grid with 3-unit spacing, centred on the origin, plus a
119
+ * sub-cell jitter derived from the id hash so distinct instances read as distinct
120
+ * even when ranks collide visually. Pure: (id, rank, n) → position, no ambient state.
121
+ */
122
+ export function seededLayout(id: string, rank: number, n: number): [number, number, number] {
123
+ const cols = Math.max(1, Math.ceil(Math.sqrt(n)));
124
+ const col = rank % cols;
125
+ const row = Math.floor(rank / cols);
126
+ const h = fnv1a32(id);
127
+ const jx = ((h & 0xffff) / 0xffff - 0.5) * 1.5;
128
+ const jy = (((h >>> 16) & 0xffff) / 0xffff - 0.5) * 1.5;
129
+ const half = (cols - 1) / 2;
130
+ return [(col - half) * 3 + jx, (row - half) * 3 + jy, 0];
131
+ }
132
+
133
+ /** Deterministic display colour from the id hash (HSV slice → RGB, fully pure). */
134
+ export function seededColor(id: string): [number, number, number] {
135
+ const h6 = (fnv1a32(id) % 360) / 60;
136
+ const c = 0.55,
137
+ m = 0.35;
138
+ const x = c * (1 - Math.abs((h6 % 2) - 1));
139
+ const [r, g, b] =
140
+ h6 < 1 ? [c, x, 0] : h6 < 2 ? [x, c, 0] : h6 < 3 ? [0, c, x] : h6 < 4 ? [0, x, c] : h6 < 5 ? [x, 0, c] : [c, 0, x];
141
+ const f = (v: number) => Math.round((v + m) * 1000) / 1000;
142
+ return [f(r), f(g), f(b)];
143
+ }
144
+
145
+ // ── canonical value rendering ─────────────────────────────────────────────────────────
146
+
147
+ /** JSON with sorted object keys at every depth — one byte form per value. */
148
+ function canonicalJson(v: unknown): string {
149
+ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
150
+ if (v !== null && typeof v === "object") {
151
+ const o = v as Record<string, unknown>;
152
+ return `{${Object.keys(o)
153
+ .sort()
154
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
155
+ .join(",")}}`;
156
+ }
157
+ return JSON.stringify(v);
158
+ }
159
+
160
+ /** A finite double as usda text (JS canonical shortest repr — deterministic). */
161
+ function num(n: number): string {
162
+ if (!Number.isFinite(n)) throw new Error(`usd-state: non-finite number ${n} cannot enter a usda literal`);
163
+ return String(n);
164
+ }
165
+
166
+ const fail = (msg: string): never => {
167
+ throw new Error(`usd-state: ${msg}`);
168
+ };
169
+
170
+ /** The instance-track key: NUL-joined so no (type, id) pair can collide. */
171
+ const keyOf = (type: string, id: string): string => `${type}\u0000${id}`;
172
+
173
+ // ── the emitter ───────────────────────────────────────────────────────────────────────
174
+
175
+ interface InstanceTrack {
176
+ readonly type: string;
177
+ readonly id: string;
178
+ firstSeen: number;
179
+ /** field → declared usda type + ordered (t, renderedValue) samples, on change only. */
180
+ readonly fields: Map<string, { decl: string; samples: { t: number; value: string }[] }>;
181
+ /** spatial tier: ordered (t, xyz) samples, recorded on change only. */
182
+ readonly positions: { t: number; xyz: [number, number, number] }[];
183
+ }
184
+
185
+ /** usda literal + declared type for one folded field value. */
186
+ function renderField(kind: string | undefined, value: unknown): { decl: string; literal: string } {
187
+ if (kind === "set") {
188
+ if (!Array.isArray(value) || value.some((x) => typeof x !== "string")) {
189
+ return { decl: "string", literal: usdaString(canonicalJson(value)) };
190
+ }
191
+ return { decl: "string[]", literal: usdaStringArray([...(value as string[])].sort()) };
192
+ }
193
+ if (kind === "int" && typeof value === "number" && Number.isInteger(value)) {
194
+ return { decl: "int", literal: String(value) };
195
+ }
196
+ if ((kind === "string" || kind === "enum") && typeof value === "string") {
197
+ return { decl: "string", literal: usdaString(value) };
198
+ }
199
+ // maps / json / refs / derived strings / anything else: canonical JSON in a string.
200
+ return {
201
+ decl: "string",
202
+ literal: usdaString(typeof value === "string" ? value : canonicalJson(value)),
203
+ };
204
+ }
205
+
206
+ function spatialValue(row: StateRow, field: string, t: number, scale: number): number {
207
+ const v = row.data[field];
208
+ if (typeof v !== "number" || !Number.isFinite(v)) {
209
+ fail(
210
+ `spatial tier requires a finite numeric '${field}' on ${row.type}:${row.id} at chain index ${t}; ` +
211
+ `folded value was ${canonicalJson(v)} — fix the fixture chain (the directive must set '${field}') ` +
212
+ `or drop the spatial mapping (the seeded auto-layout tier needs no fields)`,
213
+ );
214
+ }
215
+ return (v as number) * scale;
216
+ }
217
+
218
+ /**
219
+ * THE STATE LAYER: one `#usda` text whose timeCode axis is the chain. Per aggregate
220
+ * instance: an `Xform` prim (under `/NomosState/<domain>/<Type>/`) holding
221
+ * * `visibility` timeSamples — `invisible` before the instance's first fold,
222
+ * `inherited` from its birth frame (instances APPEAR as you scrub);
223
+ * * `xformOp:translate` — auto-layout (static, id-hash-seeded) or spatial
224
+ * timeSamples (from the folded positional fields, sample-on-change);
225
+ * * one `custom` attribute per folded field, `nomos:state:<field>`, timeSamples
226
+ * recorded ON CHANGE (USD's held interpolation = the fold's persistence);
227
+ * * a child gprim so stock tooling renders something (synthetic for the abstract
228
+ * tier — stated honestly; the SPATIAL tier's transform is the domain's truth).
229
+ * Plus `/NomosState/<domain>/Chain` carrying `nomos:chain:directive` / `:intent`
230
+ * timeSamples — the scrub legend (what happened at frame t).
231
+ *
232
+ * Fail-closed: frames must be CONTIGUOUS chain indices from 0 (the timeCode IS the
233
+ * chain position); spatial fields must fold finite numbers. Pure function — same
234
+ * frames, same bytes.
235
+ */
236
+ export function emitStateLayerUsda(opts: StateLayerOptions): string {
237
+ const { domain, frames } = opts;
238
+ if (frames.length === 0) {
239
+ fail(`no frames for domain '${domain}' — record one frame per sealed intent (chain index 0 onward)`);
240
+ }
241
+ frames.forEach((f, i) => {
242
+ if (f.t !== i) {
243
+ fail(
244
+ `frame ${i} carries t=${f.t} — the timeCode IS the chain index, never wall clock; ` +
245
+ `record exactly one frame per sealed intent, contiguous from 0`,
246
+ );
247
+ }
248
+ });
249
+ const scale = opts.spatial?.scale ?? 1;
250
+
251
+ // ── fold the frames into per-instance sample tracks ──
252
+ const tracks = new Map<string, InstanceTrack>(); // keyed by keyOf(type, id)
253
+ const order: string[] = []; // first-appearance order (the layout rank)
254
+ for (const frame of frames) {
255
+ const seen = new Set<string>();
256
+ for (const row of frame.rows) {
257
+ const key = keyOf(row.type, row.id);
258
+ if (seen.has(key)) fail(`frame t=${frame.t} carries duplicate rows for ${row.type}:${row.id}`);
259
+ seen.add(key);
260
+ let track = tracks.get(key);
261
+ if (track === undefined) {
262
+ track = { type: row.type, id: row.id, firstSeen: frame.t, fields: new Map(), positions: [] };
263
+ tracks.set(key, track);
264
+ order.push(key);
265
+ }
266
+ const kinds = opts.fieldKinds?.[row.type];
267
+ for (const field of Object.keys(row.data).sort()) {
268
+ const { decl, literal } = renderField(kinds?.[field]?.kind, row.data[field]);
269
+ let entry = track.fields.get(field);
270
+ if (entry === undefined) {
271
+ entry = { decl, samples: [] };
272
+ // A field that first folds AFTER the instance's birth gets a VALUE BLOCK
273
+ // (`None`) at the birth frame — USD evaluates a timeSamples attribute as
274
+ // its first sample held BACKWARD, which would show the value before the
275
+ // fold produced it. The block keeps every earlier frame honestly unfolded.
276
+ if (frame.t > track.firstSeen) entry.samples.push({ t: track.firstSeen, value: "None" });
277
+ track.fields.set(field, entry);
278
+ } else if (entry.decl !== decl) {
279
+ fail(
280
+ `field '${field}' of ${row.type}:${row.id} changed shape mid-chain ` +
281
+ `(${entry.decl} → ${decl} at t=${frame.t}) — a folded field keeps one wire kind; ` +
282
+ `check the read manifest's aggregateFieldKinds for '${row.type}'`,
283
+ );
284
+ }
285
+ const last = entry.samples[entry.samples.length - 1];
286
+ if (last === undefined || last.value !== literal) {
287
+ entry.samples.push({ t: frame.t, value: literal });
288
+ }
289
+ }
290
+ if (opts.spatial !== undefined) {
291
+ const xyz: [number, number, number] = [
292
+ spatialValue(row, opts.spatial.x, frame.t, scale),
293
+ spatialValue(row, opts.spatial.y, frame.t, scale),
294
+ opts.spatial.z !== undefined ? spatialValue(row, opts.spatial.z, frame.t, scale) : 0,
295
+ ];
296
+ const last = track.positions[track.positions.length - 1];
297
+ if (last === undefined || last.xyz.join(",") !== xyz.join(",")) {
298
+ track.positions.push({ t: frame.t, xyz });
299
+ }
300
+ }
301
+ }
302
+ }
303
+ const rankOf = new Map(order.map((k, i) => [k, i]));
304
+
305
+ // ── render ──
306
+ const lines: string[] = [
307
+ `#usda 1.0`,
308
+ `(`,
309
+ ` doc = """Nomos STATE layer — the projection as time samples. timeCode t = the`,
310
+ `chain index of the sealed intent whose fold frame t shows (causal order, never`,
311
+ `wall clock). Scrubbing the timeline IS replaying the chain; the same chain emits`,
312
+ `the same bytes. Composes OVER the domain's law layers (see the scrub stage)."""`,
313
+ ` customLayerData = {`,
314
+ ` string "nomos:format" = ${usdaString(NOMOS_USD_STATE_LAYER_FORMAT)}`,
315
+ ` string "nomos:domain" = ${usdaString(domain)}`,
316
+ ` string "nomos:timeCode" = "chain index (intent position on main)"`,
317
+ ` }`,
318
+ ` defaultPrim = "NomosState"`,
319
+ ` startTimeCode = 0`,
320
+ ` endTimeCode = ${frames.length - 1}`,
321
+ ` timeCodesPerSecond = 1`,
322
+ ` framesPerSecond = 1`,
323
+ ` metersPerUnit = 1`,
324
+ ` upAxis = "Z"`,
325
+ `)`,
326
+ ``,
327
+ `def Scope "NomosState"`,
328
+ `{`,
329
+ ` def Scope ${usdaString(primName(domain))}`,
330
+ ` {`,
331
+ ];
332
+ const ind2 = " ";
333
+ const ind3 = " ";
334
+ const ind4 = " ";
335
+
336
+ // THE SCRUB CAMERA — a fixed, deterministic top-down orthographic plan view over
337
+ // the union extent of every placement (spatial samples or seeded layout), so
338
+ // usdrecord/usdview frames are COMPARABLE across timeCodes (auto-framing per frame
339
+ // would re-zoom and hide the motion). Pure function of the same inputs as the rest.
340
+ {
341
+ const pts: [number, number, number][] = [];
342
+ for (const track of tracks.values()) {
343
+ if (opts.spatial !== undefined) pts.push(...track.positions.map((p) => p.xyz));
344
+ else pts.push(seededLayout(track.id, rankOf.get(keyOf(track.type, track.id))!, order.length));
345
+ }
346
+ if (pts.length > 0) {
347
+ const min = [0, 1, 2].map((i) => Math.min(...pts.map((p) => p[i]!)));
348
+ const max = [0, 1, 2].map((i) => Math.max(...pts.map((p) => p[i]!)));
349
+ const extent = Math.max(max[0]! - min[0]!, max[1]! - min[1]!) + 4;
350
+ const cx = (min[0]! + max[0]!) / 2;
351
+ const cy = (min[1]! + max[1]!) / 2;
352
+ const h = max[2]! + extent;
353
+ lines.push(
354
+ `${ind2}def Camera "ScrubCam"`,
355
+ `${ind2}{`,
356
+ `${ind3}token projection = "orthographic"`,
357
+ `${ind3}float horizontalAperture = ${num(extent * 10)}`,
358
+ `${ind3}float verticalAperture = ${num(extent * 10)}`,
359
+ `${ind3}float2 clippingRange = (0.1, ${num(h + extent + 10)})`,
360
+ `${ind3}double3 xformOp:translate = (${num(cx)}, ${num(cy)}, ${num(h)})`,
361
+ `${ind3}uniform token[] xformOpOrder = ["xformOp:translate"]`,
362
+ `${ind2}}`,
363
+ ``,
364
+ );
365
+ }
366
+ }
367
+
368
+ // The chain legend prim: what happened at each frame.
369
+ lines.push(
370
+ `${ind2}def Scope "Chain"`,
371
+ `${ind2}{`,
372
+ `${ind3}custom string nomos:chain:directive.timeSamples = {`,
373
+ ...frames.map((f) => `${ind4}${f.t}: ${usdaString(f.directive)},`),
374
+ `${ind3}}`,
375
+ `${ind3}custom string nomos:chain:intent.timeSamples = {`,
376
+ ...frames.map((f) => `${ind4}${f.t}: ${usdaString(f.intentId)},`),
377
+ `${ind3}}`,
378
+ `${ind2}}`,
379
+ ``,
380
+ );
381
+
382
+ // Instances grouped per aggregate type, both levels sorted.
383
+ const byType = new Map<string, InstanceTrack[]>();
384
+ for (const track of tracks.values()) {
385
+ const list = byType.get(track.type) ?? [];
386
+ if (list.length === 0) byType.set(track.type, list);
387
+ list.push(track);
388
+ }
389
+ for (const type of [...byType.keys()].sort()) {
390
+ lines.push(`${ind2}def Scope ${usdaString(primName(type))}`, `${ind2}{`);
391
+ const instances = byType.get(type)!.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
392
+ for (const inst of instances) {
393
+ const key = keyOf(inst.type, inst.id);
394
+ lines.push(`${ind3}def Xform ${usdaString(primName(inst.id))}`, `${ind3}{`);
395
+ // Birth: invisible before the first fold, inherited from it.
396
+ if (inst.firstSeen > 0) {
397
+ lines.push(
398
+ `${ind4}token visibility.timeSamples = {`,
399
+ `${ind4} 0: "invisible",`,
400
+ `${ind4} ${inst.firstSeen}: "inherited",`,
401
+ `${ind4}}`,
402
+ );
403
+ }
404
+ // Placement: spatial samples from the folds, or the seeded static layout.
405
+ if (opts.spatial !== undefined) {
406
+ lines.push(
407
+ `${ind4}double3 xformOp:translate.timeSamples = {`,
408
+ ...inst.positions.map((p) => `${ind4} ${p.t}: (${p.xyz.map(num).join(", ")}),`),
409
+ `${ind4}}`,
410
+ );
411
+ } else {
412
+ const [x, y, zz] = seededLayout(inst.id, rankOf.get(key)!, order.length);
413
+ lines.push(`${ind4}double3 xformOp:translate = (${num(x)}, ${num(y)}, ${num(zz)})`);
414
+ }
415
+ lines.push(
416
+ `${ind4}uniform token[] xformOpOrder = ["xformOp:translate"]`,
417
+ `${ind4}custom string nomos:type = ${usdaString(inst.type)}`,
418
+ `${ind4}custom string nomos:id = ${usdaString(inst.id)}`,
419
+ );
420
+ // Folded fields, sample-on-change (held interpolation = the fold's persistence).
421
+ for (const field of [...inst.fields.keys()].sort()) {
422
+ const { decl, samples } = inst.fields.get(field)!;
423
+ lines.push(
424
+ `${ind4}custom ${decl} nomos:state:${field}.timeSamples = {`,
425
+ ...samples.map((s) => `${ind4} ${s.t}: ${s.value},`),
426
+ `${ind4}}`,
427
+ );
428
+ }
429
+ // The gprim (synthetic for the abstract tier — usdview needs SOMETHING to draw;
430
+ // the spatial tier's TRANSFORM is the domain's truth, the gprim is still a marker).
431
+ const [r, g, b] = seededColor(inst.id);
432
+ if (opts.spatial !== undefined) {
433
+ lines.push(
434
+ `${ind4}def Cube "geo"`,
435
+ `${ind4}{`,
436
+ `${ind4} double size = 1`,
437
+ `${ind4} color3f[] primvars:displayColor = [(${num(r)}, ${num(g)}, ${num(b)})]`,
438
+ `${ind4}}`,
439
+ );
440
+ } else {
441
+ lines.push(
442
+ `${ind4}def Sphere "geo"`,
443
+ `${ind4}{`,
444
+ `${ind4} double radius = 0.6`,
445
+ `${ind4} color3f[] primvars:displayColor = [(${num(r)}, ${num(g)}, ${num(b)})]`,
446
+ `${ind4}}`,
447
+ );
448
+ }
449
+ lines.push(`${ind3}}`, ``);
450
+ }
451
+ if (lines[lines.length - 1] === "") lines.pop();
452
+ lines.push(`${ind2}}`, ``);
453
+ }
454
+ if (lines[lines.length - 1] === "") lines.pop();
455
+ lines.push(` }`, `}`, ``);
456
+ return lines.join("\n");
457
+ }
458
+
459
+ /**
460
+ * THE SCRUB STAGE root: state stacked OVER the law layers. `subLayers` is
461
+ * STRONGEST-FIRST (USD layer-stack order) — the state layer leads, then the law
462
+ * layers in Nomos module order REVERSED (the spike-1 correspondence: the module
463
+ * list composes weak→strong, the sublayer list reads strong→weak). Open this one
464
+ * file in usdview/usdcat and the WHOLE picture composes: what may happen
465
+ * (`/Nomos/<domain>`) and what has happened (`/NomosState/<domain>`), on one
466
+ * scrubbable timeline.
467
+ */
468
+ export function stateScrubStageUsda(opts: {
469
+ readonly domain: string;
470
+ readonly stateLayerRelPath: string;
471
+ readonly lawLayerRelPathsWeakToStrong: readonly string[];
472
+ readonly endTimeCode: number;
473
+ }): string {
474
+ if (opts.lawLayerRelPathsWeakToStrong.length === 0) {
475
+ fail(
476
+ `scrub stage for '${opts.domain}' has no law layers — emit them first ` +
477
+ `(nomos-compile --layered, spike 1) so state has law to compose over`,
478
+ );
479
+ }
480
+ const strongestFirst = [opts.stateLayerRelPath, ...[...opts.lawLayerRelPathsWeakToStrong].reverse()];
481
+ return [
482
+ `#usda 1.0`,
483
+ `(`,
484
+ ` doc = """Nomos SCRUB STAGE — the state layer (what has happened: the projection`,
485
+ `as time samples, timeCode = chain index) composed OVER the law layers (what may`,
486
+ `happen: aggregates, merge drivers, directives — spike 1). subLayers are`,
487
+ `STRONGEST-FIRST; scrubbing the timeline replays the chain."""`,
488
+ ` customLayerData = {`,
489
+ ` string "nomos:format" = ${usdaString(NOMOS_USD_SCRUB_STAGE_FORMAT)}`,
490
+ ` string "nomos:domain" = ${usdaString(opts.domain)}`,
491
+ ` }`,
492
+ ` defaultPrim = "NomosState"`,
493
+ ` startTimeCode = 0`,
494
+ ` endTimeCode = ${opts.endTimeCode}`,
495
+ ` timeCodesPerSecond = 1`,
496
+ ` framesPerSecond = 1`,
497
+ ` metersPerUnit = 1`,
498
+ ` upAxis = "Z"`,
499
+ ` subLayers = [`,
500
+ ...strongestFirst.map((p, i) => ` @${p}@${i < strongestFirst.length - 1 ? "," : ""}`),
501
+ ` ]`,
502
+ `)`,
503
+ ``,
504
+ ].join("\n");
505
+ }