@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.1

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 (217) hide show
  1. package/CLAUDE.md +87 -0
  2. package/README.md +84 -2
  3. package/dist/dist-manifest.json +218 -0
  4. package/dist/eyes/index.js +109 -0
  5. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  6. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  7. package/dist/fat/pkg-node/package.json +12 -0
  8. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  9. package/dist/frqtl/compat/boot-node.js +34 -0
  10. package/dist/frqtl/compat/engine.js +127 -0
  11. package/dist/frqtl/compat/package-root.js +24 -0
  12. package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
  13. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  14. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  15. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  16. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  17. package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
  18. package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
  19. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  20. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  21. package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
  22. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  23. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  24. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  25. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  26. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  27. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  28. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  30. package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
  31. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  32. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  33. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  34. package/dist/frqtl/compat/vendor.js +196 -0
  35. package/dist/frqtl/node-entry.js +17 -0
  36. package/dist/frqtl/objects/FrqtlApp.js +2922 -0
  37. package/dist/frqtl/objects/ObjectFactory.js +297 -0
  38. package/dist/frqtl/objects/RecipeContext.js +322 -0
  39. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  40. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  41. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  42. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  43. package/dist/frqtl/objects/instruments/capture.js +711 -0
  44. package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
  45. package/dist/frqtl/objects/kinds-entry.js +14 -0
  46. package/dist/frqtl/objects/kinds.js +190 -0
  47. package/dist/frqtl/objects/objects/Box.js +273 -0
  48. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  49. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  50. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  51. package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
  52. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  53. package/dist/frqtl/objects/objects/LightSource.js +354 -0
  54. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  55. package/dist/frqtl/objects/objects/Tube.js +471 -0
  56. package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
  57. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  58. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  59. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  60. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  61. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  62. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  66. package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
  67. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  68. package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
  69. package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
  70. package/dist/frqtl/objects/objects/tagger.js +228 -0
  71. package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
  72. package/dist/frqtl/objects/schema/index.js +256 -0
  73. package/dist/frqtl/objects/schema/scopes.js +457 -0
  74. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  75. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  76. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  77. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  78. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  79. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  80. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  81. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  82. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
  83. package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  85. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  86. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  87. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  88. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  89. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  90. package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
  91. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  92. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  94. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
  95. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  96. package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
  97. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  98. package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
  99. package/dist/frqtl/objects/v1-replacements.js +30 -0
  100. package/dist/frqtl/package-core.js +23 -0
  101. package/dist/frqtl/parts/CameraControls.js +643 -0
  102. package/dist/frqtl/parts/CameraPath.js +575 -0
  103. package/dist/frqtl/parts/CollisionActions.js +35 -0
  104. package/dist/frqtl/parts/CollisionTypes.js +31 -0
  105. package/dist/frqtl/parts/DetectorWall.js +496 -0
  106. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  107. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  108. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  109. package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
  110. package/dist/frqtl/parts/LargeObject.js +406 -0
  111. package/dist/frqtl/parts/LightColor.js +255 -0
  112. package/dist/frqtl/parts/LiveChart.js +285 -0
  113. package/dist/frqtl/parts/LogicGate.js +720 -0
  114. package/dist/frqtl/parts/OvenBox.js +612 -0
  115. package/dist/frqtl/parts/PointSource.js +763 -0
  116. package/dist/frqtl/parts/Rectangle.js +200 -0
  117. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  118. package/dist/frqtl/parts/SlitScreen.js +628 -0
  119. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  120. package/dist/frqtl/parts/Trace.js +508 -0
  121. package/dist/frqtl/parts/Tunnel.js +1057 -0
  122. package/dist/frqtl/parts/Wall.js +1211 -0
  123. package/dist/frqtl/parts/index.js +33 -0
  124. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  126. package/dist/frqtl/scenes/atomic-model.js +88 -0
  127. package/dist/frqtl/scenes/big-bang.js +46 -0
  128. package/dist/frqtl/scenes/blackbody.js +81 -0
  129. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  130. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  131. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  132. package/dist/frqtl/scenes/counting-wall.js +38 -0
  133. package/dist/frqtl/scenes/double-slit.js +31 -0
  134. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  135. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  136. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  137. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
  138. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  139. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  140. package/dist/frqtl/scenes/empty.js +14 -0
  141. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  142. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  143. package/dist/frqtl/scenes/mirror-route.js +140 -0
  144. package/dist/frqtl/scenes/mirror.js +38 -0
  145. package/dist/frqtl/scenes/omni-3d.js +27 -0
  146. package/dist/frqtl/scenes/omni-source.js +22 -0
  147. package/dist/frqtl/scenes/self-interference.js +39 -0
  148. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  149. package/dist/frqtl/scenes/straight-walk.js +22 -0
  150. package/dist/frqtl/scenes/sun.js +39 -0
  151. package/dist/frqtl/scenes/two-source.js +37 -0
  152. package/dist/frqtl/scenes/wave.js +26 -0
  153. package/dist/shim/bundle/frqnb.js +112 -0
  154. package/dist/shim/bundle/index.js +121 -0
  155. package/dist/shim/bundle/indexHtml.js +107 -0
  156. package/dist/shim/bundle/manifest.js +95 -0
  157. package/dist/shim/bundle/zip.js +243 -0
  158. package/dist/shim/bus.js +67 -0
  159. package/dist/shim/controls/index.js +627 -0
  160. package/dist/shim/controls/registry.js +274 -0
  161. package/dist/shim/engine/artifact-shell.js +212 -0
  162. package/dist/shim/engine/boot-phases.js +44 -0
  163. package/dist/shim/engine/engine-stack-boot.js +59 -0
  164. package/dist/shim/engine/router.js +692 -0
  165. package/dist/shim/experiment-frame/index.js +296 -0
  166. package/dist/shim/expr/ast-schema.js +369 -0
  167. package/dist/shim/field-descriptor/index.js +326 -0
  168. package/dist/shim/frame-data/index.js +224 -0
  169. package/dist/shim/index.js +67 -0
  170. package/dist/shim/notebook/canvas-guard.js +82 -0
  171. package/dist/shim/notebook/catalog.js +35 -0
  172. package/dist/shim/notebook/format.js +736 -0
  173. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  174. package/dist/shim/notebook/kernel.js +672 -0
  175. package/dist/shim/notebook/knob.js +151 -0
  176. package/dist/shim/notebook/module-resolver.js +260 -0
  177. package/dist/shim/notebook/notebook-knobs.js +65 -0
  178. package/dist/shim/notebook/produced-bindings.js +67 -0
  179. package/dist/shim/notebook/realm-registry.js +121 -0
  180. package/dist/shim/notebook/realm-transport.js +73 -0
  181. package/dist/shim/notebook/setup-builder.js +510 -0
  182. package/dist/shim/notebook/store.js +159 -0
  183. package/dist/shim/notebook/vendor-importmap.js +134 -0
  184. package/dist/shim/notebook/view-builder.js +189 -0
  185. package/dist/shim/notebook/view-ctx.js +100 -0
  186. package/dist/shim/notebook/view-handle.js +202 -0
  187. package/dist/shim/notebook/warmup.js +102 -0
  188. package/dist/shim/package.json +4 -0
  189. package/dist/shim/page-params/index.js +214 -0
  190. package/dist/shim/project/identity.js +112 -0
  191. package/dist/shim/project/index.js +19 -0
  192. package/dist/shim/project/link.js +402 -0
  193. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  194. package/dist/shim/project/register-node.mjs +17 -0
  195. package/dist/shim/project/specifiers.js +106 -0
  196. package/dist/shim/registry/copy-gate.js +110 -0
  197. package/dist/shim/registry/exhibits.js +455 -0
  198. package/dist/shim/registry/experiments.js +215 -0
  199. package/dist/shim/registry/index.js +19 -0
  200. package/dist/shim/registry/scene-source.js +157 -0
  201. package/dist/shim/registry/walkthrough.js +807 -0
  202. package/dist/shim/render/capture-envelope.js +279 -0
  203. package/dist/shim/render/heat-colormap.js +92 -0
  204. package/dist/shim/render/render-stages.js +855 -0
  205. package/dist/shim/render/sun-look.js +33 -0
  206. package/dist/shim/render-state/apply.js +262 -0
  207. package/dist/shim/render-state/index.js +74 -0
  208. package/dist/shim/rpc/index.js +207 -0
  209. package/dist/shim/runtime/index.js +1023 -0
  210. package/dist/shim/storage/boot.js +69 -0
  211. package/dist/shim/storage/index.js +169 -0
  212. package/dist/shim/storage/local-storage-provider.js +230 -0
  213. package/dist/shim/storage/make-namespace-store.js +132 -0
  214. package/dist/shim/storage/modules.js +249 -0
  215. package/dist/shim/storage/projects.js +150 -0
  216. package/llms.txt +12 -0
  217. package/package.json +62 -4
