@graphty/webgpu-graph-algorithms 0.5.0 → 0.6.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 (264) hide show
  1. package/README.md +104 -52
  2. package/dist/browser.js +1 -1
  3. package/dist/chunks/{context-CRbw2Wyo.js → context-BXqgCifx.js} +225 -33
  4. package/dist/chunks/context-BXqgCifx.js.map +1 -0
  5. package/dist/node.js +1 -1
  6. package/dist/src/accelerator.d.ts +12 -10
  7. package/dist/src/accelerator.d.ts.map +1 -1
  8. package/dist/src/accelerator.js +32 -10
  9. package/dist/src/accelerator.js.map +1 -1
  10. package/dist/src/algorithms/components.d.ts.map +1 -1
  11. package/dist/src/algorithms/components.js +12 -13
  12. package/dist/src/algorithms/components.js.map +1 -1
  13. package/dist/src/algorithms/degree.d.ts +6 -8
  14. package/dist/src/algorithms/degree.d.ts.map +1 -1
  15. package/dist/src/algorithms/degree.js +58 -35
  16. package/dist/src/algorithms/degree.js.map +1 -1
  17. package/dist/src/algorithms/pagerank.d.ts.map +1 -1
  18. package/dist/src/algorithms/pagerank.js +16 -14
  19. package/dist/src/algorithms/pagerank.js.map +1 -1
  20. package/dist/src/algorithms/power-iteration.d.ts +2 -2
  21. package/dist/src/algorithms/power-iteration.d.ts.map +1 -1
  22. package/dist/src/algorithms/power-iteration.js +17 -14
  23. package/dist/src/algorithms/power-iteration.js.map +1 -1
  24. package/dist/src/constants.d.ts +85 -8
  25. package/dist/src/constants.d.ts.map +1 -1
  26. package/dist/src/constants.js +85 -8
  27. package/dist/src/constants.js.map +1 -1
  28. package/dist/src/errors.d.ts +3 -2
  29. package/dist/src/errors.d.ts.map +1 -1
  30. package/dist/src/errors.js +2 -1
  31. package/dist/src/errors.js.map +1 -1
  32. package/dist/src/index.d.ts +10 -5
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +14 -5
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/kernel/dispatch.d.ts +8 -3
  37. package/dist/src/kernel/dispatch.d.ts.map +1 -1
  38. package/dist/src/kernel/dispatch.js +18 -7
  39. package/dist/src/kernel/dispatch.js.map +1 -1
  40. package/dist/src/kernel/kernel.d.ts +30 -1
  41. package/dist/src/kernel/kernel.d.ts.map +1 -1
  42. package/dist/src/kernel/kernel.js +49 -5
  43. package/dist/src/kernel/kernel.js.map +1 -1
  44. package/dist/src/kernel/prelude.d.ts.map +1 -1
  45. package/dist/src/kernel/prelude.js +9 -1
  46. package/dist/src/kernel/prelude.js.map +1 -1
  47. package/dist/src/kernel/profiler.d.ts +15 -3
  48. package/dist/src/kernel/profiler.d.ts.map +1 -1
  49. package/dist/src/kernel/profiler.js +27 -4
  50. package/dist/src/kernel/profiler.js.map +1 -1
  51. package/dist/src/kernels.d.ts +18 -8
  52. package/dist/src/kernels.d.ts.map +1 -1
  53. package/dist/src/kernels.js +345 -22
  54. package/dist/src/kernels.js.map +1 -1
  55. package/dist/src/layouts/calibrate.d.ts +51 -0
  56. package/dist/src/layouts/calibrate.d.ts.map +1 -0
  57. package/dist/src/layouts/calibrate.js +172 -0
  58. package/dist/src/layouts/calibrate.js.map +1 -0
  59. package/dist/src/layouts/force-simulation.d.ts +42 -5
  60. package/dist/src/layouts/force-simulation.d.ts.map +1 -1
  61. package/dist/src/layouts/force-simulation.js +84 -22
  62. package/dist/src/layouts/force-simulation.js.map +1 -1
  63. package/dist/src/layouts/forceatlas2.d.ts +107 -38
  64. package/dist/src/layouts/forceatlas2.d.ts.map +1 -1
  65. package/dist/src/layouts/forceatlas2.js +297 -290
  66. package/dist/src/layouts/forceatlas2.js.map +1 -1
  67. package/dist/src/layouts/fruchterman-reingold.d.ts +241 -0
  68. package/dist/src/layouts/fruchterman-reingold.d.ts.map +1 -0
  69. package/dist/src/layouts/fruchterman-reingold.js +739 -0
  70. package/dist/src/layouts/fruchterman-reingold.js.map +1 -0
  71. package/dist/src/layouts/model-common.d.ts +140 -0
  72. package/dist/src/layouts/model-common.d.ts.map +1 -0
  73. package/dist/src/layouts/model-common.js +269 -0
  74. package/dist/src/layouts/model-common.js.map +1 -0
  75. package/dist/src/layouts/repulsion-grid.d.ts +152 -0
  76. package/dist/src/layouts/repulsion-grid.d.ts.map +1 -0
  77. package/dist/src/layouts/repulsion-grid.js +318 -0
  78. package/dist/src/layouts/repulsion-grid.js.map +1 -0
  79. package/dist/src/layouts/spring-electrical.d.ts +224 -0
  80. package/dist/src/layouts/spring-electrical.d.ts.map +1 -0
  81. package/dist/src/layouts/spring-electrical.js +665 -0
  82. package/dist/src/layouts/spring-electrical.js.map +1 -0
  83. package/dist/src/memory/residency.d.ts +6 -2
  84. package/dist/src/memory/residency.d.ts.map +1 -1
  85. package/dist/src/memory/residency.js +84 -14
  86. package/dist/src/memory/residency.js.map +1 -1
  87. package/dist/src/primitives/core-shape.d.ts +38 -2
  88. package/dist/src/primitives/core-shape.d.ts.map +1 -1
  89. package/dist/src/primitives/core-shape.js +71 -3
  90. package/dist/src/primitives/core-shape.js.map +1 -1
  91. package/dist/src/primitives/grid-pyramid.d.ts +71 -0
  92. package/dist/src/primitives/grid-pyramid.d.ts.map +1 -0
  93. package/dist/src/primitives/grid-pyramid.js +143 -0
  94. package/dist/src/primitives/grid-pyramid.js.map +1 -0
  95. package/dist/src/primitives/grid.d.ts +118 -0
  96. package/dist/src/primitives/grid.d.ts.map +1 -0
  97. package/dist/src/primitives/grid.js +225 -0
  98. package/dist/src/primitives/grid.js.map +1 -0
  99. package/dist/src/primitives/histogram.d.ts +67 -0
  100. package/dist/src/primitives/histogram.d.ts.map +1 -0
  101. package/dist/src/primitives/histogram.js +190 -0
  102. package/dist/src/primitives/histogram.js.map +1 -0
  103. package/dist/src/primitives/radix-sort.d.ts +75 -0
  104. package/dist/src/primitives/radix-sort.d.ts.map +1 -0
  105. package/dist/src/primitives/radix-sort.js +168 -0
  106. package/dist/src/primitives/radix-sort.js.map +1 -0
  107. package/dist/src/primitives/scan.d.ts +44 -0
  108. package/dist/src/primitives/scan.d.ts.map +1 -0
  109. package/dist/src/primitives/scan.js +151 -0
  110. package/dist/src/primitives/scan.js.map +1 -0
  111. package/dist/src/primitives/segmented-reduce.d.ts +25 -17
  112. package/dist/src/primitives/segmented-reduce.d.ts.map +1 -1
  113. package/dist/src/primitives/segmented-reduce.js +166 -47
  114. package/dist/src/primitives/segmented-reduce.js.map +1 -1
  115. package/dist/src/primitives/spmv.d.ts +18 -14
  116. package/dist/src/primitives/spmv.d.ts.map +1 -1
  117. package/dist/src/primitives/spmv.js +94 -58
  118. package/dist/src/primitives/spmv.js.map +1 -1
  119. package/dist/src/primitives/verify.d.ts +49 -0
  120. package/dist/src/primitives/verify.d.ts.map +1 -0
  121. package/dist/src/primitives/verify.js +229 -0
  122. package/dist/src/primitives/verify.js.map +1 -0
  123. package/dist/src/types/accelerator.d.ts +7 -3
  124. package/dist/src/types/accelerator.d.ts.map +1 -1
  125. package/dist/src/types/context.d.ts +53 -0
  126. package/dist/src/types/context.d.ts.map +1 -1
  127. package/dist/src/types/layout.d.ts +52 -0
  128. package/dist/src/types/layout.d.ts.map +1 -1
  129. package/dist/src/types/options.d.ts +43 -1
  130. package/dist/src/types/options.d.ts.map +1 -1
  131. package/dist/src/wgsl/counting-scatter.wgsl.d.ts +8 -0
  132. package/dist/src/wgsl/counting-scatter.wgsl.d.ts.map +1 -0
  133. package/dist/src/wgsl/counting-scatter.wgsl.js +17 -0
  134. package/dist/src/wgsl/counting-scatter.wgsl.js.map +1 -0
  135. package/dist/src/wgsl/fa2-attraction.wgsl.d.ts +23 -8
  136. package/dist/src/wgsl/fa2-attraction.wgsl.d.ts.map +1 -1
  137. package/dist/src/wgsl/fa2-attraction.wgsl.js +100 -17
  138. package/dist/src/wgsl/fa2-attraction.wgsl.js.map +1 -1
  139. package/dist/src/wgsl/fa2-integrate.wgsl.d.ts +7 -2
  140. package/dist/src/wgsl/fa2-integrate.wgsl.d.ts.map +1 -1
  141. package/dist/src/wgsl/fa2-integrate.wgsl.js +28 -2
  142. package/dist/src/wgsl/fa2-integrate.wgsl.js.map +1 -1
  143. package/dist/src/wgsl/fa2-repulsion-exact.wgsl.d.ts +4 -2
  144. package/dist/src/wgsl/fa2-repulsion-exact.wgsl.d.ts.map +1 -1
  145. package/dist/src/wgsl/fa2-repulsion-exact.wgsl.js +14 -5
  146. package/dist/src/wgsl/fa2-repulsion-exact.wgsl.js.map +1 -1
  147. package/dist/src/wgsl/fa2-stats-finalize.wgsl.d.ts +12 -1
  148. package/dist/src/wgsl/fa2-stats-finalize.wgsl.d.ts.map +1 -1
  149. package/dist/src/wgsl/fa2-stats-finalize.wgsl.js +54 -0
  150. package/dist/src/wgsl/fa2-stats-finalize.wgsl.js.map +1 -1
  151. package/dist/src/wgsl/grid-cell-key.wgsl.d.ts +8 -0
  152. package/dist/src/wgsl/grid-cell-key.wgsl.d.ts.map +1 -0
  153. package/dist/src/wgsl/grid-cell-key.wgsl.js +30 -0
  154. package/dist/src/wgsl/grid-cell-key.wgsl.js.map +1 -0
  155. package/dist/src/wgsl/grid-centroid-hub.wgsl.d.ts +8 -0
  156. package/dist/src/wgsl/grid-centroid-hub.wgsl.d.ts.map +1 -0
  157. package/dist/src/wgsl/grid-centroid-hub.wgsl.js +29 -0
  158. package/dist/src/wgsl/grid-centroid-hub.wgsl.js.map +1 -0
  159. package/dist/src/wgsl/grid-centroid.wgsl.d.ts +8 -0
  160. package/dist/src/wgsl/grid-centroid.wgsl.d.ts.map +1 -0
  161. package/dist/src/wgsl/grid-centroid.wgsl.js +29 -0
  162. package/dist/src/wgsl/grid-centroid.wgsl.js.map +1 -0
  163. package/dist/src/wgsl/grid-downsample.wgsl.d.ts +7 -0
  164. package/dist/src/wgsl/grid-downsample.wgsl.d.ts.map +1 -0
  165. package/dist/src/wgsl/grid-downsample.wgsl.js +28 -0
  166. package/dist/src/wgsl/grid-downsample.wgsl.js.map +1 -0
  167. package/dist/src/wgsl/grid-far-field.wgsl.d.ts +13 -0
  168. package/dist/src/wgsl/grid-far-field.wgsl.d.ts.map +1 -0
  169. package/dist/src/wgsl/grid-far-field.wgsl.js +98 -0
  170. package/dist/src/wgsl/grid-far-field.wgsl.js.map +1 -0
  171. package/dist/src/wgsl/grid-near-field.wgsl.d.ts +19 -0
  172. package/dist/src/wgsl/grid-near-field.wgsl.d.ts.map +1 -0
  173. package/dist/src/wgsl/grid-near-field.wgsl.js +129 -0
  174. package/dist/src/wgsl/grid-near-field.wgsl.js.map +1 -0
  175. package/dist/src/wgsl/histogram.wgsl.d.ts +7 -0
  176. package/dist/src/wgsl/histogram.wgsl.d.ts.map +1 -0
  177. package/dist/src/wgsl/histogram.wgsl.js +15 -0
  178. package/dist/src/wgsl/histogram.wgsl.js.map +1 -0
  179. package/dist/src/wgsl/indirect-finalize.wgsl.d.ts +8 -0
  180. package/dist/src/wgsl/indirect-finalize.wgsl.d.ts.map +1 -0
  181. package/dist/src/wgsl/indirect-finalize.wgsl.js +26 -0
  182. package/dist/src/wgsl/indirect-finalize.wgsl.js.map +1 -0
  183. package/dist/src/wgsl/radix-hist.wgsl.d.ts +9 -0
  184. package/dist/src/wgsl/radix-hist.wgsl.d.ts.map +1 -0
  185. package/dist/src/wgsl/radix-hist.wgsl.js +31 -0
  186. package/dist/src/wgsl/radix-hist.wgsl.js.map +1 -0
  187. package/dist/src/wgsl/radix-scatter.wgsl.d.ts +9 -0
  188. package/dist/src/wgsl/radix-scatter.wgsl.d.ts.map +1 -0
  189. package/dist/src/wgsl/radix-scatter.wgsl.js +40 -0
  190. package/dist/src/wgsl/radix-scatter.wgsl.js.map +1 -0
  191. package/dist/src/wgsl/scan-add.wgsl.d.ts +6 -0
  192. package/dist/src/wgsl/scan-add.wgsl.d.ts.map +1 -0
  193. package/dist/src/wgsl/scan-add.wgsl.js +14 -0
  194. package/dist/src/wgsl/scan-add.wgsl.js.map +1 -0
  195. package/dist/src/wgsl/scan-block.wgsl.d.ts +8 -0
  196. package/dist/src/wgsl/scan-block.wgsl.d.ts.map +1 -0
  197. package/dist/src/wgsl/scan-block.wgsl.js +30 -0
  198. package/dist/src/wgsl/scan-block.wgsl.js.map +1 -0
  199. package/dist/src/wgsl/segmented-reduce.wgsl.d.ts +22 -8
  200. package/dist/src/wgsl/segmented-reduce.wgsl.d.ts.map +1 -1
  201. package/dist/src/wgsl/segmented-reduce.wgsl.js +84 -15
  202. package/dist/src/wgsl/segmented-reduce.wgsl.js.map +1 -1
  203. package/dist/src/wgsl/spmv-pull.wgsl.d.ts +22 -11
  204. package/dist/src/wgsl/spmv-pull.wgsl.d.ts.map +1 -1
  205. package/dist/src/wgsl/spmv-pull.wgsl.js +110 -36
  206. package/dist/src/wgsl/spmv-pull.wgsl.js.map +1 -1
  207. package/dist/tsconfig.build.tsbuildinfo +1 -1
  208. package/dist/webgpu-graph-algorithms.js +5016 -1130
  209. package/dist/webgpu-graph-algorithms.js.map +1 -1
  210. package/package.json +10 -7
  211. package/src/accelerator.ts +46 -12
  212. package/src/algorithms/components.ts +12 -16
  213. package/src/algorithms/degree.ts +58 -43
  214. package/src/algorithms/pagerank.ts +20 -18
  215. package/src/algorithms/power-iteration.ts +19 -18
  216. package/src/constants.ts +108 -8
  217. package/src/errors.ts +3 -1
  218. package/src/index.ts +25 -5
  219. package/src/kernel/dispatch.ts +18 -7
  220. package/src/kernel/kernel.ts +59 -5
  221. package/src/kernel/prelude.ts +15 -0
  222. package/src/kernel/profiler.ts +28 -4
  223. package/src/kernels.ts +378 -24
  224. package/src/layouts/calibrate.ts +187 -0
  225. package/src/layouts/force-simulation.ts +111 -26
  226. package/src/layouts/forceatlas2.ts +346 -324
  227. package/src/layouts/fruchterman-reingold.ts +918 -0
  228. package/src/layouts/model-common.ts +323 -0
  229. package/src/layouts/repulsion-grid.ts +451 -0
  230. package/src/layouts/spring-electrical.ts +845 -0
  231. package/src/memory/residency.ts +126 -20
  232. package/src/primitives/core-shape.ts +91 -4
  233. package/src/primitives/grid-pyramid.ts +221 -0
  234. package/src/primitives/grid.ts +349 -0
  235. package/src/primitives/histogram.ts +273 -0
  236. package/src/primitives/radix-sort.ts +246 -0
  237. package/src/primitives/scan.ts +197 -0
  238. package/src/primitives/segmented-reduce.ts +214 -56
  239. package/src/primitives/spmv.ts +125 -65
  240. package/src/primitives/verify.ts +249 -0
  241. package/src/types/accelerator.ts +15 -3
  242. package/src/types/context.ts +56 -0
  243. package/src/types/layout.ts +58 -0
  244. package/src/types/options.ts +45 -1
  245. package/src/wgsl/counting-scatter.wgsl.ts +16 -0
  246. package/src/wgsl/fa2-attraction.wgsl.ts +100 -17
  247. package/src/wgsl/fa2-integrate.wgsl.ts +28 -2
  248. package/src/wgsl/fa2-repulsion-exact.wgsl.ts +14 -5
  249. package/src/wgsl/fa2-stats-finalize.wgsl.ts +54 -0
  250. package/src/wgsl/grid-cell-key.wgsl.ts +29 -0
  251. package/src/wgsl/grid-centroid-hub.wgsl.ts +28 -0
  252. package/src/wgsl/grid-centroid.wgsl.ts +28 -0
  253. package/src/wgsl/grid-downsample.wgsl.ts +27 -0
  254. package/src/wgsl/grid-far-field.wgsl.ts +97 -0
  255. package/src/wgsl/grid-near-field.wgsl.ts +128 -0
  256. package/src/wgsl/histogram.wgsl.ts +14 -0
  257. package/src/wgsl/indirect-finalize.wgsl.ts +25 -0
  258. package/src/wgsl/radix-hist.wgsl.ts +30 -0
  259. package/src/wgsl/radix-scatter.wgsl.ts +39 -0
  260. package/src/wgsl/scan-add.wgsl.ts +13 -0
  261. package/src/wgsl/scan-block.wgsl.ts +29 -0
  262. package/src/wgsl/segmented-reduce.wgsl.ts +84 -15
  263. package/src/wgsl/spmv-pull.wgsl.ts +110 -36
  264. package/dist/chunks/context-CRbw2Wyo.js.map +0 -1
