@fundamental-engine/core 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/agents/element-agent.d.ts +38 -0
  4. package/dist/agents/element-agent.d.ts.map +1 -0
  5. package/dist/agents/element-agent.js +70 -0
  6. package/dist/agents/element-agent.js.map +1 -0
  7. package/dist/agents/event-agent.d.ts +47 -0
  8. package/dist/agents/event-agent.d.ts.map +1 -0
  9. package/dist/agents/event-agent.js +82 -0
  10. package/dist/agents/event-agent.js.map +1 -0
  11. package/dist/agents/index.d.ts +17 -0
  12. package/dist/agents/index.d.ts.map +1 -0
  13. package/dist/agents/index.js +57 -0
  14. package/dist/agents/index.js.map +1 -0
  15. package/dist/agents/region-agents.d.ts +40 -0
  16. package/dist/agents/region-agents.d.ts.map +1 -0
  17. package/dist/agents/region-agents.js +22 -0
  18. package/dist/agents/region-agents.js.map +1 -0
  19. package/dist/agents/relationship.d.ts +55 -0
  20. package/dist/agents/relationship.d.ts.map +1 -0
  21. package/dist/agents/relationship.js +40 -0
  22. package/dist/agents/relationship.js.map +1 -0
  23. package/dist/agents/user-agent.d.ts +57 -0
  24. package/dist/agents/user-agent.d.ts.map +1 -0
  25. package/dist/agents/user-agent.js +45 -0
  26. package/dist/agents/user-agent.js.map +1 -0
  27. package/dist/config/forces.config.d.ts +101 -0
  28. package/dist/config/forces.config.d.ts.map +1 -0
  29. package/dist/config/forces.config.js +239 -0
  30. package/dist/config/forces.config.js.map +1 -0
  31. package/dist/config/manual.d.ts +134 -0
  32. package/dist/config/manual.d.ts.map +1 -0
  33. package/dist/config/manual.js +604 -0
  34. package/dist/config/manual.js.map +1 -0
  35. package/dist/config/palettes.d.ts +18 -0
  36. package/dist/config/palettes.d.ts.map +1 -0
  37. package/dist/config/palettes.js +34 -0
  38. package/dist/config/palettes.js.map +1 -0
  39. package/dist/config/presets.d.ts +48 -0
  40. package/dist/config/presets.d.ts.map +1 -0
  41. package/dist/config/presets.js +87 -0
  42. package/dist/config/presets.js.map +1 -0
  43. package/dist/config/tokens.d.ts +3 -0
  44. package/dist/config/tokens.d.ts.map +1 -0
  45. package/dist/config/tokens.js +16 -0
  46. package/dist/config/tokens.js.map +1 -0
  47. package/dist/conformance/expectations.d.ts +40 -0
  48. package/dist/conformance/expectations.d.ts.map +1 -0
  49. package/dist/conformance/expectations.js +347 -0
  50. package/dist/conformance/expectations.js.map +1 -0
  51. package/dist/conformance/experiments.d.ts +17 -0
  52. package/dist/conformance/experiments.d.ts.map +1 -0
  53. package/dist/conformance/experiments.js +875 -0
  54. package/dist/conformance/experiments.js.map +1 -0
  55. package/dist/conformance/run.d.ts +18 -0
  56. package/dist/conformance/run.d.ts.map +1 -0
  57. package/dist/conformance/run.js +240 -0
  58. package/dist/conformance/run.js.map +1 -0
  59. package/dist/conformance/types.d.ts +100 -0
  60. package/dist/conformance/types.d.ts.map +1 -0
  61. package/dist/conformance/types.js +2 -0
  62. package/dist/conformance/types.js.map +1 -0
  63. package/dist/contracts/guards.d.ts +51 -0
  64. package/dist/contracts/guards.d.ts.map +1 -0
  65. package/dist/contracts/guards.js +100 -0
  66. package/dist/contracts/guards.js.map +1 -0
  67. package/dist/contracts/index.d.ts +18 -0
  68. package/dist/contracts/index.d.ts.map +1 -0
  69. package/dist/contracts/index.js +107 -0
  70. package/dist/contracts/index.js.map +1 -0
  71. package/dist/contracts/passport.d.ts +88 -0
  72. package/dist/contracts/passport.d.ts.map +1 -0
  73. package/dist/contracts/passport.js +135 -0
  74. package/dist/contracts/passport.js.map +1 -0
  75. package/dist/contracts/types.d.ts +120 -0
  76. package/dist/contracts/types.d.ts.map +1 -0
  77. package/dist/contracts/types.js +24 -0
  78. package/dist/contracts/types.js.map +1 -0
  79. package/dist/core/accretion.d.ts +50 -0
  80. package/dist/core/accretion.d.ts.map +1 -0
  81. package/dist/core/accretion.js +98 -0
  82. package/dist/core/accretion.js.map +1 -0
  83. package/dist/core/agents.d.ts +31 -0
  84. package/dist/core/agents.d.ts.map +1 -0
  85. package/dist/core/agents.js +51 -0
  86. package/dist/core/agents.js.map +1 -0
  87. package/dist/core/attention.d.ts +72 -0
  88. package/dist/core/attention.d.ts.map +1 -0
  89. package/dist/core/attention.js +122 -0
  90. package/dist/core/attention.js.map +1 -0
  91. package/dist/core/causality.d.ts +38 -0
  92. package/dist/core/causality.d.ts.map +1 -0
  93. package/dist/core/causality.js +64 -0
  94. package/dist/core/causality.js.map +1 -0
  95. package/dist/core/conditions.d.ts +10 -0
  96. package/dist/core/conditions.d.ts.map +1 -0
  97. package/dist/core/conditions.js +22 -0
  98. package/dist/core/conditions.js.map +1 -0
  99. package/dist/core/currents.d.ts +53 -0
  100. package/dist/core/currents.d.ts.map +1 -0
  101. package/dist/core/currents.js +65 -0
  102. package/dist/core/currents.js.map +1 -0
  103. package/dist/core/dock.d.ts +35 -0
  104. package/dist/core/dock.d.ts.map +1 -0
  105. package/dist/core/dock.js +39 -0
  106. package/dist/core/dock.js.map +1 -0
  107. package/dist/core/events.d.ts +23 -0
  108. package/dist/core/events.d.ts.map +1 -0
  109. package/dist/core/events.js +34 -0
  110. package/dist/core/events.js.map +1 -0
  111. package/dist/core/feedback-sink.d.ts +32 -0
  112. package/dist/core/feedback-sink.d.ts.map +1 -0
  113. package/dist/core/feedback-sink.js +53 -0
  114. package/dist/core/feedback-sink.js.map +1 -0
  115. package/dist/core/feedback.d.ts +11 -0
  116. package/dist/core/feedback.d.ts.map +1 -0
  117. package/dist/core/feedback.js +16 -0
  118. package/dist/core/feedback.js.map +1 -0
  119. package/dist/core/field-store.d.ts +26 -0
  120. package/dist/core/field-store.d.ts.map +1 -0
  121. package/dist/core/field-store.js +54 -0
  122. package/dist/core/field-store.js.map +1 -0
  123. package/dist/core/field.d.ts +18 -0
  124. package/dist/core/field.d.ts.map +1 -0
  125. package/dist/core/field.js +1943 -0
  126. package/dist/core/field.js.map +1 -0
  127. package/dist/core/fieldline-seeds.d.ts +25 -0
  128. package/dist/core/fieldline-seeds.d.ts.map +1 -0
  129. package/dist/core/fieldline-seeds.js +32 -0
  130. package/dist/core/fieldline-seeds.js.map +1 -0
  131. package/dist/core/fieldlines.d.ts +75 -0
  132. package/dist/core/fieldlines.d.ts.map +1 -0
  133. package/dist/core/fieldlines.js +111 -0
  134. package/dist/core/fieldlines.js.map +1 -0
  135. package/dist/core/flow.d.ts +38 -0
  136. package/dist/core/flow.d.ts.map +1 -0
  137. package/dist/core/flow.js +27 -0
  138. package/dist/core/flow.js.map +1 -0
  139. package/dist/core/formations.d.ts +11 -0
  140. package/dist/core/formations.d.ts.map +1 -0
  141. package/dist/core/formations.js +22 -0
  142. package/dist/core/formations.js.map +1 -0
  143. package/dist/core/geometry.d.ts +67 -0
  144. package/dist/core/geometry.d.ts.map +1 -0
  145. package/dist/core/geometry.js +68 -0
  146. package/dist/core/geometry.js.map +1 -0
  147. package/dist/core/heatmap.d.ts +22 -0
  148. package/dist/core/heatmap.d.ts.map +1 -0
  149. package/dist/core/heatmap.js +55 -0
  150. package/dist/core/heatmap.js.map +1 -0
  151. package/dist/core/host.d.ts +46 -0
  152. package/dist/core/host.d.ts.map +1 -0
  153. package/dist/core/host.js +11 -0
  154. package/dist/core/host.js.map +1 -0
  155. package/dist/core/integrator.d.ts +24 -0
  156. package/dist/core/integrator.d.ts.map +1 -0
  157. package/dist/core/integrator.js +375 -0
  158. package/dist/core/integrator.js.map +1 -0
  159. package/dist/core/math.d.ts +37 -0
  160. package/dist/core/math.d.ts.map +1 -0
  161. package/dist/core/math.js +77 -0
  162. package/dist/core/math.js.map +1 -0
  163. package/dist/core/reactions.d.ts +32 -0
  164. package/dist/core/reactions.d.ts.map +1 -0
  165. package/dist/core/reactions.js +45 -0
  166. package/dist/core/reactions.js.map +1 -0
  167. package/dist/core/registry.d.ts +13 -0
  168. package/dist/core/registry.d.ts.map +1 -0
  169. package/dist/core/registry.js +20 -0
  170. package/dist/core/registry.js.map +1 -0
  171. package/dist/core/render-backend.d.ts +46 -0
  172. package/dist/core/render-backend.d.ts.map +1 -0
  173. package/dist/core/render-backend.js +75 -0
  174. package/dist/core/render-backend.js.map +1 -0
  175. package/dist/core/render-modes.d.ts +42 -0
  176. package/dist/core/render-modes.d.ts.map +1 -0
  177. package/dist/core/render-modes.js +141 -0
  178. package/dist/core/render-modes.js.map +1 -0
  179. package/dist/core/reservoir.d.ts +43 -0
  180. package/dist/core/reservoir.d.ts.map +1 -0
  181. package/dist/core/reservoir.js +207 -0
  182. package/dist/core/reservoir.js.map +1 -0
  183. package/dist/core/scalar-grid.d.ts +51 -0
  184. package/dist/core/scalar-grid.d.ts.map +1 -0
  185. package/dist/core/scalar-grid.js +146 -0
  186. package/dist/core/scalar-grid.js.map +1 -0
  187. package/dist/core/scanner.d.ts +59 -0
  188. package/dist/core/scanner.d.ts.map +1 -0
  189. package/dist/core/scanner.js +260 -0
  190. package/dist/core/scanner.js.map +1 -0
  191. package/dist/core/shadow.d.ts +69 -0
  192. package/dist/core/shadow.d.ts.map +1 -0
  193. package/dist/core/shadow.js +84 -0
  194. package/dist/core/shadow.js.map +1 -0
  195. package/dist/core/spatial-hash.d.ts +30 -0
  196. package/dist/core/spatial-hash.d.ts.map +1 -0
  197. package/dist/core/spatial-hash.js +64 -0
  198. package/dist/core/spatial-hash.js.map +1 -0
  199. package/dist/core/streamlines.d.ts +29 -0
  200. package/dist/core/streamlines.d.ts.map +1 -0
  201. package/dist/core/streamlines.js +70 -0
  202. package/dist/core/streamlines.js.map +1 -0
  203. package/dist/core/surface.d.ts +19 -0
  204. package/dist/core/surface.d.ts.map +1 -0
  205. package/dist/core/surface.js +21 -0
  206. package/dist/core/surface.js.map +1 -0
  207. package/dist/core/temporal.d.ts +110 -0
  208. package/dist/core/temporal.d.ts.map +1 -0
  209. package/dist/core/temporal.js +139 -0
  210. package/dist/core/temporal.js.map +1 -0
  211. package/dist/core/thermo.d.ts +48 -0
  212. package/dist/core/thermo.d.ts.map +1 -0
  213. package/dist/core/thermo.js +48 -0
  214. package/dist/core/thermo.js.map +1 -0
  215. package/dist/core/types.d.ts +610 -0
  216. package/dist/core/types.d.ts.map +1 -0
  217. package/dist/core/types.js +2 -0
  218. package/dist/core/types.js.map +1 -0
  219. package/dist/core/weights.d.ts +111 -0
  220. package/dist/core/weights.d.ts.map +1 -0
  221. package/dist/core/weights.js +128 -0
  222. package/dist/core/weights.js.map +1 -0
  223. package/dist/diagnostics/energy.d.ts +21 -0
  224. package/dist/diagnostics/energy.d.ts.map +1 -0
  225. package/dist/diagnostics/energy.js +27 -0
  226. package/dist/diagnostics/energy.js.map +1 -0
  227. package/dist/diagnostics/fields.d.ts +23 -0
  228. package/dist/diagnostics/fields.d.ts.map +1 -0
  229. package/dist/diagnostics/fields.js +30 -0
  230. package/dist/diagnostics/fields.js.map +1 -0
  231. package/dist/diagnostics/index.d.ts +46 -0
  232. package/dist/diagnostics/index.d.ts.map +1 -0
  233. package/dist/diagnostics/index.js +23 -0
  234. package/dist/diagnostics/index.js.map +1 -0
  235. package/dist/diagnostics/modes.d.ts +108 -0
  236. package/dist/diagnostics/modes.d.ts.map +1 -0
  237. package/dist/diagnostics/modes.js +181 -0
  238. package/dist/diagnostics/modes.js.map +1 -0
  239. package/dist/diagnostics/potential.d.ts +30 -0
  240. package/dist/diagnostics/potential.d.ts.map +1 -0
  241. package/dist/diagnostics/potential.js +43 -0
  242. package/dist/diagnostics/potential.js.map +1 -0
  243. package/dist/diagnostics/probes.d.ts +31 -0
  244. package/dist/diagnostics/probes.d.ts.map +1 -0
  245. package/dist/diagnostics/probes.js +61 -0
  246. package/dist/diagnostics/probes.js.map +1 -0
  247. package/dist/diagnostics/render.d.ts +49 -0
  248. package/dist/diagnostics/render.d.ts.map +1 -0
  249. package/dist/diagnostics/render.js +132 -0
  250. package/dist/diagnostics/render.js.map +1 -0
  251. package/dist/export.d.ts +18 -0
  252. package/dist/export.d.ts.map +1 -0
  253. package/dist/export.js +17 -0
  254. package/dist/export.js.map +1 -0
  255. package/dist/forces/extended.d.ts +121 -0
  256. package/dist/forces/extended.d.ts.map +1 -0
  257. package/dist/forces/extended.js +674 -0
  258. package/dist/forces/extended.js.map +1 -0
  259. package/dist/forces/index.d.ts +33 -0
  260. package/dist/forces/index.d.ts.map +1 -0
  261. package/dist/forces/index.js +237 -0
  262. package/dist/forces/index.js.map +1 -0
  263. package/dist/forces/natural.d.ts +106 -0
  264. package/dist/forces/natural.d.ts.map +1 -0
  265. package/dist/forces/natural.js +385 -0
  266. package/dist/forces/natural.js.map +1 -0
  267. package/dist/index.d.ts +59 -0
  268. package/dist/index.d.ts.map +1 -0
  269. package/dist/index.js +71 -0
  270. package/dist/index.js.map +1 -0
  271. package/dist/inspect/budget.d.ts +17 -0
  272. package/dist/inspect/budget.d.ts.map +1 -0
  273. package/dist/inspect/budget.js +19 -0
  274. package/dist/inspect/budget.js.map +1 -0
  275. package/dist/inspect/index.d.ts +10 -0
  276. package/dist/inspect/index.d.ts.map +1 -0
  277. package/dist/inspect/index.js +10 -0
  278. package/dist/inspect/index.js.map +1 -0
  279. package/dist/inspect/report.d.ts +17 -0
  280. package/dist/inspect/report.d.ts.map +1 -0
  281. package/dist/inspect/report.js +44 -0
  282. package/dist/inspect/report.js.map +1 -0
  283. package/dist/inspect/snapshot.d.ts +21 -0
  284. package/dist/inspect/snapshot.d.ts.map +1 -0
  285. package/dist/inspect/snapshot.js +30 -0
  286. package/dist/inspect/snapshot.js.map +1 -0
  287. package/dist/recipes/catalog.d.ts +51 -0
  288. package/dist/recipes/catalog.d.ts.map +1 -0
  289. package/dist/recipes/catalog.js +1496 -0
  290. package/dist/recipes/catalog.js.map +1 -0
  291. package/dist/recipes/charge.d.ts +18 -0
  292. package/dist/recipes/charge.d.ts.map +1 -0
  293. package/dist/recipes/charge.js +27 -0
  294. package/dist/recipes/charge.js.map +1 -0
  295. package/dist/recipes/compile.d.ts +93 -0
  296. package/dist/recipes/compile.d.ts.map +1 -0
  297. package/dist/recipes/compile.js +113 -0
  298. package/dist/recipes/compile.js.map +1 -0
  299. package/dist/recipes/explain.d.ts +8 -0
  300. package/dist/recipes/explain.d.ts.map +1 -0
  301. package/dist/recipes/explain.js +46 -0
  302. package/dist/recipes/explain.js.map +1 -0
  303. package/dist/recipes/gallery.d.ts +6 -0
  304. package/dist/recipes/gallery.d.ts.map +1 -0
  305. package/dist/recipes/gallery.js +6 -0
  306. package/dist/recipes/gallery.js.map +1 -0
  307. package/dist/recipes/gravity.d.ts +16 -0
  308. package/dist/recipes/gravity.d.ts.map +1 -0
  309. package/dist/recipes/gravity.js +27 -0
  310. package/dist/recipes/gravity.js.map +1 -0
  311. package/dist/recipes/index.d.ts +18 -0
  312. package/dist/recipes/index.d.ts.map +1 -0
  313. package/dist/recipes/index.js +36 -0
  314. package/dist/recipes/index.js.map +1 -0
  315. package/dist/recipes/intent.d.ts +44 -0
  316. package/dist/recipes/intent.d.ts.map +1 -0
  317. package/dist/recipes/intent.js +46 -0
  318. package/dist/recipes/intent.js.map +1 -0
  319. package/dist/recipes/schema.d.ts +103 -0
  320. package/dist/recipes/schema.d.ts.map +1 -0
  321. package/dist/recipes/schema.js +123 -0
  322. package/dist/recipes/schema.js.map +1 -0
  323. package/dist/recipes/wayfinding.d.ts +39 -0
  324. package/dist/recipes/wayfinding.d.ts.map +1 -0
  325. package/dist/recipes/wayfinding.js +77 -0
  326. package/dist/recipes/wayfinding.js.map +1 -0
  327. package/dist/semantic/index.d.ts +13 -0
  328. package/dist/semantic/index.d.ts.map +1 -0
  329. package/dist/semantic/index.js +31 -0
  330. package/dist/semantic/index.js.map +1 -0
  331. package/dist/semantic/layers.d.ts +24 -0
  332. package/dist/semantic/layers.d.ts.map +1 -0
  333. package/dist/semantic/layers.js +27 -0
  334. package/dist/semantic/layers.js.map +1 -0
  335. package/dist/semantic/materials.d.ts +20 -0
  336. package/dist/semantic/materials.d.ts.map +1 -0
  337. package/dist/semantic/materials.js +17 -0
  338. package/dist/semantic/materials.js.map +1 -0
  339. package/dist/semantic/states.d.ts +11 -0
  340. package/dist/semantic/states.d.ts.map +1 -0
  341. package/dist/semantic/states.js +26 -0
  342. package/dist/semantic/states.js.map +1 -0
  343. package/dist/visual/channels.d.ts +71 -0
  344. package/dist/visual/channels.d.ts.map +1 -0
  345. package/dist/visual/channels.js +70 -0
  346. package/dist/visual/channels.js.map +1 -0
  347. package/dist/visual/index.d.ts +39 -0
  348. package/dist/visual/index.d.ts.map +1 -0
  349. package/dist/visual/index.js +30 -0
  350. package/dist/visual/index.js.map +1 -0
  351. package/dist/visual/lint.d.ts +41 -0
  352. package/dist/visual/lint.d.ts.map +1 -0
  353. package/dist/visual/lint.js +58 -0
  354. package/dist/visual/lint.js.map +1 -0
  355. package/dist/visual/mapping.d.ts +13 -0
  356. package/dist/visual/mapping.d.ts.map +1 -0
  357. package/dist/visual/mapping.js +43 -0
  358. package/dist/visual/mapping.js.map +1 -0
  359. package/dist/visual/semantic-text.d.ts +28 -0
  360. package/dist/visual/semantic-text.d.ts.map +1 -0
  361. package/dist/visual/semantic-text.js +36 -0
  362. package/dist/visual/semantic-text.js.map +1 -0
  363. package/dist/visual/tokens.d.ts +23 -0
  364. package/dist/visual/tokens.d.ts.map +1 -0
  365. package/dist/visual/tokens.js +54 -0
  366. package/dist/visual/tokens.js.map +1 -0
  367. package/dist/visual/visualization.d.ts +31 -0
  368. package/dist/visual/visualization.d.ts.map +1 -0
  369. package/dist/visual/visualization.js +47 -0
  370. package/dist/visual/visualization.js.map +1 -0
  371. package/package.json +60 -0