@@ -0,0 +1,855 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * lib/shim/render/render-stages — THE render-pipeline stage vocabulary contract.
5
+ *
6
+ * Peer of `lib/shim/frame-data` (`RENDER_INTENT_KEYS` — the per-frame render-intent
7
+ * vocabulary) and `lib/shim/expr/ast-schema` (`NODE_KINDS`/`OP_KINDS` — the op-program
8
+ * vocabulary): the shim owns every cross-layer contract vocabulary, and consumers FERRY
9
+ * the exported list rather than re-enumerating it (the schema-mirror rule). Named ES-module
10
+ * exports, imported by the game layer (`FrqtlApp.js`, the capture instrument) and by
11
+ * consumers above the shim — the `heat-colormap.js` precedent, same directory.
12
+ *
13
+ * ── What this contract IS (and deliberately is NOT) ──────────────────────────
14
+ *
15
+ * The render pipeline already EXISTS as an ordered sequence of transforms, and each of
16
+ * its transform-registration surfaces already exists too:
17
+ *
18
+ * - `scene` appearance registers as the per-provider `renderCapabilities()`
19
+ * descriptor knobs (R4/R5 effect layers).
20
+ * - the scene→composite segment registers as the ORDERED `filterSpec` queue
21
+ * (`Filters.js` catalog + `renderer.filter({name, glsl, uniforms})`).
22
+ * - `field` registers as ONE authored canonical module (`FieldBuilder`).
23
+ * - `tick` is the frame-data bubble — the engine-opaque `string_map` render intent.
24
+ *
25
+ * This module adds **ZERO new registration mechanisms**. It contributes exactly three
26
+ * things those mechanisms never had: a NAME for the pipeline point each attaches to, a
27
+ * way to READ the data at a named point (a tap), and PROVENANCE on what a tap returned.
28
+ * If a change to this file starts to look like a `registerStage()` / `onStage(cb)` /
29
+ * transform-plugin registry, that is the failure mode this contract exists to prevent —
30
+ * a new render transform lands as registered data on its stage's EXISTING surface, never
31
+ * as bespoke provider code and never as a new uber-registry
32
+ * (docs/internal/engine/RENDERER_CONTRACT.md §9, the standing veto).
33
+ *
34
+ * ── Layer 1: API contract ───────────────────────────────────────────────────
35
+ *
36
+ * RENDER_STAGES — the ordered, named, COMPLETE canonical stage list.
37
+ * STAGE_FORMATS — stage → payload format tag ('framedata' | 'r8' | 'rgba8').
38
+ * finalStage() — the LAST stage. `runner.captureStill()` derives its stage
39
+ * from this, never from a hardcoded 'composite' literal.
40
+ * upstreamStages(stage) — the ordered stage names strictly BEFORE `stage`.
41
+ * assertStage(stage) — throw-or-return; the message lists the whole table.
42
+ * stageNotTappableError(stage, who) — the DISTINCT error for a stage that is a real
43
+ * member of the table but has no tap executor on the active
44
+ * provider (v1: `scene`). Never conflated with unknown-stage.
45
+ * SCHEMA_VERSION — the `v` stamped on BOTH records (R1).
46
+ * SCENE_KEYS — the RUN-INVARIANT record: the regeneration input.
47
+ * STILL_KEYS — the SERIALIZED still record: coordinates within a scene.
48
+ * STILL_DERIVED_KEYS — in-memory conveniences that are NOT serialized (composedOf).
49
+ * STILL_BAG_KEYS — the full IN-MEMORY still bag = STILL_KEYS + derived.
50
+ * STILL_PROVENANCE_KEYS — the provenance subset, DERIVED (bag keys minus payload keys).
51
+ * RUNTIME_MUTATION_SURFACES — the mutability line, declared: which APIs touch which
52
+ * still fields. The drift gate runs off this.
53
+ * deepPlainCopy(value) — THE plain-data deep-copy authority (payload + scene record).
54
+ * normalizeScene(partial)— ferry a partial scene bag to the full SceneRecord, FAIL-LOUD.
55
+ * canonicalizeScene(rec) — the ONE canonical byte-string a scene identity is taken over.
56
+ * sceneHash(rec) — `Promise<'sha256:<hex>'>`; the content address.
57
+ * normalizeStill(partial)— ferry a partial bag to the full canonical shape, FAIL-LOUD.
58
+ * SCENE_SURFACE_ATTR — the scene-surface marker attribute, RE-EXPORTED from its owner
59
+ * (compat/renderer/renderer-provider-contract.js).
60
+ *
61
+ * ── The stage table ─────────────────────────────────────────────────────────
62
+ *
63
+ * tick TickData.frameData — pure physics data, format 'framedata'. The frame-data
64
+ * bubble IS stage 0 of this pipeline: the engine-boundary law ("a new
65
+ * behavior is op-program DATA, never engine code") and the render-stack law
66
+ * ("a new transform is registered data on its stage's surface") are the same
67
+ * law at two altitudes, and this stage is where they meet.
68
+ * field the `FieldBuilder` heightfield — format 'r8', physics-pixel space,
69
+ * y-down/top-left, at field resolution. Backend-INDEPENDENT by construction
70
+ * (the one field source), so its tap is runner-side and every BYO provider
71
+ * gets it for free.
72
+ * scene the frozen three-layer composite (backdrop → particle → large-object,
73
+ * RENDERER_CONTRACT §8) BEFORE the filter passes — format 'rgba8'. NAMED
74
+ * here because the pipeline genuinely has this point; its tap EXECUTOR is
75
+ * deferred to its first caller (v1 ships no `scene` tap → the distinct
76
+ * `stageNotTappableError`). "Capture without the water shader" composes the
77
+ * existing knobs instead: setFilterActive(name,false) → repaint() → capture.
78
+ * composite after the ordered filter passes — format 'rgba8'. THE final stage, tapped
79
+ * by the provider's OPTIONAL `captureStill()` (RENDERER_CONTRACT §1a.4).
80
+ *
81
+ * ── Provenance is load-bearing, not decoration ───────────────────────────────
82
+ *
83
+ * The tap consumers are VLA training and the JSONL finetuning corpus. A pre-render tap
84
+ * that is INDISTINGUISHABLE from a post-bloom tap is not a missing feature — it is a
85
+ * silently corrupted training corpus that teaches the wrong mapping from code to
86
+ * picture. So every bag states which stage produced it AND what was active:
87
+ *
88
+ * v the schema version (stamped by the normalizer)
89
+ * scene the content hash of the SceneRecord this (stamped by the dispatcher)
90
+ * still is coordinates within
91
+ * stage the stage name from RENDER_STAGES (stamped by the dispatcher)
92
+ * tick the tick the channel derives from (stamped by the dispatcher)
93
+ * format STAGE_FORMATS[stage] (stage implementation)
94
+ * width, height payload buffer dims (stage implementation)
95
+ * cssWidth/cssHeight the canvas CSS size at capture, or null (stage implementation)
96
+ * fieldOpts the FieldBuilder options the field tap (stage implementation)
97
+ * ran with; null on every other stage
98
+ * filtersApplied ORDERED filter names actually run, or [] (the PROVIDER — its own
99
+ * `_filters` list is the one ordered authority; NEVER derived from the
100
+ * runner's unordered `_activeFilters` map, which is also blind to a
101
+ * direct BYO `prov.filter()` call and would therefore lie for exactly
102
+ * the BYO case)
103
+ * renderBaseCanvas the boolean in effect at composite, or null on a stage where
104
+ * nothing composited (the provider)
105
+ * filterTime the `uTime` the filter passes drew with (simulation seconds at the
106
+ * rendered tick, RendererProviderContract.filterTime), or null when no
107
+ * filter ran or the stage composited nothing (the provider)
108
+ * pose `cameraControl.getOrbit()` (the 2D bag is {distance, target}), or null
109
+ * with no camera pose control at all (the dispatcher, via the
110
+ * ONE camera authority — a derived read, no new machinery; without it
111
+ * a 3D composite cannot be co-registered to the field)
112
+ * composedOf upstreamStages(stage) — the ordered upstream stage names, DERIVED
113
+ * from this table. STRUCTURAL: it says what precedes this stage in the
114
+ * canonical pipeline, which is what "the full stack of transforms
115
+ * composed" means at the API level. It is NOT a per-capture run
116
+ * record — `filtersApplied` (ordered, from the provider) and
117
+ * `renderOptions` are the run record.
118
+ * renderOptions the live value of each key the active provider DECLARES via
119
+ * `renderCapabilities()`, driven off that declared list so a newly
120
+ * declared R4/R5 knob appears in provenance with zero capture-code
121
+ * edits. `filter.*` capability keys are deliberately ABSENT here:
122
+ * their authority is `filtersApplied`, and a second filter read would
123
+ * be the two-sources bug. (Stamped by the dispatcher.)
124
+ *
125
+ * The RUN-INVARIANT provenance — `seed`, `sourceRef`, `build`, `physicsW/H`, `projection` —
126
+ * does NOT ride each still. It lives ONCE on the SceneRecord (`SCENE_KEYS`), which the still
127
+ * references by content hash. 3D scenes are high bandwidth; repeating their header on every
128
+ * capture is the cost this split removes.
129
+ *
130
+ * NO provenance field is ever caller-supplied — a caller-supplied provenance field is the
131
+ * hardcoded-backend-label anti-pattern (dep-map rule 4). `normalizeStill`/`normalizeScene`
132
+ * ENFORCE this for the derived fields: a `composedOf` or a `v` that disagrees THROWS.
133
+ *
134
+ * ── Channel semantics (what the numbers MEAN) ─────────────────────────────────
135
+ *
136
+ * framedata — the frame-data bubble's OWN published contract; `FrameDataSchema` is its
137
+ * owner and this contract points AT it and never restates its fields.
138
+ * r8 — gain-scaled, clamped [0,255], optionally blurred soft-splat fullness
139
+ * density: a dimensionless INSTRUMENT RESPONSE, NOT calibrated mass, and
140
+ * comparable only between captures at equal `fieldOpts`.
141
+ * rgba8 — row-major from the TOP-LEFT origin, colour space **sRGB**, alpha
142
+ * **NON-premultiplied**. An encoder cannot guess either, so both are stated:
143
+ * three initialises `alpha:false` (opaque, a=255) and p5's 2D `getImageData`
144
+ * is unpremultiplied by spec.
145
+ *
146
+ * ── Layer 2: physics behaviour ──────────────────────────────────────────────
147
+ *
148
+ * ZERO physics. This is an output-boundary vocabulary: it names points in the render
149
+ * pipeline and describes what came out of them. Nothing here reads, writes, or gates a
150
+ * frame, a dimension, or a tick — a change to this file cannot move a canonical-bench
151
+ * golden, and if one moves, physics leaked into the convenience layer.
152
+ *
153
+ * Usage:
154
+ * import { RENDER_STAGES, finalStage, normalizeStill } from 'lib/shim/render/render-stages.js';
155
+ */
156
+
157
+
158
+
159
+ /**
160
+ * RENDER_STAGES — the ordered, named, COMPLETE canonical render-pipeline stage list.
161
+ *
162
+ * Ordered EARLIEST → LATEST along the per-tick data flow. Consumers (the tap MECHANISM
163
+ * `objects/instruments/capture.js`, the conformance gate, any host UI) FERRY this array;
164
+ * a hand-written stage list anywhere else is the schema-mirror bug this ownership
165
+ * prevents, and a stage added here appears in every consumer with zero consumer edits.
166
+ *
167
+ * To add a stage: add it HERE in pipeline position, add its STAGE_FORMATS entry, and add
168
+ * its tap executor. Nothing else changes — `finalStage()`, `upstreamStages()`,
169
+ * `composedOf`, the conformance table and `runner.captureStill()` all re-derive.
170
+ *
171
+ * @type {string[]}
172
+ */
173
+ var RENDER_STAGES = ['tick', 'field', 'scene', 'composite'];
174
+
175
+ /**
176
+ * STAGE_FORMATS — stage name → payload format tag.
177
+ *
178
+ * 'framedata' → `data` is the Array of FrameData objects (pure physics data)
179
+ * 'r8' → `data` is a Uint8Array, one byte per texel (the heightfield)
180
+ * 'rgba8' → `data` is a Uint8ClampedArray, 4 bytes per pixel, ROW-MAJOR from the
181
+ * TOP-LEFT origin (a provider reading a bottom-left WebGL framebuffer
182
+ * flips the rows itself — callers never see backend row order)
183
+ *
184
+ * @type {Object<string,string>}
185
+ */
186
+ var STAGE_FORMATS = {
187
+ tick: 'framedata',
188
+ field: 'r8',
189
+ scene: 'rgba8',
190
+ composite: 'rgba8'
191
+ };
192
+
193
+ /**
194
+ * SCHEMA_VERSION — the `v` stamped on BOTH records (R1).
195
+ *
196
+ * The normalizer IS the version authority: a record's `v` is the contract version that
197
+ * normalized it, and a caller-supplied `v` that disagrees THROWS (same discipline as
198
+ * `composedOf`). Bump on ANY semantic change to `SCENE_KEYS`/`STILL_KEYS`/`STAGE_FORMATS`.
199
+ * Corpus tooling refuses to mix versions silently — which is the whole point: without `v`, a
200
+ * regenerated corpus cannot be safely mixed across generations, and the mixing is invisible.
201
+ * @type {number}
202
+ */
203
+ var SCHEMA_VERSION = 4;
204
+
205
+ /**
206
+ * SCENE_KEYS — the RUN-INVARIANT record. The regeneration input, hashed into an identity.
207
+ *
208
+ * The video-container shape: ONE scene record (the header) + many light still records (the
209
+ * frames of that video). A 3D corpus stores one scene plus N tiny stills instead of
210
+ * repeating the whole header on every capture.
211
+ *
212
+ * ── The line is drawn by MUTABILITY, not by category (the trap) ──
213
+ * ONLY what CANNOT change within a run is hoisted here. Several fields LOOK scene-level and
214
+ * are not: `filtersApplied` (a mid-run `setFilterActive('water', false)` IS the ring
215
+ * measurement control), `pose` (a camera path changes it every tick), `cssWidth`/`cssHeight`
216
+ * (viewport resize). Hoisting any one of them would mislabel EVERY subsequent still —
217
+ * corpus corruption arriving through the back door of a bandwidth optimisation. The
218
+ * `RUNTIME_MUTATION_SURFACES` table below makes that a CI failure instead of a discovery at
219
+ * training time.
220
+ *
221
+ * v — schema version (on BOTH records: they are encountered separately and must
222
+ * self-describe; 4 bytes is not the bandwidth problem)
223
+ * seed — the RNG seed. Fixed at universe init. Two of the regeneration triple
224
+ * (code, seed, tick) were previously absent — this is one of them.
225
+ * sourceRef — experiment/rung identity, registered ONCE at setup (nullable). A legitimate
226
+ * authority set at attach time, NOT per-capture caller-supplied provenance.
227
+ * `null` is HONEST for an ad-hoc universe — the corpus row then carries the
228
+ * code itself. The two corpus modes live at the harness, not in the record.
229
+ * sourceSha256 — the CODE half of the regeneration triple, by content: the project identity
230
+ * (lib/shim/project/identity.js projectIdentity — lowercase hex sha256 over the booted
231
+ * project's files), registered ONCE by the door that linked the project (nullable: a
232
+ * sketch booted as a FUNCTION has no text to hash, and says so).
233
+ * build — the runtime build pin, DERIVED from the boot authorities, never a literal.
234
+ * The package is pinned by CONTENT (`wasmSha256`), never by the URL that
235
+ * served it: an origin is a deployment fact, not a regeneration input.
236
+ * physicsW/H — universe bounds, init-fixed. (NOTE: the CANVAS may be resized mid-run; the
237
+ * still's own `width`/`height` carry that. These are the physics bounds.)
238
+ * projection — camera intrinsics, provider-stamped. Scene-level because the DISPLAY
239
+ * provider is boot-fixed and OBSERVERS are live (RENDERER_CONTRACT §9): the
240
+ * one display provider owns canvas/camera/projection and swapping it is a
241
+ * reload; observers own no projection, so scene identity never moves mid-run.
242
+ * Without it a 3D pixel→world mapping is UNDERDETERMINED offline.
243
+ *
244
+ * @type {string[]}
245
+ */
246
+ var SCENE_KEYS = ['v', 'seed', 'sourceRef', 'sourceSha256', 'build', 'physicsW', 'physicsH', 'projection'];
247
+
248
+ /**
249
+ * SCENE_BOUND_KEYS — the `SCENE_KEYS` members that are physics BOUNDS (positive integers, written
250
+ * by the scene's co-init call). A host that reads them back from a page, and checks a caller's
251
+ * stated ones as an expectation, iterates this list rather than naming the fields itself.
252
+ *
253
+ * @type {string[]}
254
+ */
255
+ var SCENE_BOUND_KEYS = ['physicsW', 'physicsH'];
256
+
257
+ /**
258
+ * STILL_KEYS — the SERIALIZED still record: coordinates WITHIN a scene, plus the payload and
259
+ * everything that can vary per capture. `scene` is the content hash of the SceneRecord this
260
+ * still belongs to, so any drift between what a still claims and what the scene was is
261
+ * DETECTABLE (recompute the hash), never silent.
262
+ * @type {string[]}
263
+ */
264
+ var STILL_KEYS = [
265
+ 'v', 'scene', 'stage', 'format', 'tick',
266
+ 'data', 'width', 'height', 'cssWidth', 'cssHeight',
267
+ 'pose', 'filtersApplied', 'renderBaseCanvas', 'filterTime', 'renderOptions', 'fieldOpts'
268
+ ];
269
+
270
+ /**
271
+ * STILL_DERIVED_KEYS — present on the IN-MEMORY bag as a convenience, deliberately NOT in
272
+ * `STILL_KEYS`: `composedOf` is derived from (`stage`, the `v`-versioned table), so a
273
+ * consumer re-derives it. Serializing a derivable is stored drift.
274
+ * @type {string[]}
275
+ */
276
+ var STILL_DERIVED_KEYS = ['composedOf'];
277
+
278
+ /**
279
+ * The full IN-MEMORY still bag shape = serialized keys + derived conveniences.
280
+ * `normalizeStill` guarantees every one of these is PRESENT (a value may legitimately be
281
+ * `null`), so no consumer tests for key existence and no gate re-types the list.
282
+ * @type {string[]}
283
+ */
284
+ var STILL_BAG_KEYS = STILL_KEYS.concat(STILL_DERIVED_KEYS);
285
+
286
+ /** The payload/geometry keys — everything else on a still is provenance. @type {string[]} */
287
+ var STILL_PAYLOAD_KEYS = ['data', 'width', 'height'];
288
+
289
+ /**
290
+ * The PROVENANCE subset of the still bag — DERIVED from the two lists above rather than
291
+ * hand-listed, so a new still field lands in provenance automatically.
292
+ * @type {string[]}
293
+ */
294
+ var STILL_PROVENANCE_KEYS = STILL_BAG_KEYS.filter(function (k) {
295
+ return STILL_PAYLOAD_KEYS.indexOf(k) === -1;
296
+ });
297
+
298
+ /**
299
+ * RUNTIME_MUTATION_SURFACES — THE MUTABILITY LINE, DECLARED.
300
+ *
301
+ * Every API that can change what a capture reports, mapped to the still fields it touches.
302
+ * The conformance gate asserts each listed field is in `STILL_KEYS` and in NO case in
303
+ * `SCENE_KEYS` — so a future attempt to hoist a toggleable field into the scene header
304
+ * fails a test instead of quietly rotting a corpus. Declared HERE (not in the test) so the
305
+ * line is self-documenting at the contract and a new mutation surface is added once.
306
+ * @type {Object<string,string[]>}
307
+ */
308
+ var RUNTIME_MUTATION_SURFACES = {
309
+ 'cameraControl.setOrbit/look/move/setPreset': ['pose'],
310
+ 'setFilterActive / provider.filter / removeFilter': ['filtersApplied', 'filterTime'],
311
+ 'setRenderOption': ['renderOptions', 'renderBaseCanvas'],
312
+ 'canvas resize (setSize)': ['width', 'height', 'cssWidth', 'cssHeight'],
313
+ 'FieldBuilder options at the field tap': ['fieldOpts']
314
+ };
315
+
316
+ /** The rgba8 payload constructor name, for the format check below. */
317
+ function _isStageKnown(stage) { return RENDER_STAGES.indexOf(stage) !== -1; }
318
+
319
+ /**
320
+ * finalStage() — the LAST stage of the canonical pipeline.
321
+ *
322
+ * `runner.captureStill()` (the convenience) resolves its stage THROUGH this function, so
323
+ * "capture the fully composed stack" follows the table by construction: if the pipeline
324
+ * ever grows a later stage, the convenience follows it with no edit. A hardcoded
325
+ * `'composite'` literal in a convenience would be truth-in-labeling drift at the API
326
+ * level — the API would claim "final" while naming a stage that is no longer last.
327
+ *
328
+ * @returns {string}
329
+ */
330
+ function finalStage() { return RENDER_STAGES[RENDER_STAGES.length - 1]; }
331
+
332
+ /**
333
+ * upstreamStages(stage) — the ordered stage names strictly BEFORE `stage` in the table.
334
+ * This is the derivation behind every bag's `composedOf`. Structural (pipeline position),
335
+ * not a per-capture run record — see the module docstring.
336
+ *
337
+ * @param {string} stage
338
+ * @returns {string[]} a fresh array (never the shared table)
339
+ * @throws {Error} on an unknown stage
340
+ */
341
+ function upstreamStages(stage) {
342
+ assertStage(stage);
343
+ return RENDER_STAGES.slice(0, RENDER_STAGES.indexOf(stage));
344
+ }
345
+
346
+ /**
347
+ * assertStage(stage) — throw-or-return. The message ENUMERATES the whole table, so a
348
+ * typo'd stage name names its own fix (the `getRenderOption` fail-loud precedent).
349
+ *
350
+ * @param {string} stage
351
+ * @returns {string} the same stage, when valid
352
+ * @throws {Error} when `stage` is absent or not a member of RENDER_STAGES
353
+ */
354
+ function assertStage(stage) {
355
+ if (!_isStageKnown(stage)) {
356
+ throw new Error('[RenderStages] unknown stage ' + JSON.stringify(stage) +
357
+ ' — the canonical pipeline is [' + RENDER_STAGES.join(', ') + ']. ' +
358
+ 'The stage argument is REQUIRED on the tap mechanism (a default is the ' +
359
+ 'convenience\'s job, not the mechanism\'s). (FAIL-LOUD.)');
360
+ }
361
+ return stage;
362
+ }
363
+
364
+ /**
365
+ * stageNotTappableError(stage, who) — the DISTINCT error for a stage that IS a real member
366
+ * of the table but has no tap executor available (v1: `scene`; or `composite` on a
367
+ * provider that does not implement the OPTIONAL `captureStill()`).
368
+ *
369
+ * Kept separate from `assertStage`'s unknown-stage throw on purpose: "you named something
370
+ * that does not exist" and "you named a real pipeline point nothing can read yet" are
371
+ * different diagnoses with different fixes, and collapsing them would send a caller
372
+ * hunting for a typo that is not there.
373
+ *
374
+ * @param {string} stage
375
+ * @param {string} who — the party that cannot tap it (e.g. 'the active renderer provider')
376
+ * @returns {Error} (the caller throws it — never a null bag, never a blank still)
377
+ */
378
+ function stageNotTappableError(stage, who) {
379
+ return new Error('[RenderStages] stage ' + JSON.stringify(stage) + ' is known, but ' +
380
+ 'not tappable by ' + (who || 'the active provider') + '. Known stages: [' +
381
+ RENDER_STAGES.join(', ') + ']. A tap executor is added when a real caller for it ' +
382
+ 'appears — never a null image or an empty bag in the meantime. (FAIL-LOUD.)');
383
+ }
384
+
385
+ function _fail(msg) {
386
+ throw new Error('[RenderStages.normalizeStill] ' + msg + ' (FAIL-LOUD.)');
387
+ }
388
+
389
+ /** THE positive-integer predicate — `_posInt` (still dims) and `assertSceneBound` (scene bounds)
390
+ both decide through it, each with its own prefix and wording. @private */
391
+ function _isPosInt(v) {
392
+ return typeof v === 'number' && isFinite(v) && v > 0 && Math.floor(v) === v;
393
+ }
394
+
395
+ function _posInt(v, name) {
396
+ if (!_isPosInt(v)) {
397
+ _fail(name + ' must be a positive integer, got ' + String(v));
398
+ }
399
+ return v;
400
+ }
401
+
402
+ // ── `data` IS IMMUTABLE BY CONTRACT — snapshot at capture ─────────────────────────
403
+ //
404
+ // A still that keeps changing is not a still. The `tick` stage's payload arrives as the
405
+ // LIVE `TickData.frameData` the engine just marshalled, and the render contract makes NO
406
+ // promise that the marshalling layer builds fresh arrays/objects per tick — so a record
407
+ // holding that reference is (a) an aliasing hazard the moment any layer reuses a buffer and
408
+ // (b) not serialization-safe, because a live object graph with prototype accessors does not
409
+ // survive JSON. For a training corpus that is the worst class of defect: every record
410
+ // silently drifts toward the latest tick and nothing announces it. So the normalizer
411
+ // SNAPSHOTS.
412
+ //
413
+ // Snapshotting (rather than ratifying a fresh-per-tick guarantee with the engine-boundary
414
+ // owner) is the ruled choice because it buys the SECOND property too: the snapshot is plain
415
+ // data, hence JSON-safe, which is the prerequisite for any on-disk corpus row.
416
+
417
+ /**
418
+ * Every READABLE property name of an object, own and inherited, data and accessor.
419
+ *
420
+ * Why not `Object.keys` / `structuredClone` / a JSON round-trip: all three copy only OWN
421
+ * ENUMERABLE DATA properties, and the engine's `FrameData.rect` is a `Rectangle` whose
422
+ * `x`/`y`/`left`/`top`/`right`/`bottom` are NON-ENUMERABLE PROTOTYPE ACCESSORS over private
423
+ * `_x`/`_y` backing fields. Any of those three would therefore have produced a snapshot
424
+ * whose `rect.x` was silently `undefined` — swapping one silent-data-loss bug for another,
425
+ * and forcing a corpus consumer to read a private field it should never know about.
426
+ *
427
+ * This walk FLATTENS accessors into plain data. It is deliberately GENERIC — it knows no
428
+ * field names, so it cannot drift from `FrameDataSchema` (the frame-data bubble's owner,
429
+ * which this contract points AT and never restates). The backing field AND its accessor both
430
+ * survive (e.g. both `_x` and `x`) because the walk knows no naming convention — a corpus
431
+ * writer that wants only one may project.
432
+ *
433
+ * Functions are split by OWNERSHIP, deliberately: an INHERITED function is a METHOD and is
434
+ * skipped (that is what flattening means), while an OWN function-valued field is a genuine
435
+ * contract violation and is INCLUDED here so the clone can THROW naming its path. Silently
436
+ * dropping it would be the fail-silent behaviour this whole change exists to remove.
437
+ *
438
+ * @param {object} obj
439
+ * @returns {string[]}
440
+ */
441
+ function _readableKeys(obj) {
442
+ var keys = [];
443
+ var seen = Object.create(null);
444
+ var o = obj;
445
+ var own = true;
446
+ while (o && o !== Object.prototype && o !== Array.prototype) {
447
+ var names = Object.getOwnPropertyNames(o);
448
+ for (var i = 0; i < names.length; i++) {
449
+ var k = names[i];
450
+ if (k === 'constructor' || seen[k]) { continue; }
451
+ var d = Object.getOwnPropertyDescriptor(o, k);
452
+ if (d.get) { seen[k] = true; keys.push(k); }
453
+ else if (own || typeof d.value !== 'function') { seen[k] = true; keys.push(k); }
454
+ }
455
+ o = Object.getPrototypeOf(o);
456
+ own = false;
457
+ }
458
+ return keys;
459
+ }
460
+
461
+ function _isTypedArray(v) {
462
+ return v && typeof v === 'object' && typeof v.BYTES_PER_ELEMENT === 'number' &&
463
+ typeof v.length === 'number' && typeof v.constructor === 'function';
464
+ }
465
+
466
+ /**
467
+ * Deep PLAIN-DATA clone. Primitives pass through; typed arrays are copied; arrays and
468
+ * objects are rebuilt as plain `Array`/`Object` with accessors flattened. A FUNCTION value
469
+ * THROWS naming its path — a function inside a data payload means the payload is not
470
+ * serialization-safe, which is precisely what this contract requires it to be.
471
+ * @private
472
+ */
473
+ function _deepPlainClone(v, path) {
474
+ if (v === null || v === undefined) { return null; }
475
+ var t = typeof v;
476
+ if (t === 'number' || t === 'string' || t === 'boolean') { return v; }
477
+ if (t === 'function') {
478
+ _fail('the payload carries a FUNCTION at ' + path + ' — a still record must be plain, ' +
479
+ 'serialization-safe data');
480
+ }
481
+ if (t !== 'object') {
482
+ _fail('the payload carries an unsupported ' + t + ' at ' + path);
483
+ }
484
+ if (_isTypedArray(v)) { return new v.constructor(v); }
485
+ if (Array.isArray(v)) {
486
+ var arr = new Array(v.length);
487
+ for (var i = 0; i < v.length; i++) { arr[i] = _deepPlainClone(v[i], path + '[' + i + ']'); }
488
+ return arr;
489
+ }
490
+ var out = {};
491
+ var keys = _readableKeys(v);
492
+ for (var j = 0; j < keys.length; j++) {
493
+ var k = keys[j];
494
+ var val;
495
+ try { val = v[k]; }
496
+ catch (e) { continue; } // a throwing getter is not payload data
497
+ // An OWN function-valued field reaches here (see _readableKeys) and THROWS below;
498
+ // inherited methods were already filtered out as flattening, not as an error.
499
+ out[k] = _deepPlainClone(val, path + '.' + k);
500
+ }
501
+ return out;
502
+ }
503
+
504
+ /**
505
+ * deepPlainCopy(value) — THE plain-data deep-copy authority.
506
+ *
507
+ * Returns a copy that shares NO object identity with `value` (accessors flattened, typed
508
+ * arrays copied, prototypes dropped). Exported because the same guarantee is needed in two
509
+ * places and must not be written twice: the still PAYLOAD (`snapshotPayload`, R8) and the
510
+ * SCENE RECORD handed out by `runner.sceneRecord()`. A record a caller can mutate is
511
+ * internal state on loan, not a record.
512
+ *
513
+ * @param {*} value
514
+ * @returns {*} a copy sharing no object identity with `value`
515
+ */
516
+ function deepPlainCopy(value) { return _deepPlainClone(value, 'value'); }
517
+
518
+ /**
519
+ * snapshotPayload(data, format) — the immutability guarantee, applied.
520
+ *
521
+ * `'rgba8'`/`'r8'` → a fresh typed-array copy. `'framedata'` → a deep plain-data clone with
522
+ * accessors flattened. Exported so the conformance gate and any future stage executor use
523
+ * the SAME snapshot, never a second one.
524
+ *
525
+ * @param {*} data — the stage implementation's payload
526
+ * @param {string} format — a `STAGE_FORMATS` value
527
+ * @returns {*} a payload that no later tick can change
528
+ */
529
+ function snapshotPayload(data, format) {
530
+ if (format === 'framedata') { return _deepPlainClone(data, 'data'); }
531
+ if (_isTypedArray(data)) { return new data.constructor(data); }
532
+ if (Array.isArray(data)) { return _deepPlainClone(data, 'data'); }
533
+ _fail('format ' + JSON.stringify(format) + ' expects an indexed buffer payload, got ' + String(data));
534
+ }
535
+
536
+ // ── The SCENE record: immutable, CONTENT-ADDRESSED ───────────────────────────────
537
+ //
538
+ // `scene = sha256(canonicalize(SceneRecord))`. Canonicalization lives HERE — one hashing
539
+ // authority, or two writers mint two identities for the same scene. Content-addressing buys
540
+ // two properties for free: identical setups SHARE a scene identity across runs, and any
541
+ // drift between what a still claims and what the scene was is DETECTABLE.
542
+ //
543
+ // Scene records are IMMUTABLE. A structural edit (object added/deleted) does not revise a
544
+ // record, it mints a NEW one — under content-addressing "revision bump" and "new record" are
545
+ // the same operation, and a mutable revisioned record would reintroduce exactly the
546
+ // identity-vs-content ambiguity content-addressing removes. A field-level revision scheme
547
+ // would have been catastrophic in the other direction: a per-tick pose change would mint a
548
+ // revision per tick and the normalization would have destroyed itself.
549
+
550
+ function _failScene(msg) {
551
+ throw new Error('[RenderStages.normalizeScene] ' + msg + ' (FAIL-LOUD.)');
552
+ }
553
+
554
+ /**
555
+ * assertSceneBound(v, name) — THE rule for a physics bound (`physicsW`/`physicsH`): a positive
556
+ * integer. `normalizeScene` validates through it, and a host that accepts a STATED bound as an
557
+ * expectation checks it here before it acquires anything — one rule, never a second copy.
558
+ *
559
+ * @param {*} v
560
+ * @param {string} name — 'physicsW' | 'physicsH'
561
+ * @returns {number} v
562
+ */
563
+ function assertSceneBound(v, name) {
564
+ if (!_isPosInt(v)) {
565
+ _failScene(name + ' must be a positive integer (a physics bound, init-fixed), got ' + String(v));
566
+ }
567
+ return v;
568
+ }
569
+
570
+ /**
571
+ * assertSourceSha256(v) — THE rule for a scene's code identity: null/undefined (honestly absent) or a
572
+ * project identity, 64 lowercase hex characters (identity.js projectIdentity). Returns null or v.
573
+ * @param {*} v
574
+ * @returns {string|null}
575
+ */
576
+ function assertSourceSha256(v) {
577
+ if (v === undefined || v === null) { return null; }
578
+ if (typeof v !== 'string' || !/^[0-9a-f]{64}$/.test(v)) {
579
+ _failScene('sourceSha256 must be null or a project identity (64 lowercase hex characters, ' +
580
+ 'lib/shim/project/identity.js projectIdentity), got ' + String(v));
581
+ }
582
+ return v;
583
+ }
584
+
585
+ /**
586
+ * normalizeScene(partial) — ferry a partial scene bag to the full canonical SceneRecord.
587
+ *
588
+ * Every `SCENE_KEYS` key is present on the result. `seed` must be a finite number (it is
589
+ * half of the regeneration triple — a null seed silently downgrades "regenerate" to
590
+ * "hope"); `sourceRef` and `build` and `projection` may be `null` (honestly absent).
591
+ *
592
+ * @param {object} partial
593
+ * @returns {object} a record carrying exactly SCENE_KEYS
594
+ */
595
+ function normalizeScene(partial) {
596
+ if (!partial || typeof partial !== 'object') {
597
+ _failScene('expected a partial scene bag object, got ' + String(partial));
598
+ }
599
+ if (partial.v !== undefined && partial.v !== null && partial.v !== SCHEMA_VERSION) {
600
+ _failScene('v ' + String(partial.v) + ' disagrees with the contract version ' +
601
+ SCHEMA_VERSION + ' — the normalizer is the version authority, `v` is never ' +
602
+ 'caller-supplied');
603
+ }
604
+ if (typeof partial.seed !== 'number' || !isFinite(partial.seed)) {
605
+ _failScene('seed must be a finite number — it is half of the regeneration triple ' +
606
+ '(code, seed, tick), and a null seed downgrades "regenerate" to "trust the ' +
607
+ 'raster", got ' + String(partial.seed));
608
+ }
609
+ return {
610
+ v: SCHEMA_VERSION,
611
+ seed: partial.seed,
612
+ sourceRef: (partial.sourceRef === undefined || partial.sourceRef === null)
613
+ ? null : String(partial.sourceRef),
614
+ sourceSha256: assertSourceSha256(partial.sourceSha256),
615
+ // COPIED, never aliased: a BYO provider is free to return a CACHED projection
616
+ // object from `projection()`, and a caller is free to keep a reference to the
617
+ // `build` bag it passed in. Either would make the record a live view of somebody
618
+ // else's mutable state — the R8 defect class, in the record that the whole
619
+ // regenerate-not-replay claim rests on.
620
+ build: (partial.build && typeof partial.build === 'object')
621
+ ? deepPlainCopy(partial.build) : null,
622
+ physicsW: assertSceneBound(partial.physicsW, 'physicsW'),
623
+ physicsH: assertSceneBound(partial.physicsH, 'physicsH'),
624
+ projection: (partial.projection && typeof partial.projection === 'object')
625
+ ? deepPlainCopy(partial.projection) : null
626
+ };
627
+ }
628
+
629
+ /** Deterministic key-sorted encoding of a plain value. @private */
630
+ function _stableEncode(v) {
631
+ if (v === null || v === undefined) { return 'null'; }
632
+ var t = typeof v;
633
+ if (t === 'number' || t === 'boolean') { return String(v); }
634
+ if (t === 'string') { return JSON.stringify(v); }
635
+ if (Array.isArray(v)) {
636
+ return '[' + v.map(_stableEncode).join(',') + ']';
637
+ }
638
+ var keys = Object.keys(v).sort();
639
+ return '{' + keys.map(function (k) {
640
+ return JSON.stringify(k) + ':' + _stableEncode(v[k]);
641
+ }).join(',') + '}';
642
+ }
643
+
644
+ /**
645
+ * canonicalizeScene(record) — the ONE canonical byte-string a scene identity is taken over.
646
+ *
647
+ * PROJECTS onto `SCENE_KEYS` in table order, encoding nested objects with sorted keys. Two
648
+ * consequences worth stating: (a) key insertion order cannot change an identity, and (b) an
649
+ * `scene` id field carried ALONGSIDE the record (as `getScene()` does for convenience)
650
+ * cannot perturb its own hash, because it is not a `SCENE_KEYS` member and is projected out.
651
+ *
652
+ * @param {object} record — a normalized SceneRecord (or a superset of one)
653
+ * @returns {string}
654
+ */
655
+ function canonicalizeScene(record) {
656
+ if (!record || typeof record !== 'object') {
657
+ _failScene('canonicalizeScene expects a record object, got ' + String(record));
658
+ }
659
+ var parts = [];
660
+ for (var i = 0; i < SCENE_KEYS.length; i++) {
661
+ var k = SCENE_KEYS[i];
662
+ parts.push(JSON.stringify(k) + ':' + _stableEncode(record[k]));
663
+ }
664
+ return '{' + parts.join(',') + '}';
665
+ }
666
+
667
+ /**
668
+ * sceneHash(record) → Promise<'sha256:<hex>'> — the content address.
669
+ *
670
+ * ASYNC because the platform's own digest is: `crypto.subtle.digest` is the ONE hashing
671
+ * implementation used here, and it is Promise-based. It is a global in both the browser and
672
+ * modern Node, so this is a single path — not a per-environment fork of a crypto primitive
673
+ * (hand-rolling sha256 in the render layer would be exactly the wrong kind of DRY win).
674
+ *
675
+ * The `sha256:` prefix makes the algorithm self-describing in the record, so a future
676
+ * algorithm change can never be silent.
677
+ *
678
+ * @param {object} record
679
+ * @returns {Promise<string>}
680
+ */
681
+ function sceneHash(record) {
682
+ var canon = canonicalizeScene(record);
683
+ var subtle = (typeof crypto !== 'undefined') ? crypto.subtle : null;
684
+ if (!subtle || typeof subtle.digest !== 'function') {
685
+ return Promise.reject(new Error('[RenderStages.sceneHash] crypto.subtle.digest is ' +
686
+ 'unavailable — the scene identity is a content hash and there is no fallback ' +
687
+ 'hashing path by design (two hashing authorities would mint two identities for ' +
688
+ 'one scene). (FAIL-LOUD.)'));
689
+ }
690
+ var bytes = new TextEncoder().encode(canon);
691
+ return Promise.resolve(subtle.digest('SHA-256', bytes)).then(function (buf) {
692
+ var view = new Uint8Array(buf);
693
+ var hex = '';
694
+ for (var i = 0; i < view.length; i++) {
695
+ hex += (view[i] < 16 ? '0' : '') + view[i].toString(16);
696
+ }
697
+ return 'sha256:' + hex;
698
+ });
699
+ }
700
+
701
+ /**
702
+ * normalizeStill(partial) — ferry a partial tap result to the FULL canonical bag.
703
+ *
704
+ * The owner-exported normalizer of the "owner exports contract → consumers ferry the bag"
705
+ * pattern: the dispatcher merges the stage implementation's render-domain fields with the
706
+ * provenance it alone knows, hands the merged partial here ONCE, and every consumer reads
707
+ * a bag with all STILL_BAG_KEYS present. No consumer re-enumerates the key list.
708
+ *
709
+ * FAIL-LOUD rules (each closes a way a bag could lie):
710
+ * - unknown/absent `stage` → assertStage throws (names the table)
711
+ * - absent or EMPTY `data` → throw. A zero-length payload is the "silent
712
+ * empty bag"/black-still failure the whole
713
+ * capability exists to make impossible.
714
+ * - `format` ≠ STAGE_FORMATS[stage] → throw. The format is a property of the
715
+ * stage, not a caller opinion.
716
+ * - non-positive-integer width/height → throw.
717
+ * - `filtersApplied` not an array → throw (order is contract; a map is not).
718
+ * - a filter ran without a finite → throw. A filtered still without the uTime it
719
+ * `filterTime`, or a `filterTime` drew with is not regenerable; a time with no
720
+ * with no filter filter claims a pass that never ran. Checked
721
+ * HERE because a BYO captureStill() bag never
722
+ * passes the built-in providers' readback helper.
723
+ * - a caller-supplied `composedOf` that → throw. Provenance is stamped by its
724
+ * disagrees with upstreamStages(stage) authority, never supplied by the caller.
725
+ *
726
+ * @param {object} partial
727
+ * @returns {object} a bag carrying every STILL_BAG_KEYS key
728
+ */
729
+ function normalizeStill(partial) {
730
+ if (!partial || typeof partial !== 'object') { _fail('expected a partial bag object, got ' + String(partial)); }
731
+ var stage = assertStage(partial.stage);
732
+
733
+ var format = STAGE_FORMATS[stage];
734
+ if (partial.format !== undefined && partial.format !== null && partial.format !== format) {
735
+ _fail('format ' + JSON.stringify(partial.format) + ' does not match stage ' +
736
+ JSON.stringify(stage) + '\'s declared format ' + JSON.stringify(format));
737
+ }
738
+
739
+ var data = partial.data;
740
+ if (data === undefined || data === null) {
741
+ _fail('stage ' + JSON.stringify(stage) + ' returned no `data` payload');
742
+ }
743
+ if (typeof data.length !== 'number') {
744
+ _fail('`data` must be an indexed payload (Array / Uint8Array / Uint8ClampedArray), got ' + String(data));
745
+ }
746
+ if (data.length === 0) {
747
+ _fail('stage ' + JSON.stringify(stage) + ' returned an EMPTY `data` payload — never ' +
748
+ 'a blank still: a measurement or golden built on an empty capture is fabricated evidence');
749
+ }
750
+
751
+ if (partial.v !== undefined && partial.v !== null && partial.v !== SCHEMA_VERSION) {
752
+ _fail('v ' + String(partial.v) + ' disagrees with the contract version ' + SCHEMA_VERSION +
753
+ ' — the normalizer is the version authority, `v` is never caller-supplied');
754
+ }
755
+ if (typeof partial.scene !== 'string' || !partial.scene) {
756
+ _fail('scene must be the content hash of the SceneRecord this still belongs to — a ' +
757
+ 'still without a scene id is unmoored: it cannot be verified against, or ' +
758
+ 'regenerated from, anything. Got ' + String(partial.scene));
759
+ }
760
+ if (typeof partial.tick !== 'number' || !isFinite(partial.tick) || partial.tick < 0) {
761
+ _fail('tick must be a finite non-negative number (the dispatcher stamps it from the ' +
762
+ 'live TickData — the tick-equality assertion is what co-registers a multi-channel ' +
763
+ 'corpus row), got ' + String(partial.tick));
764
+ }
765
+
766
+ var filters = partial.filtersApplied;
767
+ if (!Array.isArray(filters)) {
768
+ _fail('filtersApplied must be an ARRAY of filter names in the order they ran (the ' +
769
+ 'provider\'s ordered _filters list is the one authority), got ' + String(filters));
770
+ }
771
+
772
+ var filterTime = (partial.filterTime === undefined) ? null : partial.filterTime;
773
+ if (filters.length > 0 ? !(typeof filterTime === 'number' && isFinite(filterTime)) : filterTime !== null) {
774
+ _fail('filterTime must be the finite uTime the filter passes drew with when filtersApplied is ' +
775
+ 'non-empty, and null when it is empty (filtersApplied ' + JSON.stringify(filters) +
776
+ ', filterTime ' + String(filterTime) + ') — the provider reports it ' +
777
+ '(RendererProviderContract.filterTime of the rendered tick)');
778
+ }
779
+
780
+ var composed = upstreamStages(stage);
781
+ if (partial.composedOf !== undefined && partial.composedOf !== null) {
782
+ var supplied = partial.composedOf;
783
+ var same = Array.isArray(supplied) && supplied.length === composed.length &&
784
+ supplied.every(function (s, i) { return s === composed[i]; });
785
+ if (!same) {
786
+ _fail('composedOf is DERIVED from RENDER_STAGES (' + JSON.stringify(composed) +
787
+ ') and is never caller-supplied; got ' + JSON.stringify(supplied));
788
+ }
789
+ }
790
+
791
+ return {
792
+ v: SCHEMA_VERSION,
793
+ scene: partial.scene,
794
+ stage: stage,
795
+ tick: partial.tick,
796
+ format: format,
797
+ // IMMUTABLE BY CONTRACT: snapshot at capture. See snapshotPayload's docstring for
798
+ // why a live reference here is the worst defect class a training corpus can carry.
799
+ data: snapshotPayload(data, format),
800
+ width: _posInt(partial.width, 'width'),
801
+ height: _posInt(partial.height, 'height'),
802
+ // CSS size is meaningful only for a stage that read a real canvas; null elsewhere.
803
+ cssWidth: (typeof partial.cssWidth === 'number') ? partial.cssWidth : null,
804
+ cssHeight: (typeof partial.cssHeight === 'number') ? partial.cssHeight : null,
805
+ filtersApplied: filters.slice(),
806
+ // null (not false) on a stage where NOTHING composited — reporting `true` there
807
+ // would be a claim about a compositing pass that never ran.
808
+ renderBaseCanvas: (typeof partial.renderBaseCanvas === 'boolean') ? partial.renderBaseCanvas : null,
809
+ // The uTime the provider's filter passes drew with; null where no filter ran (checked
810
+ // above). A filtered still is regenerable only with it (a filter may animate on uTime).
811
+ filterTime: filterTime,
812
+ pose: (partial.pose !== undefined && partial.pose !== null) ? partial.pose : null,
813
+ // The FieldBuilder options the field tap actually ran with (gain/radius/blur/dims).
814
+ // Two field captures at different gain are otherwise INDISTINGUISHABLE, and the r8
815
+ // channel is a dimensionless instrument response, comparable only at equal options.
816
+ // null on every other stage.
817
+ fieldOpts: (partial.fieldOpts && typeof partial.fieldOpts === 'object')
818
+ ? partial.fieldOpts : null,
819
+ composedOf: composed,
820
+ renderOptions: (partial.renderOptions && typeof partial.renderOptions === 'object')
821
+ ? partial.renderOptions : {}
822
+ };
823
+ }
824
+
825
+ // ── Export ────────────────────────────────────────────────────────────────────
826
+
827
+ export {
828
+ SCHEMA_VERSION,
829
+ RENDER_STAGES,
830
+ STAGE_FORMATS,
831
+ SCENE_KEYS,
832
+ SCENE_BOUND_KEYS,
833
+ STILL_KEYS,
834
+ STILL_DERIVED_KEYS,
835
+ STILL_BAG_KEYS,
836
+ STILL_PAYLOAD_KEYS,
837
+ STILL_PROVENANCE_KEYS,
838
+ RUNTIME_MUTATION_SURFACES,
839
+ finalStage,
840
+ upstreamStages,
841
+ assertStage,
842
+ stageNotTappableError,
843
+ deepPlainCopy,
844
+ snapshotPayload,
845
+ normalizeScene,
846
+ assertSceneBound,
847
+ assertSourceSha256,
848
+ canonicalizeScene,
849
+ sceneHash,
850
+ normalizeStill
851
+ };
852
+ // SCENE_SURFACE_ATTR — the attribute `FrqtlApp.createCanvas` stamps on the scene's drawing surface. OWNED by the
853
+ // RendererProvider contract; re-exported here so consumers above the engine reach it through the shim's render
854
+ // vocabulary — a barrel, never a second literal.
855
+ export { SCENE_SURFACE_ATTR } from '../../frqtl/compat/renderer/renderer-provider-contract.js';