@@ -0,0 +1,845 @@
1
+ /**
2
+ * The spring-electrical preset on the exact or the grid repulsion tier (spec 7.20 "the preset", 7.8; contract 3.13):
3
+ * ngraph.forcelayout's physics under ngraph's option names and defaults -- Coulomb repulsion `-g m_i m_j / d^2` (K3,
4
+ * LAW 2; G6 / G7 with LAW 2 on the grid tier, P4-T13), Hooke springs
5
+ * `k_s (d - L)` (K2, LAW 2), drag and the semi-implicit Euler step with the unit speed clamp over a per-node velocity
6
+ * (K5, APPLY 2), the kinetic energy folded into the trace (K1, STATS_MODE 2; PD-4) -- as the ForceModel that
7
+ * ForceSimulation drives over the four FA2 kernels K1 K2 K3 K5 per iteration and toScene per batch. No K4: there is no
8
+ * speed controller. The velocity is the model's `velocity` buffer bound into the `oldForce` slot of K3 and K5 (PD-2),
9
+ * left alone by the FA2 text because the model compiles `SWING_MODE = 1` (PD-20). Mass is `1 + degree / 3` and the
10
+ * weights are ignored (PD-11); `SpringElectricalOptions.gravity` is ngraph's Coulomb constant, written into
11
+ * `Fa2Params.coulomb` while FA2's centre gravity is 0 (PD-12). Settlement is the shared rule of spec 7.17
12
+ * (DEP-P5-A); `reheat()` leaves the velocities alone (ngraph has no reheat).
13
+ *
14
+ * Model decisions this file shares with forceatlas2.ts: on the exact tier the K1-K5 dispatches of one batch share ONE
15
+ * compute pass and toScene runs in a second pass that ends it; the fill kernel takes its FillParams from a model-owned
16
+ * 256-byte uniform buffer. The grid tier (P4-T13, PD-22) is FA2's: `RepulsionGrid` with `LAW` 2 when `tierFor(tuning,
17
+ * n)` says so (PD-18), the grid buffers from `buffers()`, K1's grid block under `gridMax > 0` (PD-14) over the model's
18
+ * own `hubCounters`, the three passes `se-k1` / `se-attraction` / `se-grid` before `se-to-scene` (PD-16), and the
19
+ * union stage list (PD-17; K4 is never recorded).
20
+ */
21
+
22
+ import { type GraphSnapshot } from "@graphty/graph-format";
23
+
24
+ import {
25
+ MAX_ITERATIONS_PER_STEP,
26
+ SE_DEFAULTS,
27
+ SE_SCALE_REFERENCE_NODES,
28
+ TRACE_RECORD_BYTES,
29
+ UNIFORM_SLOT_BYTES,
30
+ } from "../constants.js";
31
+ import { type GpuContext } from "../context.js";
32
+ import { BufferUsage } from "../device/webgpu-constants.js";
33
+ import { WebGpuGraphError } from "../errors.js";
34
+ import { type CommandBatch } from "../kernel/batch.js";
35
+ import { type DispatchPlan, plan1d } from "../kernel/dispatch.js";
36
+ import { type BoundKernel, type Kernel } from "../kernel/kernel.js";
37
+ import { type UniformBlock, type UniformValues } from "../kernel/struct-block.js";
38
+ import { type WgslModuleSpec } from "../kernel/wgsl.js";
39
+ import { FA2_PARAMS, FA2_STATE, FA2_TRACE, FILL_PARAMS, kernelSpec } from "../kernels.js";
40
+ import { arcCountOf } from "../primitives/core-shape.js";
41
+ import { type GridSpec, gridSpecFor } from "../primitives/grid.js";
42
+ import {
43
+ type GpuLayoutSimulation,
44
+ type GpuLayoutTuning,
45
+ type ResolvedLayoutTuning,
46
+ type SpringElectricalStats,
47
+ type SpringElectricalTraceRecord,
48
+ } from "../types/layout.js";
49
+ import { type Binding } from "../types/memory.js";
50
+ import { type ResolvedSpringElectricalOptions, type SpringElectricalOptions } from "../types/options.js";
51
+ import {
52
+ type BufferSpec,
53
+ type ForceModel,
54
+ ForceSimulation,
55
+ type ModelInputs,
56
+ type ModelResources,
57
+ type StateWriter,
58
+ tierFor,
59
+ } from "./force-simulation.js";
60
+ import { resolveLayoutTuning, writeGridFrame } from "./forceatlas2.js";
61
+ import {
62
+ type AttractionBound,
63
+ bindAttraction,
64
+ describeValue,
65
+ FILL_PARAMS_BUFFER,
66
+ FORCE_BYTES_PER_NODE,
67
+ invalid,
68
+ isPositiveInteger,
69
+ type Overrides,
70
+ pickCenter,
71
+ pickDim,
72
+ pickNumber,
73
+ pickSeed,
74
+ recordAttraction,
75
+ scalar,
76
+ seedWord,
77
+ subset,
78
+ vector,
79
+ } from "./model-common.js";
80
+ import { type GridStage, RepulsionGrid, type RepulsionGridOverrides } from "./repulsion-grid.js";
81
+
82
+ // ============================================================ constants
83
+
84
+ /** The stage names of both tiers in dispatch order plus the per-batch toScene (spec 7.20; P4 PD-17: the union list; the exact tier records K1 K2 K3 K5, the grid tier K1 K2 G1..G7 K5, K4 never). */
85
+ const SE_STAGES = ["K1", "K2", "K3", "G1", "G2", "G3", "G4", "G5", "G6", "G7", "K4", "K5", "toScene"] as const;
86
+
87
+ /** The SE_STAGES index of the first grid stage, of K4, of K5 and of toScene. */
88
+ const STAGE_G1 = 3;
89
+ const STAGE_K4 = 10;
90
+ const STAGE_K5 = 11;
91
+ const STAGE_TO_SCENE = 12;
92
+
93
+ /** The name of the model-owned hub-counter buffer K1 binds on every tier (P4 PD-14). */
94
+ const HUB_COUNTERS_BUFFER = "hubCounters";
95
+
96
+ /** The one-workgroup dispatch of K1. */
97
+ const ONE_WORKGROUP: DispatchPlan = { x: 1, y: 1, z: 1, items: 1, stride: null };
98
+
99
+ /** The model's constant override set (PD-1, PD-20): the spring / coulomb laws, ngraph's Euler step, the kinetic-energy statistic, SWING_MODE 1 so the FA2 text never touches the oldForce slot. */
100
+ const SE_OVERRIDES: Overrides = Object.freeze({
101
+ LINLOG: false,
102
+ DISTRIBUTED: false,
103
+ TIER: 0,
104
+ SWING_MODE: 1,
105
+ STRONG_GRAVITY: false,
106
+ GRAVITY_CENTER: 0,
107
+ LAW: 2,
108
+ APPLY: 2,
109
+ STATS_MODE: 2,
110
+ });
111
+
112
+ /** The grid stage's override set (G6 / G7 / K4; P4-T13, PD-22): K3's constant three and the coulomb law. */
113
+ const SE_GRID_OVERRIDES: RepulsionGridOverrides = Object.freeze({
114
+ SWING_MODE: 1,
115
+ STRONG_GRAVITY: false,
116
+ GRAVITY_CENTER: 0,
117
+ LAW: 2,
118
+ });
119
+
120
+ /** Every override K1 accepts, with its default. */
121
+ const K1_DEFAULTS: Overrides = { STATS_MODE: 0 };
122
+
123
+ /** Every override K2 accepts, with its default (plus the two standard graph overrides). */
124
+ const K2_DEFAULTS: Overrides = {
125
+ LINLOG: false,
126
+ DISTRIBUTED: false,
127
+ TIER: 0,
128
+ USE_PERM: false,
129
+ HAS_WEIGHTS: false,
130
+ LAW: 0,
131
+ };
132
+
133
+ /** Every override K3 accepts, with its default. */
134
+ const K3_DEFAULTS: Overrides = { SWING_MODE: 0, STRONG_GRAVITY: false, GRAVITY_CENTER: 0, LAW: 0 };
135
+
136
+ /** Every override K5 accepts, with its default. */
137
+ const K5_DEFAULTS: Overrides = { SWING_MODE: 0, APPLY: 0 };
138
+
139
+ /** The name of the model's velocity buffer (bound into the `oldForce` slot of K3 / K5, PD-2). */
140
+ const VELOCITY_BUFFER = "velocity";
141
+
142
+ /** The resolved record with no option given: SE_DEFAULTS plus the origin centre and the null seed; the two force constants null = size-scaled at load. */
143
+ const DEFAULT_RESOLVED: ResolvedSpringElectricalOptions = Object.freeze<ResolvedSpringElectricalOptions>({
144
+ ...SE_DEFAULTS,
145
+ gravity: null,
146
+ springCoefficient: null,
147
+ center: [0, 0, 0],
148
+ seed: null,
149
+ });
150
+
151
+ /**
152
+ * The size factor of the default force constants: 1 up to SE_SCALE_REFERENCE_NODES nodes (ngraph's constants as
153
+ * they are), then SE_SCALE_REFERENCE_NODES / n, so the per-node force stays at the level ngraph's constants were
154
+ * tuned for instead of pinning every node at the unit speed clamp.
155
+ * @param n - the node count
156
+ * @returns the factor in (0, 1]
157
+ */
158
+ export function springSizeFactor(n: number): number {
159
+ return Math.min(1, SE_SCALE_REFERENCE_NODES / Math.max(1, n));
160
+ }
161
+
162
+ /**
163
+ * An optional force constant: undefined keeps the fallback; null means the size-scaled default; else a finite number
164
+ * passing `ok`.
165
+ * @param name - the option name
166
+ * @param given - the value given
167
+ * @param fallback - the previous record's value or the default
168
+ * @param ok - the range predicate
169
+ * @param range - the range text of the error
170
+ * @returns the number or null
171
+ */
172
+ function pickNullable(
173
+ name: string,
174
+ given: unknown,
175
+ fallback: number | null,
176
+ ok: (v: number) => boolean,
177
+ range: string,
178
+ ): number | null {
179
+ if (given === undefined) {
180
+ return fallback;
181
+ }
182
+ if (given === null) {
183
+ return null;
184
+ }
185
+ return pickNumber(name, given as number | undefined, 1, ok, `${range} or null`);
186
+ }
187
+
188
+ // ============================================================ the resolver
189
+
190
+ /**
191
+ * Applies SE_DEFAULTS to the option record; validates ranges (spec 7.20; ngraph's constraints): `springLength` > 0,
192
+ * `springCoefficient` > 0, `dragCoefficient` >= 0, `timeStep` > 0, `gravity` any finite number (negative repels; ngraph's
193
+ * comment: "if you make it positive nodes start attract each other"). `gravity` and `springCoefficient` left out (or
194
+ * null) resolve to null: ngraph's constant times springSizeFactor(n), applied in paramsFor once n is known. With `previous` the record is a PATCH over it and
195
+ * `maxInFlight` may not change (the uniform ring is sized by it at construction).
196
+ * @param options - the caller's options (or a setParams patch)
197
+ * @param previous - the current resolved record when resolving a patch
198
+ * @returns the frozen resolved record
199
+ */
200
+ export function resolveSpringElectricalOptions(
201
+ options: SpringElectricalOptions | undefined,
202
+ previous?: ResolvedSpringElectricalOptions,
203
+ ): ResolvedSpringElectricalOptions {
204
+ const o: SpringElectricalOptions = options ?? {};
205
+ const base = previous ?? DEFAULT_RESOLVED;
206
+ if (previous !== undefined && o.maxInFlight !== undefined && o.maxInFlight !== previous.maxInFlight) {
207
+ throw new WebGpuGraphError(
208
+ "E_INVALID_ARGUMENT",
209
+ `maxInFlight cannot change after creation (the uniform ring is sized by it): got ${describeValue(o.maxInFlight)}, current ${previous.maxInFlight}`,
210
+ { argument: "maxInFlight", value: o.maxInFlight, expected: previous.maxInFlight },
211
+ );
212
+ }
213
+ const resolved: ResolvedSpringElectricalOptions = {
214
+ springLength: pickNumber("springLength", o.springLength, base.springLength, (v) => v > 0, "> 0"),
215
+ springCoefficient: pickNullable(
216
+ "springCoefficient",
217
+ o.springCoefficient,
218
+ base.springCoefficient,
219
+ (v) => v > 0,
220
+ "> 0",
221
+ ),
222
+ gravity: pickNullable("gravity", o.gravity, base.gravity, () => true, "a finite number (negative repels)"),
223
+ dragCoefficient: pickNumber("dragCoefficient", o.dragCoefficient, base.dragCoefficient, (v) => v >= 0, ">= 0"),
224
+ timeStep: pickNumber("timeStep", o.timeStep, base.timeStep, (v) => v > 0, "> 0"),
225
+ dim: pickDim(o.dim, base.dim),
226
+ scale: pickNumber("scale", o.scale, base.scale, (v) => v > 0, "> 0"),
227
+ center: pickCenter(o.center, base.center),
228
+ seed: pickSeed(o.seed, base.seed),
229
+ settleThreshold: pickNumber("settleThreshold", o.settleThreshold, base.settleThreshold, (v) => v >= 0, ">= 0"),
230
+ settleWindow: pickNumber(
231
+ "settleWindow",
232
+ o.settleWindow,
233
+ base.settleWindow,
234
+ isPositiveInteger,
235
+ "an integer >= 1",
236
+ ),
237
+ iterationsPerStep: pickNumber(
238
+ "iterationsPerStep",
239
+ o.iterationsPerStep,
240
+ base.iterationsPerStep,
241
+ (v) => isPositiveInteger(v) && v <= MAX_ITERATIONS_PER_STEP,
242
+ `an integer in [1, ${MAX_ITERATIONS_PER_STEP}]`,
243
+ ),
244
+ maxInFlight: pickNumber("maxInFlight", o.maxInFlight, base.maxInFlight, isPositiveInteger, "an integer >= 1"),
245
+ };
246
+ return Object.freeze(resolved);
247
+ }
248
+
249
+ // ============================================================ the model
250
+
251
+ /** Everything bind() produced for one load(): the kernels, their bind groups and the dispatch plans of this n. */
252
+ interface BoundModel {
253
+ readonly n: number;
254
+ /** plan1d(n): K2, K3, K5, toScene. */
255
+ readonly plan: DispatchPlan;
256
+ /** plan1d(3n): the fill of force (arcCount === 0 only). */
257
+ readonly fillPlan: DispatchPlan;
258
+ readonly k1: Kernel;
259
+ readonly k1Bound: BoundKernel;
260
+ /** The K2 tier dispatches (P4 PD-7); null when arcCount === 0 (K2 is not recorded; the fill below zeroes force instead). */
261
+ readonly attraction: AttractionBound | null;
262
+ /** K3 and its bind group, or null on the grid tier (P4 PD-18): one tier's kernels compile per load. */
263
+ readonly k3: Kernel | null;
264
+ readonly k3Bound: BoundKernel | null;
265
+ /** The grid-tier stage (G1-G7), or null on the exact tier (P4 PD-18). */
266
+ readonly grid: RepulsionGrid | null;
267
+ readonly k5: Kernel;
268
+ readonly k5Bound: BoundKernel;
269
+ readonly toScene: Kernel;
270
+ readonly toSceneBound: BoundKernel;
271
+ readonly fill: Kernel;
272
+ /** The fill of `force` (arcCount === 0 only). */
273
+ readonly fillForceBound: BoundKernel | null;
274
+ }
275
+
276
+ /**
277
+ * The mass of the preset: `1 + degree / 3` per node (ngraph index.js:391-395; the undirected snapshot's outDegree is
278
+ * the degree), as the Float32Array the simulation packs into pos.w.
279
+ * @param s - the snapshot
280
+ * @returns n masses
281
+ */
282
+ function massOf(s: GraphSnapshot): Float32Array<ArrayBuffer> {
283
+ const degree = s.outDegree();
284
+ const out = new Float32Array(s.nodeCount);
285
+ for (let i = 0; i < s.nodeCount; i++) {
286
+ out[i] = 1 + degree[i] / 3;
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /** The spring-electrical model (spec 7.20: K1 K2 K3 K5 per iteration on the exact tier, K1 K2 G1..G7 K5 on the grid tier; toScene once per batch). Stages: the union list of PD-17. */
292
+ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions, SpringElectricalStats> {
293
+ /** The model kind of spec 7.19. */
294
+ readonly kind = "springElectrical";
295
+ /** The stage names in dispatch order (the `upTo` vocabulary of recordIteration and debugRunStages). */
296
+ readonly stages: typeof SE_STAGES = SE_STAGES;
297
+ /** Fa2Params: the per-iteration uniform block (the simulation writes the shared fields into it). */
298
+ readonly params: UniformBlock = FA2_PARAMS;
299
+ /** Fa2State: the state header block. */
300
+ readonly state: UniformBlock = FA2_STATE;
301
+ /** Fa2Trace: one record per iteration of a batch. */
302
+ readonly trace: UniformBlock = FA2_TRACE;
303
+ /** The resolved GPU-only tuning this model was created with (only `repulsion` / `exactMaxNodes` / `nearMax` / `extentFactor` matter here). */
304
+ readonly tuning: ResolvedLayoutTuning;
305
+
306
+ /** The option record the model holds: the constructor's record, replaced by onSetParams() ONLY (the query hooks never assign it). */
307
+ private current: ResolvedSpringElectricalOptions;
308
+ /** The resources of the last bind(), or null before the first. */
309
+ private resources: ModelResources | null = null;
310
+ /** The kernels and bind groups of the last bind(), or null before it (and for n === 0). */
311
+ private bound: BoundModel | null = null;
312
+ /** The grid of the load inputs() last resolved (null on the exact tier): onLoad() writes its frame, specs() lists its kernels. */
313
+ private nextGrid: GridSpec | null = null;
314
+ /** The K1-K5 compute pass of the batch being recorded, keyed by CommandBatch.id; null between batches. */
315
+ private openPass: { readonly id: number; readonly pass: GPUComputePassEncoder } | null = null;
316
+
317
+ /**
318
+ * Creates the model for one simulation.
319
+ * @param tuning - the resolved GPU-only tuning
320
+ * @param resolved - the resolved option record at creation
321
+ */
322
+ constructor(tuning: ResolvedLayoutTuning, resolved: ResolvedSpringElectricalOptions) {
323
+ this.tuning = tuning;
324
+ this.current = resolved;
325
+ }
326
+
327
+ /**
328
+ * force 12n (zeroed), velocity 12n (zeroed: every load() starts at rest, `allocate()` / `clearKept()` honour
329
+ * `zero`), the 256-byte FillParams uniform buffer, the 16-byte `hubCounters` K1 binds on every tier (P4 PD-14),
330
+ * and the grid buffers of `RepulsionGrid.buffers` exactly when `tierFor(tuning, n)` is the grid tier (PD-18).
331
+ * n = 0 reports one node's worth of bytes so no zero-length buffer is ever created.
332
+ * @param n - the node count
333
+ * @param dim - the layout dimension (the arrays are stride 3 in both; the grid's geometry differs)
334
+ * @returns the model-owned buffer specs
335
+ */
336
+ buffers(n: number, dim: 2 | 3): readonly BufferSpec[] {
337
+ const bytes = Math.max(1, n) * FORCE_BYTES_PER_NODE;
338
+ const usage = BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST;
339
+ const grid =
340
+ tierFor(this.tuning, n) === "grid" ? RepulsionGrid.buffers(n, gridSpecFor(n, dim, this.tuning)) : [];
341
+ return [
342
+ { name: "force", byteLength: bytes, usage, zero: true },
343
+ { name: VELOCITY_BUFFER, byteLength: bytes, usage, zero: true },
344
+ {
345
+ name: FILL_PARAMS_BUFFER,
346
+ byteLength: UNIFORM_SLOT_BYTES,
347
+ usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST,
348
+ zero: false,
349
+ },
350
+ { name: HUB_COUNTERS_BUFFER, byteLength: 16, usage, zero: true },
351
+ ...grid,
352
+ ];
353
+ }
354
+
355
+ /**
356
+ * { mass: 1 + degree / 3, weights: none } (PD-11): the preset has no mass or weight option, and K2 compiles
357
+ * HAS_WEIGHTS = false. No `fixed` (setFixed is the live API). Also remembers the grid of this load
358
+ * (`tierFor(tuning, n)`, spec 7.8) for onLoad() and specs(): the simulation calls inputs() first, then onLoad()
359
+ * before bind().
360
+ * @param s - the snapshot being loaded
361
+ * @param options - the simulation's current option record (its dimension picks the grid's geometry)
362
+ * @returns the per-load inputs
363
+ */
364
+ inputs(s: GraphSnapshot, options: SpringElectricalOptions): ModelInputs {
365
+ const { dim } = resolveSpringElectricalOptions(options, this.current);
366
+ const n = s.nodeCount;
367
+ this.nextGrid = tierFor(this.tuning, n) === "grid" ? gridSpecFor(n, dim, this.tuning) : null;
368
+ return { mass: massOf(s), weights: { data: null, source: "none", column: null } };
369
+ }
370
+
371
+ /**
372
+ * The constant SE_OVERRIDES (PD-1): no option changes a law, so setParams never recompiles.
373
+ * @param _options - an option record (unused)
374
+ * @returns the model's own override set
375
+ */
376
+ overrides(_options: SpringElectricalOptions): Overrides {
377
+ return SE_OVERRIDES;
378
+ }
379
+
380
+ /**
381
+ * The six module specs of an override set in dispatch order -- K1, K2, K3, K5, toScene, fill -- each with only the
382
+ * override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix,
383
+ * followed by the grid tier's specs (`RepulsionGrid.specs` under SE_GRID_OVERRIDES) when the load inputs() last
384
+ * resolved is a grid load (the pipeline key carries no geometry).
385
+ * @param overrides - the merged override set (the model's plus USE_PERM / HAS_WEIGHTS)
386
+ * @param _subgroups - accepted for the ForceModel interface and unused (the composer picks the twin from caps)
387
+ * @returns the specs
388
+ */
389
+ specs(overrides: Overrides, _subgroups: boolean): readonly WgslModuleSpec[] {
390
+ const grid = this.nextGrid === null ? [] : RepulsionGrid.specs(SE_GRID_OVERRIDES, this.nextGrid);
391
+ return [
392
+ kernelSpec("fa2-stats-finalize", subset(overrides, K1_DEFAULTS)),
393
+ kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS)),
394
+ kernelSpec("fa2-repulsion-exact", subset(overrides, K3_DEFAULTS)),
395
+ kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS)),
396
+ kernelSpec("fa2-to-scene"),
397
+ kernelSpec("fill"),
398
+ ...grid,
399
+ ];
400
+ }
401
+
402
+ /**
403
+ * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 over the degree
404
+ * tiers through bindAttraction (or the fill of force when arcCount === 0), K3 on the exact tier or G1-G7 through
405
+ * RepulsionGrid on the grid tier (PD-18), K5, with the velocity in the `oldForce` slot of K3 / G7 / K5 (PD-2),
406
+ * toScene; writes the FillParams { count: 3n, value: 0, mode: 0 } into the model's uniform buffer. With n === 0
407
+ * nothing is bound. The K2 TIER 1 / 2 pipelines compile on the first load whose degrees need them (P4 PD-7).
408
+ * @param resources - the graph, the shared and model buffers, the ring and the cache
409
+ * @param overrides - the merged override set
410
+ */
411
+ async bind(resources: ModelResources, overrides: Overrides): Promise<void> {
412
+ this.dropBound();
413
+ this.resources = resources;
414
+ const { n, pipelines, caps, core, ring, device } = resources;
415
+ if (n === 0) {
416
+ return;
417
+ }
418
+ const pos = resources.buffer("positions");
419
+ const force = resources.buffer("force");
420
+ const params = ring.binding(FA2_PARAMS);
421
+ const hasArcs = core.colIdx !== null;
422
+ const [k1, k5, toScene, fill] = await Promise.all([
423
+ pipelines.kernel(kernelSpec("fa2-stats-finalize", subset(overrides, K1_DEFAULTS))),
424
+ pipelines.kernel(kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS))),
425
+ pipelines.kernel(kernelSpec("fa2-to-scene")),
426
+ pipelines.kernel(kernelSpec("fill")),
427
+ ]);
428
+ const k3 =
429
+ resources.tier === "grid"
430
+ ? null
431
+ : await pipelines.kernel(kernelSpec("fa2-repulsion-exact", subset(overrides, K3_DEFAULTS)));
432
+ const attraction = hasArcs
433
+ ? await bindAttraction(resources, subset(overrides, K2_DEFAULTS), { pos, force, params })
434
+ : null;
435
+ const grid =
436
+ resources.tier === "grid"
437
+ ? await RepulsionGrid.create(
438
+ resources,
439
+ k1.workgroupSize,
440
+ SE_GRID_OVERRIDES,
441
+ gridSpecFor(n, resources.dim, this.tuning),
442
+ )
443
+ : null;
444
+ if (this.resources !== resources) {
445
+ // a newer bind() superseded this one while the pipelines compiled; its own bind groups stand
446
+ grid?.dispose();
447
+ return;
448
+ }
449
+ const scene = resources.buffer("scenePositions");
450
+ const fixed = resources.buffer("fixed");
451
+ const partials = resources.buffer("partials");
452
+ const state = resources.buffer("state");
453
+ const trace = resources.buffer("trace");
454
+ const velocity = resources.buffer(VELOCITY_BUFFER);
455
+ const fillParamsBuffer = resources.buffer(FILL_PARAMS_BUFFER);
456
+ const fillParams: Binding = {
457
+ buffer: fillParamsBuffer.buffer,
458
+ offset: fillParamsBuffer.offset,
459
+ size: FILL_PARAMS.byteLength,
460
+ window: null,
461
+ };
462
+ const fillBytes = new ArrayBuffer(FILL_PARAMS.byteLength);
463
+ FILL_PARAMS.write(new DataView(fillBytes), { count: 3 * n, value: 0, mode: 0 });
464
+ device.queue.writeBuffer(fillParamsBuffer.buffer, fillParamsBuffer.offset, fillBytes);
465
+ const hubCounters = resources.buffer(HUB_COUNTERS_BUFFER);
466
+ grid?.bind({
467
+ pos,
468
+ state,
469
+ trace,
470
+ force,
471
+ oldForce: velocity,
472
+ fixedMask: fixed,
473
+ partials,
474
+ params,
475
+ cellKey: resources.buffer("cellKey"),
476
+ cellVal: resources.buffer("cellVal"),
477
+ sortedKey: resources.buffer("sortedKey"),
478
+ sortedIdx: resources.buffer("sortedIdx"),
479
+ cellHist: resources.buffer("cellHist"),
480
+ cellStart: resources.buffer("cellStart"),
481
+ hubList: resources.buffer("hubList"),
482
+ hubCounters,
483
+ hubArgs: resources.buffer("hubArgs"),
484
+ pyramid: resources.buffer("pyramid"),
485
+ });
486
+ const wg = k1.workgroupSize;
487
+ this.bound = {
488
+ n,
489
+ plan: plan1d(n, wg, caps),
490
+ fillPlan: plan1d(3 * n, wg, caps),
491
+ k1,
492
+ // PD-14: on the exact tier K1's cellHist slot takes a dummy (partials, both read-only) and the block is
493
+ // dead under gridMax 0; hubCounters is the model's 16-byte buffer on every tier
494
+ k1Bound: k1.bind({
495
+ partials,
496
+ S: state,
497
+ T: trace,
498
+ cellHist: grid === null ? partials : resources.buffer("cellHist"),
499
+ hubCounters,
500
+ P: params,
501
+ }),
502
+ attraction,
503
+ k3,
504
+ k3Bound:
505
+ k3?.bind({ pos, S: state, force, oldForce: velocity, fixedMask: fixed, partials, P: params }) ?? null,
506
+ grid,
507
+ k5,
508
+ k5Bound: k5.bind({ force, oldForce: velocity, fixedMask: fixed, S: state, pos, partials, P: params }),
509
+ toScene,
510
+ toSceneBound: toScene.bind({ pos, scene, P: params }),
511
+ fill,
512
+ fillForceBound: hasArcs ? null : fill.bind({ dst: force, P: fillParams }),
513
+ };
514
+ }
515
+
516
+ /**
517
+ * The Fa2Params values of one iteration: the shared fields, the FA2 fields at 0 (no centre gravity, PD-12; no
518
+ * FR temperature) and ngraph's five constants with `gravity` in `coulomb`.
519
+ * @param iteration - the global iteration index
520
+ * @param options - the simulation's current option record
521
+ * @returns the uniform values
522
+ */
523
+ paramsFor(iteration: number, options: SpringElectricalOptions): UniformValues {
524
+ const { n, core, tiers, tier, dim } = this.requireResources();
525
+ const resolved = resolveSpringElectricalOptions(options, this.current);
526
+ const { nearMax, extentFactor } = this.tuning;
527
+ const grid = tier === "grid" ? gridSpecFor(n, dim, this.tuning) : null;
528
+ // P4 PD-7: TIER 2 reads [0, hiEnd), TIER 1 [hiEnd, midEnd), TIER 0 [tierStart, tierEnd) = [midEnd, n)
529
+ const so = tiers?.segmentOffsets;
530
+ const hiEnd = so?.[1] ?? 0;
531
+ const midEnd = so?.[2] ?? 0;
532
+ return {
533
+ n,
534
+ dim: resolved.dim,
535
+ flags: 0,
536
+ tierStart: midEnd,
537
+ tierEnd: n,
538
+ iterationIndex: iteration,
539
+ seed: seedWord(resolved.seed),
540
+ nearMax,
541
+ scalingRatio: 0,
542
+ gravity: 0,
543
+ jitterTolerance: 0,
544
+ scale: resolved.scale,
545
+ center: [resolved.center[0], resolved.center[1], resolved.center[2], 0],
546
+ settleThreshold: resolved.settleThreshold,
547
+ extentFactor,
548
+ gridMax: grid?.g ?? 0,
549
+ levels: grid?.levels ?? 0,
550
+ arcBase: 0,
551
+ arcEnd: arcCountOf(core),
552
+ accumulate: 0,
553
+ hiEnd,
554
+ midEnd,
555
+ frK: 0,
556
+ temperature: 0,
557
+ springLength: resolved.springLength,
558
+ springCoefficient: resolved.springCoefficient ?? SE_DEFAULTS.springCoefficient * springSizeFactor(n),
559
+ coulomb: resolved.gravity ?? SE_DEFAULTS.gravity * springSizeFactor(n),
560
+ dragCoefficient: resolved.dragCoefficient,
561
+ timeStep: resolved.timeStep,
562
+ };
563
+ }
564
+
565
+ /**
566
+ * Records one iteration into the batch, stopping after stage `upTo` when given (PD-17: `upTo` names a position
567
+ * in the union list and the recording stops after the last stage recorded at or before it, so "K3" on the grid
568
+ * tier stops after K2 and "G5" on the exact tier after K3). The exact tier: K1, K2 (or the fill of force when
569
+ * arcCount === 0), K3, K5 in the batch's ONE compute pass, then toScene in a second pass that ends it. The grid
570
+ * tier (PD-16): the passes `se-k1` (K1), `se-attraction` (K2's tiers) and `se-grid` (G1-G7, K5) per iteration,
571
+ * then `se-to-scene`. With n === 0 nothing is recorded; a call before bind() completed is E_NOT_LOADED.
572
+ * @param batch - the batch being recorded
573
+ * @param slot - the UniformRing slot holding this iteration's Fa2Params
574
+ * @param tier - the tier the simulation resolved at load() (the same rule bind() applied, PD-18)
575
+ * @param upTo - a stage name to stop after; undefined records every stage including toScene
576
+ */
577
+ recordIteration(batch: CommandBatch, slot: number, tier: "exact" | "grid", upTo?: string): void {
578
+ const resources = this.requireResources();
579
+ const stop = upTo === undefined ? STAGE_TO_SCENE : this.stageIndex(upTo);
580
+ const { bound } = this;
581
+ if (bound === null) {
582
+ if (resources.n === 0) {
583
+ return;
584
+ }
585
+ throw new WebGpuGraphError(
586
+ "E_NOT_LOADED",
587
+ "the spring-electrical model is not bound (bind() has not completed)",
588
+ { state: "loaded" },
589
+ );
590
+ }
591
+ const offset = resources.ring.offsetOf(slot);
592
+ if (tier === "grid") {
593
+ this.recordGridIteration(batch, bound, offset, stop);
594
+ return;
595
+ }
596
+ const { k3, k3Bound } = bound;
597
+ if (k3 === null || k3Bound === null) {
598
+ throw new WebGpuGraphError("E_NOT_LOADED", "the spring-electrical model was bound on the grid tier", {
599
+ state: "loaded",
600
+ });
601
+ }
602
+ const pass = this.openPass !== null && this.openPass.id === batch.id ? this.openPass.pass : batch.pass("se");
603
+ this.openPass = { id: batch.id, pass };
604
+ bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
605
+ if (stop < 1) {
606
+ return;
607
+ }
608
+ this.recordK2(pass, bound, offset);
609
+ if (stop < 2) {
610
+ return;
611
+ }
612
+ k3.dispatch(pass, k3Bound, bound.plan, [offset]);
613
+ if (stop < STAGE_K5) {
614
+ return;
615
+ }
616
+ bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
617
+ if (stop < STAGE_TO_SCENE) {
618
+ return;
619
+ }
620
+ this.recordToScene(batch, bound, offset);
621
+ }
622
+
623
+ /**
624
+ * The grid tier's iteration (PD-16): three compute passes before toScene; no K4.
625
+ * @param batch - the batch being recorded
626
+ * @param bound - the bound model
627
+ * @param offset - the Fa2Params dynamic offset of the iteration
628
+ * @param stop - the SE_STAGES index to stop after
629
+ */
630
+ private recordGridIteration(batch: CommandBatch, bound: BoundModel, offset: number, stop: number): void {
631
+ const { grid } = bound;
632
+ if (grid === null) {
633
+ throw new WebGpuGraphError("E_NOT_LOADED", "the spring-electrical model was bound on the exact tier", {
634
+ state: "loaded",
635
+ });
636
+ }
637
+ this.openPass = null;
638
+ bound.k1.dispatch(batch.pass("se-k1"), bound.k1Bound, ONE_WORKGROUP, [offset]);
639
+ if (stop < 1) {
640
+ return;
641
+ }
642
+ this.recordK2(batch.pass("se-attraction"), bound, offset);
643
+ if (stop < STAGE_G1) {
644
+ return;
645
+ }
646
+ const pass = batch.pass("se-grid");
647
+ const gridStop = stop < STAGE_K4 ? (SE_STAGES[stop] as GridStage) : undefined;
648
+ grid.recordRepulsion(pass, bound.n, offset, gridStop);
649
+ if (stop < STAGE_K5) {
650
+ return;
651
+ }
652
+ bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
653
+ if (stop < STAGE_TO_SCENE) {
654
+ return;
655
+ }
656
+ this.recordToScene(batch, bound, offset);
657
+ }
658
+
659
+ /**
660
+ * K2's tier dispatches, or the fill of force when the graph has no arcs (spec 7.5).
661
+ * @param pass - the open compute pass
662
+ * @param bound - the bound model
663
+ * @param offset - the Fa2Params dynamic offset
664
+ */
665
+ private recordK2(pass: GPUComputePassEncoder, bound: BoundModel, offset: number): void {
666
+ if (bound.attraction !== null) {
667
+ recordAttraction(pass, bound.attraction, offset);
668
+ } else if (bound.fillForceBound !== null) {
669
+ bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
670
+ }
671
+ }
672
+
673
+ /**
674
+ * The toScene pass that ends the iteration's pass; the batch is complete after it, so nothing reuses the pass.
675
+ * @param batch - the batch
676
+ * @param bound - the bound model
677
+ * @param offset - the Fa2Params dynamic offset
678
+ */
679
+ private recordToScene(batch: CommandBatch, bound: BoundModel, offset: number): void {
680
+ this.openPass = null;
681
+ const scenePass = batch.pass("se-to-scene");
682
+ bound.toScene.dispatch(scenePass, bound.toSceneBound, bound.plan, [offset]);
683
+ }
684
+
685
+ /**
686
+ * kineticEnergy = 0 and temperature = 0 in the header (the velocities start at 0 through the buffer's `zero`);
687
+ * on a grid load the frame of the first build (K1 folds nothing on the first iteration).
688
+ * @param state - the state writer of the simulation
689
+ */
690
+ onLoad(state: StateWriter): void {
691
+ state.set("kineticEnergy", 0);
692
+ state.set("temperature", 0);
693
+ if (this.nextGrid !== null) {
694
+ writeGridFrame(state, this.nextGrid, this.tuning.extentFactor);
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Nothing: the velocities carry on (ngraph has no reheat; a drag lands in the next batch through the override
700
+ * list, spec 7.12).
701
+ * @param _state - the state writer of the simulation (unused)
702
+ */
703
+ onReheat(_state: StateWriter): void {
704
+ // intentionally empty (D8; DEP-P5-A)
705
+ }
706
+
707
+ /**
708
+ * Replaces the record with the patch applied (every option is a numeric tweak; nothing recompiles, no reset).
709
+ * @param patch - the setParams patch
710
+ * @param _state - the state writer of the simulation (unused)
711
+ */
712
+ onSetParams(patch: Partial<SpringElectricalOptions>, _state: StateWriter): void {
713
+ this.current = resolveSpringElectricalOptions(patch, this.current);
714
+ }
715
+
716
+ /**
717
+ * Decodes the state header and the k trace records of a completed batch into SpringElectricalStats:
718
+ * `repulsionTier` is the bound tier, the grid fields are the header's on the grid tier and null on the exact
719
+ * tier; msPerIteration null (the simulation owns the clock); `kineticEnergy` is the last folded value (one
720
+ * iteration behind the last integrate, PD-4).
721
+ * @param state - a DataView over the 256-byte state header
722
+ * @param trace - a DataView over the k Fa2Trace records of the batch
723
+ * @returns the stats
724
+ */
725
+ readStats(state: DataView, trace: DataView): SpringElectricalStats {
726
+ const header = FA2_STATE.read(state);
727
+ const centroid = vector(header, "centroid");
728
+ const records: SpringElectricalTraceRecord[] = [];
729
+ const count = Math.floor(trace.byteLength / TRACE_RECORD_BYTES);
730
+ for (let i = 0; i < count; i++) {
731
+ const record = FA2_TRACE.read(trace, i * TRACE_RECORD_BYTES);
732
+ records.push({
733
+ kineticEnergy: scalar(record, "modelScalar"),
734
+ meanDisplacement: scalar(record, "meanDisplacement"),
735
+ settledCount: scalar(record, "settledCount"),
736
+ });
737
+ }
738
+ const grid = this.resources?.tier === "grid";
739
+ return {
740
+ iteration: scalar(header, "iteration"),
741
+ meanDisplacement: scalar(header, "meanDisplacement"),
742
+ rmsRadius: scalar(header, "rmsRadius"),
743
+ layoutRadius: scalar(header, "radius"),
744
+ centroid: [centroid[0], centroid[1], centroid[2]],
745
+ repulsionTier: grid ? "grid" : "exact",
746
+ maxCellOccupancy: grid ? scalar(header, "maxCellOccupancy") : null,
747
+ outsideGrid: grid ? scalar(header, "outsideGrid") : null,
748
+ msPerIteration: null,
749
+ kineticEnergy: scalar(header, "kineticEnergy"),
750
+ trace: records,
751
+ };
752
+ }
753
+
754
+ /**
755
+ * The resources of the last bind(), or E_NOT_LOADED before it.
756
+ * @returns the resources
757
+ */
758
+ private requireResources(): ModelResources {
759
+ if (this.resources === null) {
760
+ throw new WebGpuGraphError(
761
+ "E_NOT_LOADED",
762
+ "the spring-electrical model has not been bound (load() first)",
763
+ { state: "created" },
764
+ );
765
+ }
766
+ return this.resources;
767
+ }
768
+
769
+ /**
770
+ * The index of a stage name in SE_STAGES, or E_INVALID_ARGUMENT.
771
+ * @param upTo - the stage name
772
+ * @returns its index
773
+ */
774
+ private stageIndex(upTo: string): number {
775
+ for (let i = 0; i < SE_STAGES.length; i++) {
776
+ if (SE_STAGES[i] === upTo) {
777
+ return i;
778
+ }
779
+ }
780
+ throw invalid("upTo", upTo, SE_STAGES.join(" | "));
781
+ }
782
+
783
+ /** Releases the grid stage's lease and the bind groups (the simulation calls it from dispose() once every in-flight batch has settled). */
784
+ dispose(): void {
785
+ this.dropBound();
786
+ }
787
+
788
+ /** Drops the bind groups of the previous bind() (the buffers changed), releases the grid stage's lease and forgets the pass of a batch recorded before the rebind. */
789
+ private dropBound(): void {
790
+ this.openPass = null;
791
+ const { bound } = this;
792
+ if (bound === null) {
793
+ return;
794
+ }
795
+ for (const kernel of [bound.k1, bound.k5, bound.toScene, bound.fill]) {
796
+ kernel.invalidate();
797
+ }
798
+ bound.k3?.invalidate();
799
+ for (const [kernel] of bound.attraction?.kernels ?? []) {
800
+ kernel.invalidate();
801
+ }
802
+ bound.grid?.dispose();
803
+ this.bound = null;
804
+ }
805
+ }
806
+
807
+ // ============================================================ the factory
808
+
809
+ /**
810
+ * The resolve callback of the simulation's setParams: the patch over the current record, re-validated.
811
+ * @param patch - the setParams patch
812
+ * @param current - the simulation's current option record
813
+ * @returns the new record
814
+ */
815
+ function resolvePatch(
816
+ patch: Partial<SpringElectricalOptions>,
817
+ current: SpringElectricalOptions,
818
+ ): SpringElectricalOptions {
819
+ return resolveSpringElectricalOptions(patch, resolveSpringElectricalOptions(current));
820
+ }
821
+
822
+ /**
823
+ * Spec 3.3 createSpringElectrical, verbatim: a GpuLayoutSimulation running ngraph's spring-electrical model on the
824
+ * exact or the grid repulsion tier (spec 7.8) with ngraph's defaults (spec 7.20) and the GPU-only tuning of
825
+ * GpuLayoutTuning.
826
+ * @param ctx - the context (E_DISPOSED / E_DEVICE_LOST through assertReady)
827
+ * @param options - the spring-electrical options and the GPU-only tuning knobs in one record
828
+ * @returns the simulation in state "created"; load() next
829
+ */
830
+ export function createSpringElectrical(
831
+ ctx: GpuContext,
832
+ options?: SpringElectricalOptions & GpuLayoutTuning,
833
+ ): GpuLayoutSimulation<SpringElectricalOptions, SpringElectricalStats> {
834
+ ctx.assertReady();
835
+ const resolved = resolveSpringElectricalOptions(options);
836
+ const tuning = resolveLayoutTuning(options);
837
+ const model = new SpringElectricalModel(tuning, resolved);
838
+ return new ForceSimulation<SpringElectricalOptions, SpringElectricalStats>(
839
+ ctx,
840
+ model,
841
+ resolved,
842
+ tuning,
843
+ resolvePatch,
844
+ );
845
+ }