@hraness/slopcamera 3.2.8 → 3.3.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.
Files changed (153) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +271 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-grk47tgs.js +3 -0
  59. package/dist/index-gs48afpt.js +7 -0
  60. package/dist/index-qry58nj2.js +10 -0
  61. package/dist/index-zhg36tf9.js +3 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/docs/studio.md +12 -0
  69. package/package.json +7 -5
  70. package/skills/slopcamera/SKILL.md +7 -1
  71. package/skills/slopcamera/references/directed-scenes.md +48 -2
  72. package/skills/slopcamera/references/gateway-media.md +15 -1
  73. package/skills/slopcamera/references/image-galleries.md +166 -0
  74. package/skills/slopcamera/references/install.md +1 -1
  75. package/skills/slopcamera/references/music-video.md +4 -0
  76. package/skills/slopcamera/references/native-studio.md +19 -0
  77. package/skills/slopcamera/references/parametric-design.md +14 -0
  78. package/skills/slopcamera/references/scene-building.md +172 -0
  79. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  80. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  81. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  82. package/src/capability-manifest.ts +361 -0
  83. package/src/cli.ts +222 -4
  84. package/src/code/canonical-json.ts +9 -0
  85. package/src/generate.ts +2 -2
  86. package/src/icon.ts +1111 -0
  87. package/src/image-gallery.ts +1049 -0
  88. package/src/index.ts +11 -0
  89. package/src/mcp/boundary.ts +11 -11
  90. package/src/mcp/index.ts +3 -0
  91. package/src/mcp/server.ts +2 -1
  92. package/src/mcp/tools.ts +1649 -132
  93. package/src/mcp/types.ts +19 -0
  94. package/src/operations.ts +412 -0
  95. package/src/portable-capability-manifest.ts +67 -0
  96. package/src/scene-gallery.ts +184 -0
  97. package/src/spatial-scene/asset-admission.ts +183 -0
  98. package/src/spatial-scene/audit-rendered.ts +692 -0
  99. package/src/spatial-scene/audit.ts +617 -0
  100. package/src/spatial-scene/behavior-audit.ts +252 -0
  101. package/src/spatial-scene/behavior-bake.ts +232 -0
  102. package/src/spatial-scene/behavior-fns.ts +585 -0
  103. package/src/spatial-scene/behavior-gallery.ts +108 -0
  104. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  105. package/src/spatial-scene/behavior-trace.ts +325 -0
  106. package/src/spatial-scene/behavior.ts +617 -0
  107. package/src/spatial-scene/build.ts +632 -0
  108. package/src/spatial-scene/camera-rig.ts +125 -0
  109. package/src/spatial-scene/camera-track.ts +4 -2
  110. package/src/spatial-scene/character.ts +124 -0
  111. package/src/spatial-scene/contracts.ts +75 -7
  112. package/src/spatial-scene/design-templates.ts +351 -0
  113. package/src/spatial-scene/design.ts +327 -0
  114. package/src/spatial-scene/direction-compile.ts +584 -0
  115. package/src/spatial-scene/direction.ts +122 -0
  116. package/src/spatial-scene/effects.ts +149 -0
  117. package/src/spatial-scene/evaluate.ts +109 -29
  118. package/src/spatial-scene/gallery.ts +277 -0
  119. package/src/spatial-scene/generate.ts +239 -0
  120. package/src/spatial-scene/geometry-native.ts +165 -0
  121. package/src/spatial-scene/geometry.ts +1216 -0
  122. package/src/spatial-scene/gltf.ts +600 -77
  123. package/src/spatial-scene/identity.ts +55 -2
  124. package/src/spatial-scene/index.ts +36 -0
  125. package/src/spatial-scene/inspect.ts +35 -6
  126. package/src/spatial-scene/material-lighting.ts +609 -0
  127. package/src/spatial-scene/math.ts +21 -4
  128. package/src/spatial-scene/motion-evidence.ts +88 -0
  129. package/src/spatial-scene/parametric.ts +765 -0
  130. package/src/spatial-scene/particle-preparation.ts +183 -0
  131. package/src/spatial-scene/particle.ts +195 -0
  132. package/src/spatial-scene/patch.ts +109 -15
  133. package/src/spatial-scene/performance.ts +1613 -0
  134. package/src/spatial-scene/probe.ts +137 -0
  135. package/src/spatial-scene/recipe-pack.ts +86 -0
  136. package/src/spatial-scene/render-effects.ts +240 -0
  137. package/src/spatial-scene/review.ts +639 -0
  138. package/src/spatial-scene/simulation-bake.ts +206 -0
  139. package/src/spatial-scene/simulation.ts +187 -0
  140. package/src/spatial-scene/solve.ts +366 -0
  141. package/src/spatial-scene/temporal-audit.ts +354 -0
  142. package/src/version.ts +1 -1
  143. package/dist/index-42zsesc1.js +0 -2257
  144. package/dist/index-7131pg1c.js +0 -1820
  145. package/dist/index-7308egqr.js +0 -48
  146. package/dist/index-8txs6fkn.js +0 -712
  147. package/dist/index-fava6pge.js +0 -192
  148. package/dist/index-h1k0fnjq.js +0 -2363
  149. package/dist/index-r7gdhmsp.js +0 -424
  150. package/dist/index-sh6xbav6.js +0 -1219
  151. package/dist/index-z1w83f81.js +0 -4
  152. package/dist/index-zfnddgay.js +0 -2029
  153. package/dist/skill-install-dh0ntqba.js +0 -10