@@ -0,0 +1,875 @@
1
+ import { adoptsTint, approachesBody, endsFartherOut, exactDelta, followsGradient, gatesOutsideCone, modulatesStrength, momentumConserved, movesAway, movesToward, noEffectBeyondRange, recedesFromBody, separates, speedPreserved, speedReduced, unaffectedWhenNeutral, } from "./expectations.js";
2
+ const f3 = (n) => (Math.abs(n) < 1e-9 ? '0' : n.toFixed(3));
3
+ /** Build an inline one-off expectation. */
4
+ function check(label, kind, fn) {
5
+ return { label, kind, check: fn };
6
+ }
7
+ const headingAngle = (vx, vy) => Math.atan2(vy, vx);
8
+ const gap = (r, frame, i, j) => {
9
+ const a = r.trajectory[frame][i];
10
+ const b = r.trajectory[frame][j];
11
+ return Math.hypot(a.x - b.x, a.y - b.y);
12
+ };
13
+ export const EXPERIMENTS = [
14
+ // ── canonical nine ────────────────────────────────────────────────────────
15
+ {
16
+ scenario: {
17
+ force: 'attract',
18
+ label: 'A particle 150px from an attractor',
19
+ family: 'canonical',
20
+ klass: 'A',
21
+ body: { cx: 150, range: 300, strength: 1 },
22
+ particles: [{ x: 0, y: 0 }],
23
+ frames: 60,
24
+ },
25
+ expectations: [movesToward(), exactDelta(0.125, 0), approachesBody(), noEffectBeyondRange()],
26
+ },
27
+ {
28
+ scenario: {
29
+ force: 'repel',
30
+ label: 'A particle 150px from a repeller',
31
+ family: 'canonical',
32
+ klass: 'A',
33
+ body: { cx: 150, range: 300, strength: 1 },
34
+ particles: [{ x: 0, y: 0 }],
35
+ frames: 60,
36
+ },
37
+ expectations: [movesAway(), exactDelta(-0.125, 0), recedesFromBody(), noEffectBeyondRange()],
38
+ },
39
+ {
40
+ scenario: {
41
+ force: 'swirl',
42
+ label: 'A particle 150px from a swirl (spin +1)',
43
+ family: 'canonical',
44
+ klass: 'A',
45
+ body: { cx: 150, range: 300, strength: 1, spin: 1 },
46
+ particles: [{ x: 0, y: 0 }],
47
+ frames: 120, // long enough to show the swirl arc (canonical swirl retains shape lightly)
48
+ },
49
+ expectations: [
50
+ // canonical swirl is swirl-dominant with only light inward retention (0.12); it does
51
+ // NOT bind into an inward spiral — that is a preset (whirlpool / blackhole). So the
52
+ // check is tangential dominance, not movesToward.
53
+ exactDelta(0.020462, -0.170518, 2e-3), // first-frame Δv: tangential −0.1705 ≫ inward 0.0205
54
+ check('the swirl dominates the inward pull (§6.8)', 'invariant', (r) => {
55
+ const d = r.applyDelta[0];
56
+ const tangential = Math.abs(d.dvy);
57
+ const inward = Math.abs(d.dvx);
58
+ return {
59
+ pass: tangential > inward * 4,
60
+ measured: `|Δvᵧ| / |Δvₓ| = ${f3(tangential / Math.max(inward, 1e-9))}`,
61
+ expected: 'tangential > 4× inward',
62
+ };
63
+ }),
64
+ noEffectBeyondRange(),
65
+ ],
66
+ },
67
+ {
68
+ scenario: {
69
+ force: 'stream',
70
+ label: 'A particle in a stream along +x',
71
+ family: 'canonical',
72
+ klass: 'A',
73
+ body: { cx: 150, range: 300, strength: 1, angle: 0 },
74
+ particles: [{ x: 0, y: 0 }],
75
+ frames: 60,
76
+ },
77
+ expectations: [exactDelta(0.233258, 0, 1e-3), approachesBody(), noEffectBeyondRange()],
78
+ },
79
+ {
80
+ scenario: {
81
+ force: 'viscosity',
82
+ label: 'A moving particle entering a viscosity field',
83
+ family: 'canonical',
84
+ klass: 'A',
85
+ body: { cx: 150, range: 300, strength: 1 },
86
+ particles: [{ x: 0, y: 0, vx: 5, vy: 0 }],
87
+ frames: 30,
88
+ },
89
+ expectations: [
90
+ speedReduced(),
91
+ exactDelta(-0.3, 0),
92
+ check('direction unchanged (no redirection)', 'invariant', (r) => {
93
+ // viscosity is v -= v·k, so Δv is anti-parallel to v: it has NO perpendicular
94
+ // component, at any velocity (not just horizontal motion).
95
+ const a = r.applyDelta[0];
96
+ const p0 = r.scenario.particles[0];
97
+ const vx = p0.vx ?? 0, vy = p0.vy ?? 0;
98
+ const speed = Math.hypot(vx, vy) || 1;
99
+ const cross = (a.dvx * vy - a.dvy * vx) / speed; // ⟂ component of Δv ⇒ redirection
100
+ return { pass: Math.abs(cross) < 1e-6, measured: `⟂ Δv = ${f3(cross)}`, expected: '0 (no redirect)' };
101
+ }),
102
+ ],
103
+ },
104
+ {
105
+ scenario: {
106
+ force: 'jet',
107
+ label: 'A particle relaunched from the jet nozzle (the jet)',
108
+ family: 'canonical',
109
+ klass: 'A',
110
+ body: { cx: 0, cy: 0, range: 300, strength: 1, angle: 0 }, // jet heading +x
111
+ particles: [{ x: -15, y: 0 }], // inside the nozzle (d < 24) → relaunched outward
112
+ frames: 40,
113
+ seed: 3, // the jet's spread cone uses RNG — seed it for reproducibility
114
+ },
115
+ expectations: [
116
+ check('relaunched as a fast jet (not a gentle pull)', 'invariant', (r) => {
117
+ const a = r.applyDelta[0];
118
+ const sp = Math.hypot(a.dvx, a.dvy);
119
+ return { pass: sp > 2, measured: `|Δv| = ${f3(sp)}`, expected: '> 2 (a jet, not the feed)' };
120
+ }),
121
+ check('ejected along the heading', 'invariant', (r) => {
122
+ const a = r.applyDelta[0];
123
+ const sp = Math.hypot(a.dvx, a.dvy) || 1;
124
+ const along = (a.dvx * r.body.ux + a.dvy * r.body.uy) / sp;
125
+ return { pass: along > 0.7, measured: `along ${f3(along)}`, expected: '> 0.7 (follows the nozzle heading)' };
126
+ }),
127
+ recedesFromBody(),
128
+ ],
129
+ },
130
+ {
131
+ scenario: {
132
+ force: 'tether',
133
+ label: 'A particle inside a tether rest shell (compressed)',
134
+ family: 'canonical',
135
+ klass: 'A',
136
+ body: { cx: 150, range: 300, strength: 1 },
137
+ particles: [{ x: 0, y: 0 }],
138
+ frames: 60,
139
+ },
140
+ // rest = 180; the particle at 150 is inside → pushed out, with light damping
141
+ expectations: [movesAway(), exactDelta(-0.5319, 0, 2e-3)],
142
+ },
143
+ {
144
+ scenario: {
145
+ force: 'wall',
146
+ label: 'A particle hitting a wall',
147
+ family: 'canonical',
148
+ klass: 'A',
149
+ body: { cx: 0, cy: 0, hw: 40, hh: 40 },
150
+ particles: [{ x: 30, y: 0, vx: 3, vy: 0 }],
151
+ frames: 4,
152
+ },
153
+ expectations: [
154
+ check('velocity reverses off the wall', 'invariant', (r) => {
155
+ const a = r.applyDelta[0];
156
+ const after = 3 + a.dvx;
157
+ return { pass: after < 0, measured: `vx 3 → ${f3(after)}`, expected: '< 0 (reversed)' };
158
+ }),
159
+ check('bounce is damped (e ≈ 0.85)', 'invariant', (r) => {
160
+ const a = r.applyDelta[0];
161
+ const after = Math.abs(3 + a.dvx);
162
+ return { pass: after < 3, measured: `|vx| ${f3(after)}`, expected: '< 3 (energy lost)' };
163
+ }),
164
+ ],
165
+ },
166
+ {
167
+ scenario: {
168
+ force: 'sink',
169
+ label: 'A particle drifting into a sink',
170
+ family: 'canonical',
171
+ klass: 'A',
172
+ body: { cx: 0, cy: 0, range: 300, absorbR: 64, capacity: 60 },
173
+ particles: [{ x: 30, y: 0 }],
174
+ frames: 20,
175
+ },
176
+ expectations: [
177
+ check('particle is captured', 'invariant', (r) => {
178
+ const n = r.body.accreted;
179
+ return { pass: n > 0, measured: `accreted = ${n}`, expected: '> 0' };
180
+ }),
181
+ approachesBody(),
182
+ ],
183
+ },
184
+ // ── natural primitives ────────────────────────────────────────────────────
185
+ {
186
+ scenario: {
187
+ force: 'gravity',
188
+ label: 'A particle near a massive body',
189
+ family: 'natural',
190
+ klass: 'A',
191
+ body: { cx: 120, range: 300, M: 2000 },
192
+ particles: [{ x: 0, y: 0 }],
193
+ frames: 60,
194
+ },
195
+ expectations: [movesToward(), approachesBody(), noEffectBeyondRange()],
196
+ },
197
+ {
198
+ scenario: {
199
+ force: 'charge',
200
+ label: 'A like-signed charge near a charged body',
201
+ family: 'natural',
202
+ klass: 'A',
203
+ body: { cx: 120, range: 300, M: 2000, spin: 1 },
204
+ particles: [{ x: 0, y: 0, charge: 1 }],
205
+ frames: 60,
206
+ },
207
+ expectations: [movesAway(), unaffectedWhenNeutral(), recedesFromBody()],
208
+ },
209
+ {
210
+ scenario: {
211
+ force: 'magnetism',
212
+ label: 'A moving charge in a magnetic field',
213
+ family: 'natural',
214
+ klass: 'A',
215
+ body: { cx: 0, cy: 0, range: 400, strength: 0.05, spin: 1 },
216
+ particles: [{ x: 0, y: 0, vx: 5, vy: 0, charge: 1 }],
217
+ frames: 40,
218
+ },
219
+ // perpendicularToVelocity() checked Euler's exact Δv⊥v; the rotation implementation
220
+ // preserves speed to float precision (better), so use the tighter speed check instead.
221
+ expectations: [speedPreserved(1e-5)],
222
+ },
223
+ {
224
+ scenario: {
225
+ force: 'fieldflow',
226
+ label: 'Neutral matter following a charge field line',
227
+ family: 'extended',
228
+ klass: 'A',
229
+ // a `charge` sources the field; `fieldflow` follows it. The particle is NEUTRAL, so
230
+ // charge itself cannot move it — only fieldflow advects it, streaming OUT along the
231
+ // radial field lines (the field direction at a point left of a + source points away).
232
+ tokens: ['charge', 'fieldflow'],
233
+ body: { cx: 120, range: 300, M: 2000, spin: 1, strength: 1 },
234
+ particles: [{ x: 0, y: 0 }],
235
+ frames: 60,
236
+ },
237
+ expectations: [movesAway(), recedesFromBody(), noEffectBeyondRange()],
238
+ },
239
+ {
240
+ scenario: {
241
+ force: 'thermal',
242
+ label: 'A particle in a thermal bath',
243
+ family: 'natural',
244
+ klass: 'A',
245
+ body: { cx: 0, cy: 0, range: 300, strength: 1 },
246
+ // a cloud of independent particles — the isotropy is a statistical property, so
247
+ // it only converges over many samples (one particle is a noisy random walk).
248
+ particles: Array.from({ length: 150 }, () => ({ x: 40, y: 0, vx: 0, vy: 0 })),
249
+ frames: 120,
250
+ seed: 7,
251
+ },
252
+ expectations: [
253
+ check('agitated into motion', 'invariant', (r) => {
254
+ const moved = r.trajectory.some((fr) => fr[0].speed > 0.05);
255
+ return { pass: moved, measured: 'speed > 0 occurs', expected: 'kicked into motion' };
256
+ }),
257
+ check('isotropic kicks (comparable spread on both axes)', 'invariant', (r) => {
258
+ // isotropic 2-D kicks give equal RMS velocity in x and y — measured across the
259
+ // whole cloud over all frames, so it converges close to 1.
260
+ let sx = 0;
261
+ let sy = 0;
262
+ let n = 0;
263
+ for (const fr of r.trajectory)
264
+ for (const p of fr) {
265
+ sx += p.vx * p.vx;
266
+ sy += p.vy * p.vy;
267
+ n++;
268
+ }
269
+ const rx = Math.sqrt(sx / n);
270
+ const ry = Math.sqrt(sy / n);
271
+ const ratio = rx / (ry || 1e-9);
272
+ return {
273
+ pass: rx > 0.1 && ry > 0.1 && ratio > 0.9 && ratio < 1.11,
274
+ measured: `rms (${f3(rx)}, ${f3(ry)}), ratio ${f3(ratio)}`,
275
+ expected: 'both > 0, ratio ≈ 1 (±10%)',
276
+ };
277
+ }),
278
+ ],
279
+ },
280
+ {
281
+ scenario: {
282
+ force: 'collide',
283
+ label: 'Two discs in a head-on elastic collision',
284
+ family: 'natural',
285
+ klass: 'B',
286
+ // centred in positive space (the sim wraps at the field origin, so keep clear of
287
+ // the edges). Start apart (gap 20) and approaching slowly, so they meet, exchange
288
+ // momentum, and clearly fly back apart rather than tunnelling through.
289
+ body: { cx: 300, cy: 300, range: 300, strength: 1 },
290
+ particles: [
291
+ { x: 290, y: 300, vx: 1, vy: 0, size: 4 },
292
+ { x: 310, y: 300, vx: -1, vy: 0, size: 4 },
293
+ ],
294
+ frames: 40,
295
+ },
296
+ expectations: [
297
+ momentumConserved(1e-6),
298
+ separates(0, 1),
299
+ check('the discs bounce (relative velocity reverses)', 'invariant', (r) => {
300
+ const last = r.trajectory[r.trajectory.length - 1];
301
+ // they approached (+x and −x); after the bounce each moves the other way
302
+ const ok = last[0].vx < 0 && last[1].vx > 0;
303
+ return { pass: ok, measured: `vx ${f3(last[0].vx)}, ${f3(last[1].vx)}`, expected: 'reversed (bounced)' };
304
+ }),
305
+ ],
306
+ },
307
+ {
308
+ scenario: {
309
+ force: 'diffuse',
310
+ label: 'A particle following a pheromone gradient',
311
+ family: 'natural',
312
+ klass: 'C',
313
+ body: { cx: 0, cy: 0, range: 300, strength: 0.5 },
314
+ particles: [{ x: 50, y: 0 }],
315
+ frames: 30,
316
+ },
317
+ expectations: [followsGradient(2, 0)],
318
+ },
319
+ {
320
+ scenario: {
321
+ force: 'propagate',
322
+ label: 'A particle riding a propagating wavefront',
323
+ family: 'natural',
324
+ klass: 'C',
325
+ body: { cx: 0, cy: 0, range: 300, strength: 2, on: true },
326
+ particles: [{ x: 40, y: 0 }],
327
+ frames: 60,
328
+ },
329
+ expectations: [endsFartherOut()],
330
+ },
331
+ {
332
+ scenario: {
333
+ force: 'memory',
334
+ label: 'A particle wearing in a remembered path',
335
+ family: 'natural',
336
+ klass: 'C',
337
+ body: { cx: 120, range: 300, strength: 1 },
338
+ particles: [{ x: 0, y: 0 }],
339
+ frames: 40,
340
+ },
341
+ expectations: [
342
+ movesToward(), // memory amplifies an attractive pull
343
+ noEffectBeyondRange(),
344
+ ],
345
+ },
346
+ // ── designed-extended ─────────────────────────────────────────────────────
347
+ {
348
+ scenario: {
349
+ force: 'lens',
350
+ label: 'A moving particle bent by a lens',
351
+ family: 'extended',
352
+ klass: 'A',
353
+ body: { cx: 150, range: 300, strength: 0.3, spin: 1 },
354
+ particles: [{ x: 0, y: 0, vx: 5, vy: 0 }],
355
+ frames: 40,
356
+ },
357
+ expectations: [
358
+ speedPreserved(1e-3), // pure rotation
359
+ check('rotated by θ = θmax·(1−d/r)·spin', 'exact', (r) => {
360
+ const a = r.applyDelta[0];
361
+ const ang = headingAngle(5 + a.dvx, 0 + a.dvy);
362
+ const expected = 0.3 * (1 - 150 / 300) * 1; // 0.15
363
+ return {
364
+ pass: Math.abs(ang - expected) < 2e-3,
365
+ measured: `θ = ${f3(ang)} rad`,
366
+ expected: `${f3(expected)} rad`,
367
+ };
368
+ }),
369
+ ],
370
+ },
371
+ {
372
+ scenario: {
373
+ force: 'gate',
374
+ label: 'A wrong-way crosser at a one-way membrane',
375
+ family: 'extended',
376
+ klass: 'A',
377
+ body: { cx: 0, cy: 0, hw: 40, hh: 40, angle: 0 },
378
+ particles: [{ x: 0, y: 0, vx: -3, vy: 0 }], // moving against the heading (+x)
379
+ frames: 4,
380
+ },
381
+ expectations: [
382
+ check('reflects the wrong-way crosser back along n', 'invariant', (r) => {
383
+ const a = r.applyDelta[0];
384
+ const after = -3 + a.dvx;
385
+ return { pass: after > 0, measured: `vx -3 → ${f3(after)}`, expected: '> 0 (reflected back along +n)' };
386
+ }),
387
+ ],
388
+ },
389
+ {
390
+ scenario: {
391
+ force: 'buoyancy',
392
+ label: 'A hot, light particle in a buoyancy field',
393
+ family: 'extended',
394
+ klass: 'A',
395
+ body: { cx: 0, cy: 0, range: 0, strength: 0.5 }, // range 0 = global
396
+ particles: [{ x: 0, y: 0, size: 2, heat: 0.8 }], // light → rises
397
+ frames: 30,
398
+ },
399
+ expectations: [
400
+ check('light matter rises (−y)', 'invariant', (r) => {
401
+ const a = r.applyDelta[0];
402
+ return { pass: a.dvy < -1e-6, measured: `Δvy = ${f3(a.dvy)}`, expected: '< 0 (up)' };
403
+ }),
404
+ ],
405
+ },
406
+ {
407
+ scenario: {
408
+ force: 'shear',
409
+ label: 'An off-axis particle in a shear gradient',
410
+ family: 'extended',
411
+ klass: 'A',
412
+ body: { cx: 0, cy: 0, range: 300, strength: 1, angle: 0 }, // flow axis +x
413
+ particles: [{ x: 0, y: 80 }], // offset perpendicular to the axis
414
+ frames: 30,
415
+ },
416
+ expectations: [
417
+ check('dragged along the flow axis by its ⟂ offset', 'invariant', (r) => {
418
+ const a = r.applyDelta[0];
419
+ // project onto the body's actual flow axis so the check holds at any `angle`
420
+ const ux = r.body.ux, uy = r.body.uy;
421
+ const along = a.dvx * ux + a.dvy * uy;
422
+ const perp = a.dvx * -uy + a.dvy * ux;
423
+ return {
424
+ pass: Math.abs(along) > 1e-6 && Math.abs(perp) < 1e-6,
425
+ measured: `along ${f3(along)}, ⟂ ${f3(perp)}`,
426
+ expected: 'along the flow axis only',
427
+ };
428
+ }),
429
+ ],
430
+ },
431
+ {
432
+ scenario: {
433
+ force: 'crystallize',
434
+ label: 'A cool particle snapping onto a lattice',
435
+ family: 'extended',
436
+ klass: 'A',
437
+ body: { cx: 0, cy: 0, range: 300, strength: 0.3 },
438
+ particles: [{ x: 20, y: 8, heat: 0.1 }], // cool, off-node → snaps to (32, 0)
439
+ frames: 40,
440
+ },
441
+ expectations: [
442
+ check('snaps toward the nearest lattice node', 'invariant', (r) => {
443
+ const a = r.applyDelta[0];
444
+ // nearest node to (20,8) is (32,0): Δv should point +x, −y
445
+ return {
446
+ pass: a.dvx > 0 && a.dvy < 0,
447
+ measured: `Δv = (${f3(a.dvx)}, ${f3(a.dvy)})`,
448
+ expected: 'toward node (32, 0)',
449
+ };
450
+ }),
451
+ ],
452
+ },
453
+ {
454
+ scenario: {
455
+ force: 'align',
456
+ label: 'A particle steering to its neighbours’ heading',
457
+ family: 'extended',
458
+ klass: 'B',
459
+ body: { cx: 0, cy: 0, range: 300, strength: 0.1 },
460
+ particles: [
461
+ { x: 0, y: 0, vx: 5, vy: 0 }, // moving +x
462
+ { x: 20, y: 0, vx: 0, vy: 5 }, // neighbours moving +y
463
+ { x: -20, y: 0, vx: 0, vy: 5 },
464
+ { x: 0, y: 20, vx: 0, vy: 5 },
465
+ ],
466
+ frames: 30,
467
+ },
468
+ expectations: [
469
+ check('steers toward the neighbour-mean heading (+y)', 'invariant', (r) => {
470
+ const a = r.applyDelta[0];
471
+ const ang0 = 0; // +x
472
+ const ang1 = headingAngle(5 + a.dvx, 0 + a.dvy);
473
+ return { pass: ang1 > ang0 + 1e-3, measured: `heading 0 → ${f3(ang1)} rad`, expected: 'turns toward +y' };
474
+ }),
475
+ ],
476
+ },
477
+ {
478
+ scenario: {
479
+ force: 'wind',
480
+ label: 'A particle in curl-noise turbulence',
481
+ family: 'extended',
482
+ klass: 'A',
483
+ body: { cx: 0, cy: 0, range: 0, strength: 8 }, // a strong global gust (legible drift)
484
+ particles: [{ x: 137, y: 89 }],
485
+ frames: 60,
486
+ },
487
+ expectations: [
488
+ check('receives a non-zero curl-noise push', 'invariant', (r) => {
489
+ const a = r.applyDelta[0];
490
+ const mag = Math.hypot(a.dvx, a.dvy);
491
+ return { pass: mag > 0.01, measured: `|Δv| = ${f3(mag)}`, expected: '> 0.01 (clearly stirred)' };
492
+ }),
493
+ ],
494
+ },
495
+ {
496
+ scenario: {
497
+ force: 'cohesion',
498
+ label: 'Two particles at cohesion range',
499
+ family: 'extended',
500
+ klass: 'B',
501
+ body: { cx: 0, cy: 0, range: 200, strength: 1 },
502
+ particles: [
503
+ { x: 0, y: 0 },
504
+ { x: 150, y: 0 }, // between r0 (100) and r1 (200) → pulled together
505
+ ],
506
+ frames: 30,
507
+ },
508
+ expectations: [
509
+ check('mid-range neighbours draw together (surface tension)', 'invariant', (r) => {
510
+ const start = gap(r, 0, 0, 1);
511
+ const end = gap(r, r.trajectory.length - 1, 0, 1);
512
+ return { pass: end < start - 1e-6, measured: `gap ${f3(start)} → ${f3(end)}`, expected: 'converge' };
513
+ }),
514
+ ],
515
+ },
516
+ {
517
+ scenario: {
518
+ force: 'pressure',
519
+ label: 'Two overlapping particles relax apart',
520
+ family: 'extended',
521
+ klass: 'B',
522
+ // a crowded pair (gap 8, well inside the smoothing radius) is over the rest
523
+ // density, so pressure pushes them apart symmetrically — momentum is conserved.
524
+ body: { cx: 0, cy: 0, range: 200, strength: 1 },
525
+ particles: [
526
+ { x: 0, y: 0 },
527
+ { x: 8, y: 0 },
528
+ ],
529
+ frames: 30,
530
+ },
531
+ expectations: [
532
+ momentumConserved(1e-6),
533
+ separates(0, 1),
534
+ check('over-dense matter spreads to an even fill', 'invariant', (r) => {
535
+ const start = gap(r, 0, 0, 1);
536
+ const end = gap(r, r.trajectory.length - 1, 0, 1);
537
+ return { pass: end > start + 1e-6, measured: `gap ${f3(start)} → ${f3(end)}`, expected: 'spread apart' };
538
+ }),
539
+ ],
540
+ },
541
+ {
542
+ scenario: {
543
+ force: 'hunt',
544
+ label: 'A predator chasing prey, the prey fleeing',
545
+ family: 'extended',
546
+ klass: 'B',
547
+ // predator (species 0) left of prey (species 1): the predator accelerates toward
548
+ // the prey (+x) and the prey flees away (+x), so the whole pair migrates +x.
549
+ body: { cx: 0, cy: 0, range: 300, strength: 1 },
550
+ particles: [
551
+ { x: 0, y: 0, species: 0 },
552
+ { x: 20, y: 0, species: 1 },
553
+ ],
554
+ frames: 30,
555
+ },
556
+ expectations: [
557
+ check('the predator accelerates toward the prey', 'invariant', (r) => {
558
+ const last = r.trajectory[r.trajectory.length - 1];
559
+ return { pass: last[0].vx > 0, measured: `predator vx ${f3(last[0].vx)}`, expected: '> 0 (toward prey)' };
560
+ }),
561
+ check('the prey flees away from the predator', 'invariant', (r) => {
562
+ const last = r.trajectory[r.trajectory.length - 1];
563
+ return { pass: last[1].vx > 0, measured: `prey vx ${f3(last[1].vx)}`, expected: '> 0 (fleeing)' };
564
+ }),
565
+ check('a particle ignores its own species', 'invariant', (r) => {
566
+ // both move the same way only because they are different species; a self-pair
567
+ // check is implicit — the predator's target must be the prey, not itself.
568
+ const first = r.trajectory[0];
569
+ const last = r.trajectory[r.trajectory.length - 1];
570
+ const migrated = last[0].x - first[0].x > 1 && last[1].x - first[1].x > 1;
571
+ return { pass: migrated, measured: `Δx ${f3(last[0].x - first[0].x)}, ${f3(last[1].x - first[1].x)}`, expected: 'both migrate +x' };
572
+ }),
573
+ ],
574
+ },
575
+ {
576
+ scenario: {
577
+ force: 'spawn',
578
+ label: 'A source emitting matter along its heading',
579
+ family: 'extended',
580
+ klass: 'S',
581
+ // an engaged source with no initial matter: it fills the field along +x (angle 0).
582
+ body: { cx: 0, cy: 0, range: 300, strength: 1, on: true, angle: 0 },
583
+ particles: [],
584
+ frames: 12,
585
+ seed: 7,
586
+ },
587
+ expectations: [
588
+ check('the source creates matter over time', 'invariant', (r) => {
589
+ const start = r.trajectory[0].length;
590
+ const end = r.trajectory[r.trajectory.length - 1].length;
591
+ return { pass: end > start, measured: `${start} → ${end} particles`, expected: 'grows' };
592
+ }),
593
+ check('emitted matter carries the heading (+x)', 'invariant', (r) => {
594
+ const last = r.trajectory[r.trajectory.length - 1];
595
+ if (!last.length)
596
+ return { pass: false, measured: 'no matter emitted', expected: 'mean vx > 0' };
597
+ const mean = last.reduce((s, p) => s + p.vx, 0) / last.length;
598
+ return { pass: mean > 0, measured: `mean vx ${f3(mean)}`, expected: '> 0 (along the heading)' };
599
+ }),
600
+ ],
601
+ },
602
+ {
603
+ scenario: {
604
+ force: 'link',
605
+ label: 'Two particles relaxing to the bond rest length',
606
+ family: 'extended',
607
+ klass: 'B',
608
+ // range 200 → rest length L = 70; a pair at gap 150 (> L) is pulled together
609
+ // toward L, symmetrically, so momentum is conserved.
610
+ body: { cx: 0, cy: 0, range: 200, strength: 1 },
611
+ particles: [
612
+ { x: 0, y: 0 },
613
+ { x: 150, y: 0 },
614
+ ],
615
+ frames: 40,
616
+ },
617
+ expectations: [
618
+ momentumConserved(1e-6),
619
+ check('a stretched bond pulls back toward its rest length', 'invariant', (r) => {
620
+ const start = gap(r, 0, 0, 1);
621
+ const end = gap(r, r.trajectory.length - 1, 0, 1);
622
+ return { pass: end < start - 1e-6 && end > 40, measured: `gap ${f3(start)} → ${f3(end)}`, expected: 'toward L ≈ 70' };
623
+ }),
624
+ ],
625
+ },
626
+ {
627
+ scenario: {
628
+ force: 'morph',
629
+ label: 'Matter assembling into a three-point mark',
630
+ family: 'extended',
631
+ klass: 'D',
632
+ // three particles, each hashed (by gx) to a distinct target — they tether out from
633
+ // the centre and settle onto the marks. A geometric mark, never letterforms (§11).
634
+ body: {
635
+ cx: 0,
636
+ cy: 0,
637
+ range: 300,
638
+ strength: 1,
639
+ targets: [
640
+ { x: 120, y: 0 },
641
+ { x: -60, y: 100 },
642
+ { x: -60, y: -100 },
643
+ ],
644
+ },
645
+ particles: [
646
+ { x: 0, y: 0, gx: 0.1 },
647
+ { x: 0, y: 0, gx: 0.45 },
648
+ { x: 0, y: 0, gx: 0.8 },
649
+ ],
650
+ frames: 80,
651
+ seed: 5,
652
+ },
653
+ expectations: [
654
+ check('each particle settles on its assigned mark', 'invariant', (r) => {
655
+ const ts = r.scenario.body.targets;
656
+ const first = r.trajectory[0];
657
+ const last = r.trajectory[r.trajectory.length - 1];
658
+ let converged = true;
659
+ let maxEnd = 0;
660
+ for (let i = 0; i < r.scenario.particles.length; i++) {
661
+ const gx = r.scenario.particles[i].gx ?? 0;
662
+ const t = ts[Math.min(ts.length - 1, Math.floor(gx * ts.length))];
663
+ const d0 = Math.hypot(first[i].x - t.x, first[i].y - t.y);
664
+ const d1 = Math.hypot(last[i].x - t.x, last[i].y - t.y);
665
+ if (d1 >= d0)
666
+ converged = false;
667
+ maxEnd = Math.max(maxEnd, d1);
668
+ }
669
+ return { pass: converged && maxEnd < 30, measured: `max dist to mark ${f3(maxEnd)}`, expected: 'each on its mark (< 30)' };
670
+ }),
671
+ ],
672
+ },
673
+ {
674
+ scenario: {
675
+ force: 'resonate',
676
+ tokens: ['resonate', 'attract'],
677
+ label: 'A resonator pulsing an attractor',
678
+ family: 'extended',
679
+ klass: 'modifier',
680
+ body: { cx: 150, range: 300, strength: 1, spin: 1 },
681
+ particles: [{ x: 0, y: 0 }],
682
+ frames: 1,
683
+ },
684
+ expectations: [modulatesStrength()],
685
+ },
686
+ {
687
+ scenario: {
688
+ force: 'spotlight',
689
+ tokens: ['spotlight', 'stream'],
690
+ label: 'A spotlight gating a stream to its cone',
691
+ family: 'extended',
692
+ klass: 'modifier',
693
+ body: { cx: 0, cy: 0, range: 300, strength: 1, angle: 0 }, // heading +x
694
+ particles: [{ x: 0, y: 0 }],
695
+ frames: 1,
696
+ },
697
+ expectations: [gatesOutsideCone(0, Math.PI)],
698
+ },
699
+ {
700
+ // screen is a CROSS-BODY modifier (workover v0.3): the quiet zone damps OTHER bodies'
701
+ // forces on matter inside its radius. Two identical attract wells act on two identical
702
+ // particles; one particle sits at the screen's core (factor → 0), the far pair is well
703
+ // outside the screen's range and must behave as plain attract.
704
+ scenario: {
705
+ force: 'screen',
706
+ label: "A quiet zone attenuating a neighbour attractor's pull",
707
+ family: 'extended',
708
+ klass: 'modifier',
709
+ body: { cx: 0, cy: 0, range: 200, strength: 1 }, // the screen: full cancellation at its core
710
+ extraBodies: [
711
+ { tokens: ['attract'], attrs: { cx: 150, cy: 0, range: 300, strength: 1 } }, // shielded pair
712
+ { tokens: ['attract'], attrs: { cx: 150, cy: 1200, range: 300, strength: 1 } }, // free pair
713
+ ],
714
+ particles: [
715
+ { x: 0, y: 0 }, // inside the screen (d = 0 → screenFactor 0)
716
+ { x: 0, y: 1200 }, // far outside the screen — the control
717
+ ],
718
+ frames: 30,
719
+ },
720
+ expectations: [
721
+ check('matter inside the quiet zone is measurably attenuated vs outside', 'invariant', (r) => {
722
+ const last = r.trajectory[r.trajectory.length - 1];
723
+ const first = r.trajectory[0];
724
+ const dispIn = Math.hypot(last[0].x - first[0].x, last[0].y - first[0].y);
725
+ const dispOut = Math.hypot(last[1].x - first[1].x, last[1].y - first[1].y);
726
+ return {
727
+ pass: dispOut > 1 && dispIn < dispOut * 0.05,
728
+ measured: `inside ${f3(dispIn)}px vs outside ${f3(dispOut)}px`,
729
+ expected: 'inside < 5% of outside (and outside actually moves)',
730
+ };
731
+ }),
732
+ check('no effect outside the radius — the control feels plain attract', 'exact', (r) => {
733
+ // frame 1 = one step: attract's Δvx 0.125 at d=150 (S=1, r=300), then friction ×0.95.
734
+ const vx = r.trajectory[1][1].vx;
735
+ const want = 0.125 * 0.95;
736
+ return {
737
+ pass: Math.abs(vx - want) < 1e-9,
738
+ measured: `vx ${vx.toFixed(6)}`,
739
+ expected: `${want.toFixed(6)} (unattenuated attract × friction)`,
740
+ };
741
+ }),
742
+ check('the screen itself never moves matter (a pure modifier)', 'invariant', (r) => {
743
+ const d = r.applyDelta[0];
744
+ return {
745
+ pass: d.dvx === 0 && d.dvy === 0,
746
+ measured: `Δv (${f3(d.dvx)}, ${f3(d.dvy)})`,
747
+ expected: '(0, 0) — apply is a no-op',
748
+ };
749
+ }),
750
+ ],
751
+ },
752
+ {
753
+ scenario: {
754
+ force: 'pigment',
755
+ label: 'A particle overlapping a pigment body',
756
+ family: 'extended',
757
+ klass: 'A',
758
+ body: { cx: 0, cy: 0, range: 100, tint: '#ff0000' },
759
+ particles: [{ x: 10, y: 0 }], // within 0.6·range = 60 → stains
760
+ frames: 4,
761
+ },
762
+ expectations: [adoptsTint()],
763
+ },
764
+ {
765
+ // warp is position-dependent (reads warpX/warpY) and in NO_OFFSET, so coords are absolute.
766
+ scenario: {
767
+ force: 'warp',
768
+ label: 'A particle entering a warp throat relocates to its pair (conserved)',
769
+ family: 'extended',
770
+ klass: 'A',
771
+ body: { cx: 2000, cy: 2000, absorbR: 64, warpHas: true, warpX: 3000, warpY: 2000, twist: 0, warpScale: 1 },
772
+ particles: [{ x: 1990, y: 2000 }], // 10px inside throat A
773
+ frames: 5,
774
+ },
775
+ expectations: [
776
+ check('relocates to the paired throat', 'invariant', (r) => {
777
+ const last = r.trajectory[r.trajectory.length - 1][0];
778
+ const dxPair = Math.hypot(last.x - 3000, last.y - 2000);
779
+ return { pass: dxPair < 90, measured: `${f3(dxPair)}px from the pair`, expected: '< ~throat (arrived at pair B)' };
780
+ }),
781
+ check('leaves the entry throat — conserved, not captured', 'invariant', (r) => {
782
+ const last = r.trajectory[r.trajectory.length - 1][0];
783
+ const fromA = Math.hypot(last.x - 2000, last.y - 2000);
784
+ const count = r.trajectory[r.trajectory.length - 1].length;
785
+ return { pass: fromA > 64 && count === 1, measured: `${f3(fromA)}px from throat A, ${count} particle(s)`, expected: '> absorbR, count unchanged' };
786
+ }),
787
+ ],
788
+ },
789
+ ];
790
+ /**
791
+ * Beyond the per-force catalog: forces **compose** (a body can carry several tokens)
792
+ * and **gate** on conditions (`data-when`). These experiments verify those two
793
+ * mechanisms. They are not per-force, so they live in their own catalog; the
794
+ * conformance test runs them alongside `EXPERIMENTS`.
795
+ */
796
+ export const COMPOSITE_EXPERIMENTS = [
797
+ {
798
+ // attract + repel at equal strength cancel — the net force is zero.
799
+ scenario: {
800
+ force: 'attract repel',
801
+ tokens: ['attract', 'repel'],
802
+ label: 'A particle between an equal attractor + repeller (they cancel)',
803
+ family: 'canonical',
804
+ klass: 'A',
805
+ body: { cx: 150, range: 300, strength: 1 },
806
+ particles: [{ x: 0, y: 0 }],
807
+ frames: 30,
808
+ },
809
+ expectations: [
810
+ exactDelta(0, 0),
811
+ check('composes to no net force', 'invariant', (r) => {
812
+ const d = r.applyDelta[0];
813
+ const mag = Math.hypot(d.dvx, d.dvy);
814
+ return { pass: mag < 1e-6, measured: `|Δv| = ${f3(mag)}`, expected: '≈ 0 (cancelled)' };
815
+ }),
816
+ ],
817
+ },
818
+ {
819
+ // attract + swirl compose into an inward spiral: inward pull + tangential swirl.
820
+ scenario: {
821
+ force: 'attract swirl',
822
+ tokens: ['attract', 'swirl'],
823
+ label: 'A particle in a composed attract + swirl (a spiral)',
824
+ family: 'canonical',
825
+ klass: 'A',
826
+ body: { cx: 150, range: 300, strength: 1, spin: 1 },
827
+ particles: [{ x: 0, y: 0 }],
828
+ frames: 90,
829
+ },
830
+ expectations: [
831
+ movesToward(),
832
+ check('acquires a tangential (swirl) component', 'invariant', (r) => {
833
+ const d = r.applyDelta[0];
834
+ return { pass: Math.abs(d.dvy) > 0.05, measured: `Δvᵧ = ${f3(d.dvy)}`, expected: '|Δvᵧ| > 0.05 (swirl)' };
835
+ }),
836
+ check('composes to the sum of its parts (inward + swirl)', 'exact', (r) => {
837
+ const d = r.applyDelta[0];
838
+ // attract Δv (0.125, 0) + swirl Δv (0.0205, −0.1705) on a still particle 150px out
839
+ const ok = Math.abs(d.dvx - 0.1455) < 0.005 && Math.abs(d.dvy + 0.1705) < 0.005;
840
+ return { pass: ok, measured: `(${f3(d.dvx)}, ${f3(d.dvy)})`, expected: '(0.1455, −0.1705) ±0.005' };
841
+ }),
842
+ ],
843
+ },
844
+ {
845
+ // a condition gate: attract only acts on a *fast* particle. Two particles fired
846
+ // in — the fast one is pulled toward the body, the slow one is left alone.
847
+ scenario: {
848
+ force: 'attract',
849
+ label: 'A gated attractor (data-when="fast"): pulls the fast particle, not the slow',
850
+ family: 'canonical',
851
+ klass: 'A',
852
+ body: { cx: 150, range: 300, strength: 2, when: 'fast' },
853
+ particles: [
854
+ { x: 0, y: 0, vy: 6 }, // fast → passes the gate
855
+ { x: 0, y: 0, vy: 0.1 }, // slow → blocked
856
+ ],
857
+ frames: 60,
858
+ },
859
+ expectations: [
860
+ check('the gate lets the fast particle through, blocks the slow one', 'invariant', (r) => {
861
+ const first = r.trajectory[0];
862
+ const last = r.trajectory[r.trajectory.length - 1];
863
+ const fastMoved = last[0].x - first[0].x; // pulled toward the body (+x)
864
+ const slowMoved = last[1].x - first[1].x; // gated → ~unmoved
865
+ const pass = fastMoved > 10 && Math.abs(slowMoved) < 5;
866
+ return {
867
+ pass,
868
+ measured: `fast Δx = ${f3(fastMoved)}, slow Δx = ${f3(slowMoved)}`,
869
+ expected: 'fast pulled toward the body (Δx > 10), slow ~unmoved',
870
+ };
871
+ }),
872
+ ],
873
+ },
874
+ ];
875
+ //# sourceMappingURL=experiments.js.map