@@ -0,0 +1,617 @@
1
+ import { z } from "zod"
2
+
3
+ import { boundedCanonicalJsonSha256 } from "../code/canonical-json.js"
4
+ import { deepFreezeJson } from "../code/json-snapshot.js"
5
+ import {
6
+ SpatialDigestSchema,
7
+ SpatialTimeUsSchema,
8
+ type SpatialSceneV1,
9
+ } from "./contracts.js"
10
+ import { parseSpatialScene, parseSpatialValue, spatialValueSha256 } from "./identity.js"
11
+
12
+ /**
13
+ * Behavior documents bind an ALGAL organism closure to one scene entity.
14
+ *
15
+ * A `slopcamera.spatial-behavior` document is bounded, inert, content-addressed
16
+ * data: it carries every organism manifest the behavior needs, keyed by the
17
+ * ALGAL `sha256:` digest, and names the entry organism plus deterministic
18
+ * inputs (seed, range, args). Admission parses from `unknown` through strict
19
+ * schemas and a bake-safe organism profile — a closed subset of
20
+ * `morphogen.organism.v1` containing only `input`, `const`, `fn`, `repeat`,
21
+ * `each`, and `organism` cells. `agent`, `classifier`, `gate`, `tool`,
22
+ * `spawn`, `slot`, `store`, `load` cells and every `via` transport field are
23
+ * structural rejections: bakes must replay deterministically from the document
24
+ * alone, with no model calls, effects, transports, or ambient store state.
25
+ *
26
+ * Organism digests are ALGAL digests — `sha256:` + SHA-256 of canonical JSON
27
+ * over `manifestToJson`'s normalized projection — mirrored here so closure
28
+ * keys rehash exactly against manifests authored with ALGAL tooling.
29
+ */
30
+ export const SPATIAL_BEHAVIOR_LIMITS = Object.freeze({
31
+ args: 16,
32
+ carry: 16,
33
+ cells: 64,
34
+ channels: 64,
35
+ constPorts: 32,
36
+ eachItems: 64,
37
+ edges: 256,
38
+ findings: 128,
39
+ interfacePorts: 32,
40
+ organisms: 8,
41
+ rounds: 16,
42
+ })
43
+
44
+ const organismKey = z.string().regex(/^organism:[a-z][a-z0-9-]{0,54}$/u)
45
+ const cellId = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u)
46
+ const portName = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u)
47
+ const interfaceName = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u)
48
+ const organismDigest = z.string().regex(/^sha256:[a-f0-9]{64}$/u)
49
+ const fnRef = z.string().regex(/^[a-z][a-z0-9_.]*\.v[0-9]+$/u).max(64)
50
+
51
+ export const SpatialBehaviorPortTypeSchema = z.strictObject({
52
+ type: z.enum(["text", "json", "choice", "ref"]),
53
+ optional: z.boolean().optional(),
54
+ many: z.boolean().optional(),
55
+ labels: z.array(z.string().min(1).max(64)).max(32).optional(),
56
+ schema: z.unknown().optional(),
57
+ }).superRefine((port, context) => {
58
+ if (port.labels !== undefined && port.type !== "choice") {
59
+ context.addIssue({ code: "custom", message: "Only choice ports declare labels.", path: ["labels"] })
60
+ }
61
+ if (port.schema !== undefined && port.type !== "json") {
62
+ context.addIssue({ code: "custom", message: "Only json ports declare a schema.", path: ["schema"] })
63
+ }
64
+ })
65
+ export type SpatialBehaviorPortType = Readonly<z.infer<typeof SpatialBehaviorPortTypeSchema>>
66
+
67
+ export const SpatialBehaviorPortMapSchema = z.record(
68
+ portName,
69
+ SpatialBehaviorPortTypeSchema,
70
+ ).refine((ports) => Object.keys(ports).length <= SPATIAL_BEHAVIOR_LIMITS.interfacePorts, {
71
+ message: `A port map admits at most ${SPATIAL_BEHAVIOR_LIMITS.interfacePorts} ports.`,
72
+ })
73
+ export type SpatialBehaviorPortMap = Readonly<z.infer<typeof SpatialBehaviorPortMapSchema>>
74
+
75
+ export interface SpatialBehaviorFnSignature {
76
+ readonly inputs: SpatialBehaviorPortMap
77
+ readonly outputs: SpatialBehaviorPortMap
78
+ }
79
+
80
+ const constOutput = z.record(
81
+ portName,
82
+ SpatialBehaviorPortTypeSchema.extend({ value: z.unknown() }),
83
+ ).refine((ports) => Object.keys(ports).length <= SPATIAL_BEHAVIOR_LIMITS.constPorts, {
84
+ message: `A const cell declares at most ${SPATIAL_BEHAVIOR_LIMITS.constPorts} outputs.`,
85
+ })
86
+
87
+ export const SpatialBehaviorCellSchema = z.discriminatedUnion("kind", [
88
+ z.strictObject({ id: cellId, kind: z.literal("input"), outputs: SpatialBehaviorPortMapSchema }),
89
+ z.strictObject({ id: cellId, kind: z.literal("const"), outputs: constOutput }),
90
+ z.strictObject({ id: cellId, kind: z.literal("fn"), fn: fnRef }),
91
+ z.strictObject({
92
+ id: cellId,
93
+ kind: z.literal("repeat"),
94
+ manifest: organismDigest,
95
+ maxRounds: z.number().int().min(1).max(SPATIAL_BEHAVIOR_LIMITS.rounds),
96
+ carry: z.record(interfaceName, interfaceName).refine(
97
+ (carry) => Object.keys(carry).length <= SPATIAL_BEHAVIOR_LIMITS.carry,
98
+ { message: `A repeat cell carries at most ${SPATIAL_BEHAVIOR_LIMITS.carry} ports.` },
99
+ ).optional(),
100
+ until: z.strictObject({
101
+ output: interfaceName,
102
+ equals: z.string().min(1).max(64),
103
+ field: portName.optional(),
104
+ }).optional(),
105
+ }),
106
+ z.strictObject({
107
+ id: cellId,
108
+ kind: z.literal("each"),
109
+ manifest: organismDigest,
110
+ over: interfaceName,
111
+ maxItems: z.number().int().min(1).max(SPATIAL_BEHAVIOR_LIMITS.eachItems),
112
+ }),
113
+ z.strictObject({ id: cellId, kind: z.literal("organism"), manifest: organismDigest }),
114
+ ])
115
+ export type SpatialBehaviorCell = Readonly<z.infer<typeof SpatialBehaviorCellSchema>>
116
+
117
+ export const SpatialBehaviorEdgeSchema = z.strictObject({
118
+ from: z.strictObject({ cell: cellId, port: portName }),
119
+ to: z.strictObject({ cell: cellId, port: portName }),
120
+ guard: z.strictObject({ equals: z.string().min(1).max(64), field: portName.optional() }).optional(),
121
+ on: z.literal("fail").optional(),
122
+ })
123
+ export type SpatialBehaviorEdge = Readonly<z.infer<typeof SpatialBehaviorEdgeSchema>>
124
+
125
+ const behaviorBudgets = z.strictObject({
126
+ maxSteps: z.number().int().min(1).max(1024).optional(),
127
+ maxAgentCalls: z.number().int().min(0).max(64).optional(),
128
+ maxWork: z.number().int().min(1).max(100_000_000).optional(),
129
+ maxContextBytes: z.number().int().min(1).max(262_144).optional(),
130
+ maxOutputBytes: z.number().int().min(1).max(262_144).optional(),
131
+ maxDepth: z.number().int().min(1).max(8).optional(),
132
+ })
133
+
134
+ const interfaceEndpoint = z.strictObject({ cell: cellId, port: portName })
135
+
136
+ export const SpatialBehaviorOrganismSchema = z.strictObject({
137
+ contract: z.literal("morphogen.organism.v1"),
138
+ key: organismKey,
139
+ name: z.string().min(1).max(120),
140
+ note: z.string().max(2000).optional(),
141
+ budgets: behaviorBudgets.optional(),
142
+ interface: z.strictObject({
143
+ inputs: z.record(interfaceName, interfaceEndpoint),
144
+ outputs: z.record(interfaceName, interfaceEndpoint),
145
+ }),
146
+ cells: z.array(SpatialBehaviorCellSchema).min(1).max(SPATIAL_BEHAVIOR_LIMITS.cells),
147
+ edges: z.array(SpatialBehaviorEdgeSchema).max(SPATIAL_BEHAVIOR_LIMITS.edges),
148
+ }).superRefine((manifest, context) => {
149
+ const ids = new Set<string>()
150
+ for (const [index, cell] of manifest.cells.entries()) {
151
+ if (ids.has(cell.id)) {
152
+ context.addIssue({ code: "custom", message: `Duplicate cell id ${cell.id}.`, path: ["cells", index, "id"] })
153
+ }
154
+ ids.add(cell.id)
155
+ // Producer ports cannot be `many`: declared port maps in the bake-safe
156
+ // subset are all producer-side (input/const outputs).
157
+ if (cell.kind === "input" || cell.kind === "const") {
158
+ for (const [name, port] of Object.entries(cell.outputs)) {
159
+ if (port.many === true) {
160
+ context.addIssue({ code: "custom", message: `Port ${name} declares many, which is only valid on consumer ports.`, path: ["cells", index, "outputs", name, "many"] })
161
+ }
162
+ }
163
+ }
164
+ }
165
+ })
166
+ export type SpatialBehaviorOrganism = Readonly<z.infer<typeof SpatialBehaviorOrganismSchema>>
167
+
168
+ export const SpatialBehaviorSchema = z.strictObject({
169
+ kind: z.literal("slopcamera.spatial-behavior"),
170
+ schemaVersion: z.literal(1),
171
+ behaviorId: z.string().regex(/^behavior_[a-zA-Z0-9_-]{1,64}$/u),
172
+ entityId: z.string().min(1).max(128).regex(/^[a-z][a-z0-9_-]*$/u),
173
+ sceneSha256: SpatialDigestSchema,
174
+ seed: z.number().int().min(0).max(4_294_967_295),
175
+ rangeUs: z.strictObject({ startUs: SpatialTimeUsSchema, endUs: SpatialTimeUsSchema })
176
+ .refine((range) => range.endUs > range.startUs, { message: "rangeUs.endUs must exceed rangeUs.startUs." }),
177
+ organisms: z.record(z.string(), SpatialBehaviorOrganismSchema)
178
+ .refine((organisms) => {
179
+ const keys = Object.keys(organisms)
180
+ return keys.length >= 1 && keys.length <= SPATIAL_BEHAVIOR_LIMITS.organisms
181
+ && keys.every((key) => /^sha256:[a-f0-9]{64}$/u.test(key))
182
+ }, { message: `organisms maps 1–${SPATIAL_BEHAVIOR_LIMITS.organisms} sha256:<64-hex> digests to manifests.` }),
183
+ entry: organismDigest,
184
+ channels: z.array(z.string().regex(/^[a-z][a-z0-9_.-]{0,62}$/u)).min(1).max(SPATIAL_BEHAVIOR_LIMITS.channels),
185
+ args: z.record(interfaceName, z.unknown()).refine(
186
+ (args) => Object.keys(args).length <= SPATIAL_BEHAVIOR_LIMITS.args,
187
+ { message: `At most ${SPATIAL_BEHAVIOR_LIMITS.args} entry args.` },
188
+ ).optional(),
189
+ }).superRefine((behavior, context) => {
190
+ const channelSet = new Set(behavior.channels)
191
+ if (channelSet.size !== behavior.channels.length) {
192
+ context.addIssue({ code: "custom", message: "Behavior channels must be unique.", path: ["channels"] })
193
+ }
194
+ })
195
+ export type SpatialBehavior = Readonly<z.infer<typeof SpatialBehaviorSchema>>
196
+
197
+ export function parseSpatialBehavior(input: unknown): SpatialBehavior {
198
+ return deepFreezeJson(parseSpatialValue(SpatialBehaviorSchema, input, "spatial behavior"))
199
+ }
200
+
201
+ export function spatialBehaviorSha256(behavior: SpatialBehavior): string {
202
+ return spatialValueSha256(behavior)
203
+ }
204
+
205
+ // --------------------------------------------------- organism projection ---
206
+ // Mirrors ALGAL's `manifestToJson` for the bake-safe subset so closure digests
207
+ // rehash exactly against manifests authored with ALGAL tooling. Canonical JSON
208
+ // on both sides is recursively sorted-key JSON.stringify, so
209
+ // `sha256:` + canonical-json-sha256(projection) reproduces the ALGAL
210
+ // `sha256:<hex>` digest byte-for-byte. Kept narrow deliberately: a cell kind
211
+ // outside the subset never reaches this function (the schema rejects it).
212
+
213
+ const ALGAL_DEFAULT_BUDGETS = Object.freeze({
214
+ maxSteps: 256,
215
+ maxAgentCalls: 16,
216
+ maxWork: 1_000_000,
217
+ maxContextBytes: 65_536,
218
+ maxOutputBytes: 65_536,
219
+ maxDepth: 4,
220
+ })
221
+
222
+ type JsonObject = Record<string, unknown>
223
+
224
+ function portTypeJson(port: SpatialBehaviorPortType): JsonObject {
225
+ const out: JsonObject = { type: port.type }
226
+ if (port.optional === true) out.optional = true
227
+ if (port.many === true) out.many = true
228
+ if (port.type === "choice" && port.labels !== undefined) out.labels = [...port.labels]
229
+ if (port.type === "json" && port.schema !== undefined) out.schema = port.schema
230
+ return out
231
+ }
232
+
233
+ function cellJson(cell: SpatialBehaviorCell): JsonObject {
234
+ switch (cell.kind) {
235
+ case "input":
236
+ return { id: cell.id, kind: cell.kind, outputs: portMapJson(cell.outputs) }
237
+ case "const": {
238
+ const outputs: JsonObject = {}
239
+ for (const [name, port] of Object.entries(cell.outputs)) {
240
+ const projected = portTypeJson(port)
241
+ projected.value = port.value
242
+ outputs[name] = projected
243
+ }
244
+ return { id: cell.id, kind: cell.kind, outputs }
245
+ }
246
+ case "fn":
247
+ return { id: cell.id, kind: cell.kind, fn: cell.fn }
248
+ case "organism":
249
+ return { id: cell.id, kind: cell.kind, manifest: cell.manifest }
250
+ case "repeat": {
251
+ const out: JsonObject = { id: cell.id, kind: cell.kind, manifest: cell.manifest, maxRounds: cell.maxRounds }
252
+ if (cell.carry !== undefined) out.carry = { ...cell.carry }
253
+ if (cell.until !== undefined) {
254
+ out.until = {
255
+ output: cell.until.output,
256
+ equals: cell.until.equals,
257
+ ...(cell.until.field === undefined ? {} : { field: cell.until.field }),
258
+ }
259
+ }
260
+ return out
261
+ }
262
+ case "each":
263
+ return { id: cell.id, kind: cell.kind, manifest: cell.manifest, over: cell.over, maxItems: cell.maxItems }
264
+ }
265
+ }
266
+
267
+ function portMapJson(ports: SpatialBehaviorPortMap): JsonObject {
268
+ const out: JsonObject = {}
269
+ for (const [name, port] of Object.entries(ports)) out[name] = portTypeJson(port)
270
+ return out
271
+ }
272
+
273
+ /** Normalized organism projection matching ALGAL `manifestToJson` for the subset. */
274
+ export function behaviorOrganismCanonicalValue(manifest: SpatialBehaviorOrganism): JsonObject {
275
+ const budgets = { ...ALGAL_DEFAULT_BUDGETS, ...(manifest.budgets ?? {}) }
276
+ const out: JsonObject = {
277
+ contract: manifest.contract,
278
+ key: manifest.key,
279
+ name: manifest.name,
280
+ budgets: {
281
+ maxAgentCalls: budgets.maxAgentCalls,
282
+ maxContextBytes: budgets.maxContextBytes,
283
+ maxDepth: budgets.maxDepth,
284
+ maxOutputBytes: budgets.maxOutputBytes,
285
+ maxSteps: budgets.maxSteps,
286
+ maxWork: budgets.maxWork,
287
+ },
288
+ cells: manifest.cells.map(cellJson),
289
+ edges: manifest.edges.map((edge) => ({
290
+ from: { cell: edge.from.cell, port: edge.from.port },
291
+ to: { cell: edge.to.cell, port: edge.to.port },
292
+ ...(edge.on === undefined ? {} : { on: edge.on }),
293
+ ...(edge.guard === undefined
294
+ ? {}
295
+ : {
296
+ guard: {
297
+ equals: edge.guard.equals,
298
+ ...(edge.guard.field === undefined ? {} : { field: edge.guard.field }),
299
+ },
300
+ }),
301
+ })),
302
+ }
303
+ if (manifest.note !== undefined) out.note = manifest.note
304
+ const mapEnds = (endpoints: Readonly<Record<string, { cell: string; port: string }>>) => {
305
+ const mapped: JsonObject = {}
306
+ for (const [name, target] of Object.entries(endpoints)) mapped[name] = { cell: target.cell, port: target.port }
307
+ return mapped
308
+ }
309
+ out.interface = { inputs: mapEnds(manifest.interface.inputs), outputs: mapEnds(manifest.interface.outputs) }
310
+ return out
311
+ }
312
+
313
+ /** ALGAL-form digest (`sha256:<hex>`) of one bake-safe organism manifest. */
314
+ export function behaviorOrganismSha256(manifest: SpatialBehaviorOrganism): string {
315
+ return `sha256:${boundedCanonicalJsonSha256(behaviorOrganismCanonicalValue(manifest), {
316
+ maximumBytes: 262_144,
317
+ name: "behavior organism",
318
+ })}`
319
+ }
320
+
321
+ // ------------------------------------------------------------------ check ---
322
+
323
+ export const SpatialBehaviorFindingCodeSchema = z.enum([
324
+ "stale-digest",
325
+ "unresolved-entity",
326
+ "unresolved-entry",
327
+ "unresolved-manifest",
328
+ "unresolved-fn",
329
+ "closure-digest-mismatch",
330
+ "invalid-wiring",
331
+ "undeclared-arg",
332
+ "range-outside-scene",
333
+ "unreachable-organism",
334
+ "cyclic-composition",
335
+ "unfed-input",
336
+ ])
337
+ export type SpatialBehaviorFindingCode = z.infer<typeof SpatialBehaviorFindingCodeSchema>
338
+
339
+ export const SpatialBehaviorCheckFindingSchema = z.strictObject({
340
+ code: SpatialBehaviorFindingCodeSchema,
341
+ severity: z.enum(["error", "warning"]),
342
+ referenceId: z.string().min(1).max(128).optional(),
343
+ detail: z.string().min(1).max(240),
344
+ })
345
+ export type SpatialBehaviorCheckFinding = Readonly<z.infer<typeof SpatialBehaviorCheckFindingSchema>>
346
+
347
+ export const SpatialBehaviorCheckReportSchema = z.strictObject({
348
+ kind: z.literal("slopcamera.spatial-behavior-check"),
349
+ schemaVersion: z.literal(1),
350
+ behaviorSha256: SpatialDigestSchema,
351
+ sceneSha256: SpatialDigestSchema,
352
+ findings: z.array(SpatialBehaviorCheckFindingSchema).max(SPATIAL_BEHAVIOR_LIMITS.findings),
353
+ counts: z.strictObject({
354
+ organisms: z.number().int().min(0),
355
+ cells: z.number().int().min(0),
356
+ edges: z.number().int().min(0),
357
+ fnRefs: z.number().int().min(0),
358
+ errors: z.number().int().min(0),
359
+ warnings: z.number().int().min(0),
360
+ }),
361
+ })
362
+ export type SpatialBehaviorCheckReport = Readonly<z.infer<typeof SpatialBehaviorCheckReportSchema>>
363
+
364
+ const checkOptionsSchema = z.strictObject({
365
+ behavior: z.unknown(),
366
+ scene: z.unknown(),
367
+ })
368
+
369
+ interface ManifestContext {
370
+ readonly manifest: SpatialBehaviorOrganism
371
+ readonly outputPorts: ReadonlyMap<string, ReadonlySet<string>>
372
+ readonly inputPorts: ReadonlyMap<string, ReadonlySet<string>>
373
+ }
374
+
375
+ function resolveEntityId(scene: SpatialSceneV1, referenceId: string): string | undefined {
376
+ const ids = new Set(scene.entities.map((entity) => entity.entityId))
377
+ if (ids.has(referenceId)) return referenceId
378
+ const prefixed = `entity_${referenceId}`
379
+ return ids.has(prefixed) ? prefixed : undefined
380
+ }
381
+
382
+ /**
383
+ * Validates a behavior document against one scene: closure digest integrity,
384
+ * manifest/fn reference resolution, interface and edge wiring against the
385
+ * admitted fn signatures, entry args, and range bounds. Findings are evidence;
386
+ * nothing executes organism graphs.
387
+ */
388
+ export function checkSpatialBehavior(
389
+ input: unknown,
390
+ admittedFns: ReadonlyMap<string, SpatialBehaviorFnSignature>,
391
+ ): SpatialBehaviorCheckReport {
392
+ const options = parseSpatialValue(checkOptionsSchema, input, "behavior check")
393
+ const behavior = parseSpatialBehavior(options.behavior)
394
+ const scene = parseSpatialScene(options.scene)
395
+ const sceneSha256 = spatialValueSha256(scene)
396
+ const findings: SpatialBehaviorCheckFinding[] = []
397
+ const finding = (code: SpatialBehaviorFindingCode, severity: "error" | "warning", detail: string, referenceId?: string): void => {
398
+ findings.push(deepFreezeJson(referenceId === undefined ? { code, severity, detail } : { code, severity, detail, referenceId }))
399
+ }
400
+
401
+ if (behavior.sceneSha256 !== sceneSha256) {
402
+ finding("stale-digest", "error", `Behavior sceneSha256 ${behavior.sceneSha256} does not match scene digest ${sceneSha256}; re-author or rebind the behavior.`)
403
+ }
404
+ if (resolveEntityId(scene, behavior.entityId) === undefined) {
405
+ finding("unresolved-entity", "error", `Behavior entity ${behavior.entityId} is not a scene entity.`, behavior.entityId)
406
+ }
407
+ if (behavior.rangeUs.endUs > scene.durationUs) {
408
+ finding("range-outside-scene", "error", `Behavior range ends at ${behavior.rangeUs.endUs}us beyond scene duration ${scene.durationUs}us.`)
409
+ }
410
+ if (behavior.organisms[behavior.entry] === undefined) {
411
+ finding("unresolved-entry", "error", `Entry digest ${behavior.entry.slice(0, 24)}… is not a member of the organisms closure.`, behavior.entry.slice(7, 39))
412
+ }
413
+
414
+ let cellTotal = 0
415
+ let edgeTotal = 0
416
+ let fnRefTotal = 0
417
+ const contexts = new Map<string, ManifestContext>()
418
+
419
+ // Pass 1: digest integrity and manifest-reference resolution.
420
+ for (const [digest, manifest] of Object.entries(behavior.organisms)) {
421
+ const recomputed = behaviorOrganismSha256(manifest)
422
+ if (recomputed !== digest) {
423
+ finding("closure-digest-mismatch", "error", `Organisms key ${digest.slice(0, 24)}… rehashes to ${recomputed.slice(0, 24)}…; the manifest changed after admission.`, manifest.key)
424
+ }
425
+ cellTotal += manifest.cells.length
426
+ edgeTotal += manifest.edges.length
427
+ const outputPorts = new Map<string, ReadonlySet<string>>()
428
+ const inputPorts = new Map<string, ReadonlySet<string>>()
429
+ for (const cell of manifest.cells) {
430
+ switch (cell.kind) {
431
+ case "input":
432
+ case "const":
433
+ outputPorts.set(cell.id, new Set(Object.keys(cell.outputs)))
434
+ break
435
+ case "fn": {
436
+ fnRefTotal += 1
437
+ const signature = admittedFns.get(cell.fn)
438
+ if (signature === undefined) {
439
+ finding("unresolved-fn", "error", `Cell ${cell.id} names fn ${cell.fn}, which the admitted behavior catalog does not contain.`, `${manifest.key}/${cell.id}`)
440
+ } else {
441
+ outputPorts.set(cell.id, new Set(Object.keys(signature.outputs)))
442
+ inputPorts.set(cell.id, new Set(Object.keys(signature.inputs)))
443
+ }
444
+ break
445
+ }
446
+ case "repeat":
447
+ case "each":
448
+ case "organism": {
449
+ const child = behavior.organisms[cell.manifest]
450
+ if (child === undefined) {
451
+ finding("unresolved-manifest", "error", `Cell ${cell.id} references manifest ${cell.manifest.slice(0, 24)}…, absent from the organisms closure.`, `${manifest.key}/${cell.id}`)
452
+ } else {
453
+ outputPorts.set(cell.id, new Set(Object.keys(child.interface.outputs)))
454
+ inputPorts.set(cell.id, new Set(Object.keys(child.interface.inputs)))
455
+ }
456
+ break
457
+ }
458
+ }
459
+ }
460
+ contexts.set(digest, { manifest, outputPorts, inputPorts })
461
+ }
462
+
463
+ // Pass 2: wiring inside each manifest.
464
+ for (const context of contexts.values()) {
465
+ const { manifest, outputPorts, inputPorts } = context
466
+ const cellIds = new Set(manifest.cells.map((cell) => cell.id))
467
+ const ref = (cell: string) => `${manifest.key}/${cell}`
468
+
469
+ for (const [name, endpoint] of Object.entries(manifest.interface.inputs)) {
470
+ const cell = manifest.cells.find((candidate) => candidate.id === endpoint.cell)
471
+ if (cell === undefined) {
472
+ finding("invalid-wiring", "error", `Interface input ${name} references absent cell ${endpoint.cell}.`, manifest.key)
473
+ } else if (cell.kind !== "input") {
474
+ finding("invalid-wiring", "error", `Interface input ${name} references ${cell.kind} cell ${cell.id}; only input cells receive entry args.`, ref(cell.id))
475
+ } else if (!(endpoint.port in cell.outputs)) {
476
+ finding("invalid-wiring", "error", `Interface input ${name} names undeclared port ${endpoint.port} on input cell ${cell.id}.`, ref(cell.id))
477
+ }
478
+ }
479
+ for (const [name, endpoint] of Object.entries(manifest.interface.outputs)) {
480
+ if (!cellIds.has(endpoint.cell)) {
481
+ finding("invalid-wiring", "error", `Interface output ${name} references absent cell ${endpoint.cell}.`, manifest.key)
482
+ } else if (!outputPorts.get(endpoint.cell)?.has(endpoint.port)) {
483
+ finding("invalid-wiring", "error", `Interface output ${name} names undeclared output port ${endpoint.port} on cell ${endpoint.cell}.`, ref(endpoint.cell))
484
+ }
485
+ }
486
+
487
+ for (const cell of manifest.cells) {
488
+ if (cell.kind === "repeat" || cell.kind === "each" || cell.kind === "organism") {
489
+ const child = behavior.organisms[cell.manifest]
490
+ if (child !== undefined) {
491
+ if (cell.kind === "repeat") {
492
+ for (const [outName, inName] of Object.entries(cell.carry ?? {})) {
493
+ if (!(outName in child.interface.outputs)) {
494
+ finding("invalid-wiring", "error", `Repeat ${cell.id} carries undeclared output ${outName} from ${child.key}.`, ref(cell.id))
495
+ }
496
+ if (!(inName in child.interface.inputs)) {
497
+ finding("invalid-wiring", "error", `Repeat ${cell.id} carries into undeclared input ${inName} on ${child.key}.`, ref(cell.id))
498
+ }
499
+ }
500
+ if (cell.until !== undefined && !(cell.until.output in child.interface.outputs)) {
501
+ finding("invalid-wiring", "error", `Repeat ${cell.id} until watches undeclared output ${cell.until.output} on ${child.key}.`, ref(cell.id))
502
+ }
503
+ }
504
+ if (cell.kind === "each" && !(cell.over in child.interface.inputs)) {
505
+ finding("invalid-wiring", "error", `Each ${cell.id} over names undeclared input ${cell.over} on ${child.key}.`, ref(cell.id))
506
+ }
507
+ // Carried inputs are fed by carry values, not edges; `over` gets its
508
+ // own dedicated check below.
509
+ const carried = cell.kind === "repeat" ? new Set(Object.values(cell.carry ?? {})) : new Set<string>()
510
+ for (const input of Object.keys(child.interface.inputs)) {
511
+ if (carried.has(input)) continue
512
+ if (cell.kind === "each" && input === cell.over) continue
513
+ const fed = manifest.edges.some((edge) => edge.to.cell === cell.id && edge.to.port === input)
514
+ if (!fed) {
515
+ finding("unfed-input", "warning", `${cell.kind} cell ${cell.id} leaves interface input ${input} of ${child.key} without a delivering edge; the input cell produces no value.`, ref(cell.id))
516
+ }
517
+ }
518
+ }
519
+ }
520
+ if (cell.kind === "each") {
521
+ const delivered = manifest.edges.some((edge) => edge.to.cell === cell.id && edge.to.port === cell.over)
522
+ if (!delivered) {
523
+ finding("invalid-wiring", "error", `Each ${cell.id} over port ${cell.over} has no delivering edge; the list can never arrive.`, ref(cell.id))
524
+ }
525
+ }
526
+ }
527
+
528
+ for (const [index, edge] of manifest.edges.entries()) {
529
+ if (!cellIds.has(edge.from.cell)) {
530
+ finding("invalid-wiring", "error", `Edge ${index} from references absent cell ${edge.from.cell}.`, manifest.key)
531
+ } else if (!outputPorts.get(edge.from.cell)?.has(edge.from.port)) {
532
+ finding("invalid-wiring", "error", `Edge ${index} names undeclared output port ${edge.from.port} on cell ${edge.from.cell}.`, ref(edge.from.cell))
533
+ }
534
+ if (!cellIds.has(edge.to.cell)) {
535
+ finding("invalid-wiring", "error", `Edge ${index} to references absent cell ${edge.to.cell}.`, manifest.key)
536
+ } else if (!inputPorts.get(edge.to.cell)?.has(edge.to.port)) {
537
+ finding("invalid-wiring", "error", `Edge ${index} names undeclared input port ${edge.to.port} on cell ${edge.to.cell}; input and const cells have no input ports.`, ref(edge.to.cell))
538
+ }
539
+ }
540
+ }
541
+
542
+ // Manifest-reference cycles can never terminate: nested invocation recurses
543
+ // until the runtime depth budget rejects the run. Flag them statically.
544
+ const visiting = new Set<string>()
545
+ const done = new Set<string>()
546
+ const detectCycles = (digest: string): void => {
547
+ if (done.has(digest)) return
548
+ if (visiting.has(digest)) {
549
+ const context = contexts.get(digest)
550
+ finding("cyclic-composition", "error", `Organism ${context?.manifest.key ?? digest.slice(0, 24)} participates in a manifest-reference cycle that cannot terminate within any depth budget.`, context?.manifest.key)
551
+ return
552
+ }
553
+ visiting.add(digest)
554
+ const context = contexts.get(digest)
555
+ if (context !== undefined) {
556
+ for (const cell of context.manifest.cells) {
557
+ if (cell.kind === "repeat" || cell.kind === "each" || cell.kind === "organism") detectCycles(cell.manifest)
558
+ }
559
+ }
560
+ visiting.delete(digest)
561
+ done.add(digest)
562
+ }
563
+ for (const digest of contexts.keys()) detectCycles(digest)
564
+
565
+ // Reachability from the entry manifest through composition cells.
566
+ const reachable = new Set<string>()
567
+ const walk = (digest: string): void => {
568
+ if (reachable.has(digest)) return
569
+ const context = contexts.get(digest)
570
+ if (context === undefined) return
571
+ reachable.add(digest)
572
+ for (const cell of context.manifest.cells) {
573
+ if (cell.kind === "repeat" || cell.kind === "each" || cell.kind === "organism") walk(cell.manifest)
574
+ }
575
+ }
576
+ walk(behavior.entry)
577
+ for (const [entryDigest, context] of contexts) {
578
+ if (!reachable.has(entryDigest)) {
579
+ finding("unreachable-organism", "warning", `Organism ${context.manifest.key} is in the closure but unreachable from the entry manifest.`, context.manifest.key)
580
+ }
581
+ }
582
+
583
+ // Entry args must bind declared interface inputs; unfed inputs warn.
584
+ const entryContext = contexts.get(behavior.entry)
585
+ if (entryContext !== undefined) {
586
+ const argNames = new Set(Object.keys(behavior.args ?? {}))
587
+ for (const name of Object.keys(behavior.args ?? {})) {
588
+ if (!(name in entryContext.manifest.interface.inputs)) {
589
+ finding("undeclared-arg", "error", `Arg ${name} is not a declared interface input of entry organism ${entryContext.manifest.key}.`, `${entryContext.manifest.key}/${name}`)
590
+ }
591
+ }
592
+ for (const name of Object.keys(entryContext.manifest.interface.inputs)) {
593
+ // `seed` is fed by convention: the bake binds behavior.seed to an entry
594
+ // interface input named `seed` when no arg supplies one.
595
+ if (!argNames.has(name) && name !== "seed") {
596
+ finding("unfed-input", "warning", `Entry organism ${entryContext.manifest.key} declares interface input ${name} but no arg binds it; the input cell produces no value.`, `${entryContext.manifest.key}/${name}`)
597
+ }
598
+ }
599
+ }
600
+
601
+ const errors = findings.filter((item) => item.severity === "error").length
602
+ return deepFreezeJson(SpatialBehaviorCheckReportSchema.parse({
603
+ kind: "slopcamera.spatial-behavior-check",
604
+ schemaVersion: 1,
605
+ behaviorSha256: spatialBehaviorSha256(behavior),
606
+ sceneSha256,
607
+ findings,
608
+ counts: {
609
+ organisms: Object.keys(behavior.organisms).length,
610
+ cells: cellTotal,
611
+ edges: edgeTotal,
612
+ fnRefs: fnRefTotal,
613
+ errors,
614
+ warnings: findings.length - errors,
615
+ },
616
+ }))
617
+ }