@graphty/webgpu-graph-algorithms 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/README.md +459 -58
  2. package/dist/browser.js +1 -1
  3. package/dist/chunks/{context-BR7fx3vR.js → context-BXqgCifx.js} +190 -40
  4. package/dist/chunks/context-BXqgCifx.js.map +1 -0
  5. package/dist/node.js +1 -1
  6. package/dist/src/algorithms/components.d.ts.map +1 -1
  7. package/dist/src/algorithms/components.js +12 -13
  8. package/dist/src/algorithms/components.js.map +1 -1
  9. package/dist/src/algorithms/degree.d.ts +6 -8
  10. package/dist/src/algorithms/degree.d.ts.map +1 -1
  11. package/dist/src/algorithms/degree.js +58 -35
  12. package/dist/src/algorithms/degree.js.map +1 -1
  13. package/dist/src/algorithms/pagerank.d.ts.map +1 -1
  14. package/dist/src/algorithms/pagerank.js +16 -14
  15. package/dist/src/algorithms/pagerank.js.map +1 -1
  16. package/dist/src/algorithms/power-iteration.d.ts +2 -2
  17. package/dist/src/algorithms/power-iteration.d.ts.map +1 -1
  18. package/dist/src/algorithms/power-iteration.js +17 -14
  19. package/dist/src/algorithms/power-iteration.js.map +1 -1
  20. package/dist/src/constants.d.ts +38 -8
  21. package/dist/src/constants.d.ts.map +1 -1
  22. package/dist/src/constants.js +38 -8
  23. package/dist/src/constants.js.map +1 -1
  24. package/dist/src/errors.d.ts +3 -2
  25. package/dist/src/errors.d.ts.map +1 -1
  26. package/dist/src/errors.js +2 -1
  27. package/dist/src/errors.js.map +1 -1
  28. package/dist/src/index.d.ts +6 -4
  29. package/dist/src/index.d.ts.map +1 -1
  30. package/dist/src/index.js +8 -3
  31. package/dist/src/index.js.map +1 -1
  32. package/dist/src/kernel/dispatch.d.ts +8 -3
  33. package/dist/src/kernel/dispatch.d.ts.map +1 -1
  34. package/dist/src/kernel/dispatch.js +18 -7
  35. package/dist/src/kernel/dispatch.js.map +1 -1
  36. package/dist/src/kernel/kernel.d.ts +30 -1
  37. package/dist/src/kernel/kernel.d.ts.map +1 -1
  38. package/dist/src/kernel/kernel.js +49 -5
  39. package/dist/src/kernel/kernel.js.map +1 -1
  40. package/dist/src/kernel/prelude.d.ts.map +1 -1
  41. package/dist/src/kernel/prelude.js +6 -1
  42. package/dist/src/kernel/prelude.js.map +1 -1
  43. package/dist/src/kernel/profiler.d.ts +15 -3
  44. package/dist/src/kernel/profiler.d.ts.map +1 -1
  45. package/dist/src/kernel/profiler.js +27 -4
  46. package/dist/src/kernel/profiler.js.map +1 -1
  47. package/dist/src/kernels.d.ts +17 -7
  48. package/dist/src/kernels.d.ts.map +1 -1
  49. package/dist/src/kernels.js +323 -16
  50. package/dist/src/kernels.js.map +1 -1
  51. package/dist/src/layouts/calibrate.d.ts +51 -0
  52. package/dist/src/layouts/calibrate.d.ts.map +1 -0
  53. package/dist/src/layouts/calibrate.js +172 -0
  54. package/dist/src/layouts/calibrate.js.map +1 -0
  55. package/dist/src/layouts/force-simulation.d.ts +39 -4
  56. package/dist/src/layouts/force-simulation.d.ts.map +1 -1
  57. package/dist/src/layouts/force-simulation.js +71 -19
  58. package/dist/src/layouts/force-simulation.js.map +1 -1
  59. package/dist/src/layouts/forceatlas2.d.ts +107 -36
  60. package/dist/src/layouts/forceatlas2.d.ts.map +1 -1
  61. package/dist/src/layouts/forceatlas2.js +296 -100
  62. package/dist/src/layouts/forceatlas2.js.map +1 -1
  63. package/dist/src/layouts/fruchterman-reingold.d.ts +73 -27
  64. package/dist/src/layouts/fruchterman-reingold.d.ts.map +1 -1
  65. package/dist/src/layouts/fruchterman-reingold.js +230 -70
  66. package/dist/src/layouts/fruchterman-reingold.js.map +1 -1
  67. package/dist/src/layouts/model-common.d.ts +41 -3
  68. package/dist/src/layouts/model-common.d.ts.map +1 -1
  69. package/dist/src/layouts/model-common.js +74 -3
  70. package/dist/src/layouts/model-common.js.map +1 -1
  71. package/dist/src/layouts/repulsion-grid.d.ts +152 -0
  72. package/dist/src/layouts/repulsion-grid.d.ts.map +1 -0
  73. package/dist/src/layouts/repulsion-grid.js +318 -0
  74. package/dist/src/layouts/repulsion-grid.js.map +1 -0
  75. package/dist/src/layouts/spring-electrical.d.ts +75 -30
  76. package/dist/src/layouts/spring-electrical.d.ts.map +1 -1
  77. package/dist/src/layouts/spring-electrical.js +231 -74
  78. package/dist/src/layouts/spring-electrical.js.map +1 -1
  79. package/dist/src/memory/residency.d.ts +6 -2
  80. package/dist/src/memory/residency.d.ts.map +1 -1
  81. package/dist/src/memory/residency.js +84 -14
  82. package/dist/src/memory/residency.js.map +1 -1
  83. package/dist/src/primitives/core-shape.d.ts +38 -2
  84. package/dist/src/primitives/core-shape.d.ts.map +1 -1
  85. package/dist/src/primitives/core-shape.js +71 -3
  86. package/dist/src/primitives/core-shape.js.map +1 -1
  87. package/dist/src/primitives/grid-pyramid.d.ts +71 -0
  88. package/dist/src/primitives/grid-pyramid.d.ts.map +1 -0
  89. package/dist/src/primitives/grid-pyramid.js +143 -0
  90. package/dist/src/primitives/grid-pyramid.js.map +1 -0
  91. package/dist/src/primitives/grid.d.ts +118 -0
  92. package/dist/src/primitives/grid.d.ts.map +1 -0
  93. package/dist/src/primitives/grid.js +225 -0
  94. package/dist/src/primitives/grid.js.map +1 -0
  95. package/dist/src/primitives/histogram.d.ts +67 -0
  96. package/dist/src/primitives/histogram.d.ts.map +1 -0
  97. package/dist/src/primitives/histogram.js +190 -0
  98. package/dist/src/primitives/histogram.js.map +1 -0
  99. package/dist/src/primitives/radix-sort.d.ts +75 -0
  100. package/dist/src/primitives/radix-sort.d.ts.map +1 -0
  101. package/dist/src/primitives/radix-sort.js +168 -0
  102. package/dist/src/primitives/radix-sort.js.map +1 -0
  103. package/dist/src/primitives/scan.d.ts +44 -0
  104. package/dist/src/primitives/scan.d.ts.map +1 -0
  105. package/dist/src/primitives/scan.js +151 -0
  106. package/dist/src/primitives/scan.js.map +1 -0
  107. package/dist/src/primitives/segmented-reduce.d.ts +25 -17
  108. package/dist/src/primitives/segmented-reduce.d.ts.map +1 -1
  109. package/dist/src/primitives/segmented-reduce.js +166 -47
  110. package/dist/src/primitives/segmented-reduce.js.map +1 -1
  111. package/dist/src/primitives/spmv.d.ts +18 -14
  112. package/dist/src/primitives/spmv.d.ts.map +1 -1
  113. package/dist/src/primitives/spmv.js +94 -58
  114. package/dist/src/primitives/spmv.js.map +1 -1
  115. package/dist/src/primitives/verify.d.ts +49 -0
  116. package/dist/src/primitives/verify.d.ts.map +1 -0
  117. package/dist/src/primitives/verify.js +229 -0
  118. package/dist/src/primitives/verify.js.map +1 -0
  119. package/dist/src/types/context.d.ts +53 -0
  120. package/dist/src/types/context.d.ts.map +1 -1
  121. package/dist/src/types/layout.d.ts +20 -0
  122. package/dist/src/types/layout.d.ts.map +1 -1
  123. package/dist/src/wgsl/counting-scatter.wgsl.d.ts +8 -0
  124. package/dist/src/wgsl/counting-scatter.wgsl.d.ts.map +1 -0
  125. package/dist/src/wgsl/counting-scatter.wgsl.js +17 -0
  126. package/dist/src/wgsl/counting-scatter.wgsl.js.map +1 -0
  127. package/dist/src/wgsl/fa2-attraction.wgsl.d.ts +23 -11
  128. package/dist/src/wgsl/fa2-attraction.wgsl.d.ts.map +1 -1
  129. package/dist/src/wgsl/fa2-attraction.wgsl.js +98 -20
  130. package/dist/src/wgsl/fa2-attraction.wgsl.js.map +1 -1
  131. package/dist/src/wgsl/fa2-stats-finalize.wgsl.d.ts +6 -2
  132. package/dist/src/wgsl/fa2-stats-finalize.wgsl.d.ts.map +1 -1
  133. package/dist/src/wgsl/fa2-stats-finalize.wgsl.js +22 -1
  134. package/dist/src/wgsl/fa2-stats-finalize.wgsl.js.map +1 -1
  135. package/dist/src/wgsl/grid-cell-key.wgsl.d.ts +8 -0
  136. package/dist/src/wgsl/grid-cell-key.wgsl.d.ts.map +1 -0
  137. package/dist/src/wgsl/grid-cell-key.wgsl.js +30 -0
  138. package/dist/src/wgsl/grid-cell-key.wgsl.js.map +1 -0
  139. package/dist/src/wgsl/grid-centroid-hub.wgsl.d.ts +8 -0
  140. package/dist/src/wgsl/grid-centroid-hub.wgsl.d.ts.map +1 -0
  141. package/dist/src/wgsl/grid-centroid-hub.wgsl.js +29 -0
  142. package/dist/src/wgsl/grid-centroid-hub.wgsl.js.map +1 -0
  143. package/dist/src/wgsl/grid-centroid.wgsl.d.ts +8 -0
  144. package/dist/src/wgsl/grid-centroid.wgsl.d.ts.map +1 -0
  145. package/dist/src/wgsl/grid-centroid.wgsl.js +29 -0
  146. package/dist/src/wgsl/grid-centroid.wgsl.js.map +1 -0
  147. package/dist/src/wgsl/grid-downsample.wgsl.d.ts +7 -0
  148. package/dist/src/wgsl/grid-downsample.wgsl.d.ts.map +1 -0
  149. package/dist/src/wgsl/grid-downsample.wgsl.js +28 -0
  150. package/dist/src/wgsl/grid-downsample.wgsl.js.map +1 -0
  151. package/dist/src/wgsl/grid-far-field.wgsl.d.ts +13 -0
  152. package/dist/src/wgsl/grid-far-field.wgsl.d.ts.map +1 -0
  153. package/dist/src/wgsl/grid-far-field.wgsl.js +98 -0
  154. package/dist/src/wgsl/grid-far-field.wgsl.js.map +1 -0
  155. package/dist/src/wgsl/grid-near-field.wgsl.d.ts +19 -0
  156. package/dist/src/wgsl/grid-near-field.wgsl.d.ts.map +1 -0
  157. package/dist/src/wgsl/grid-near-field.wgsl.js +129 -0
  158. package/dist/src/wgsl/grid-near-field.wgsl.js.map +1 -0
  159. package/dist/src/wgsl/histogram.wgsl.d.ts +7 -0
  160. package/dist/src/wgsl/histogram.wgsl.d.ts.map +1 -0
  161. package/dist/src/wgsl/histogram.wgsl.js +15 -0
  162. package/dist/src/wgsl/histogram.wgsl.js.map +1 -0
  163. package/dist/src/wgsl/indirect-finalize.wgsl.d.ts +8 -0
  164. package/dist/src/wgsl/indirect-finalize.wgsl.d.ts.map +1 -0
  165. package/dist/src/wgsl/indirect-finalize.wgsl.js +26 -0
  166. package/dist/src/wgsl/indirect-finalize.wgsl.js.map +1 -0
  167. package/dist/src/wgsl/radix-hist.wgsl.d.ts +9 -0
  168. package/dist/src/wgsl/radix-hist.wgsl.d.ts.map +1 -0
  169. package/dist/src/wgsl/radix-hist.wgsl.js +31 -0
  170. package/dist/src/wgsl/radix-hist.wgsl.js.map +1 -0
  171. package/dist/src/wgsl/radix-scatter.wgsl.d.ts +9 -0
  172. package/dist/src/wgsl/radix-scatter.wgsl.d.ts.map +1 -0
  173. package/dist/src/wgsl/radix-scatter.wgsl.js +40 -0
  174. package/dist/src/wgsl/radix-scatter.wgsl.js.map +1 -0
  175. package/dist/src/wgsl/scan-add.wgsl.d.ts +6 -0
  176. package/dist/src/wgsl/scan-add.wgsl.d.ts.map +1 -0
  177. package/dist/src/wgsl/scan-add.wgsl.js +14 -0
  178. package/dist/src/wgsl/scan-add.wgsl.js.map +1 -0
  179. package/dist/src/wgsl/scan-block.wgsl.d.ts +8 -0
  180. package/dist/src/wgsl/scan-block.wgsl.d.ts.map +1 -0
  181. package/dist/src/wgsl/scan-block.wgsl.js +30 -0
  182. package/dist/src/wgsl/scan-block.wgsl.js.map +1 -0
  183. package/dist/src/wgsl/segmented-reduce.wgsl.d.ts +22 -8
  184. package/dist/src/wgsl/segmented-reduce.wgsl.d.ts.map +1 -1
  185. package/dist/src/wgsl/segmented-reduce.wgsl.js +84 -15
  186. package/dist/src/wgsl/segmented-reduce.wgsl.js.map +1 -1
  187. package/dist/src/wgsl/spmv-pull.wgsl.d.ts +22 -11
  188. package/dist/src/wgsl/spmv-pull.wgsl.d.ts.map +1 -1
  189. package/dist/src/wgsl/spmv-pull.wgsl.js +110 -36
  190. package/dist/src/wgsl/spmv-pull.wgsl.js.map +1 -1
  191. package/dist/tsconfig.build.tsbuildinfo +1 -1
  192. package/dist/webgpu-graph-algorithms.js +3815 -1003
  193. package/dist/webgpu-graph-algorithms.js.map +1 -1
  194. package/package.json +9 -8
  195. package/src/algorithms/components.ts +12 -16
  196. package/src/algorithms/degree.ts +58 -43
  197. package/src/algorithms/pagerank.ts +20 -18
  198. package/src/algorithms/power-iteration.ts +19 -18
  199. package/src/constants.ts +38 -8
  200. package/src/errors.ts +3 -1
  201. package/src/index.ts +14 -4
  202. package/src/kernel/dispatch.ts +18 -7
  203. package/src/kernel/kernel.ts +59 -5
  204. package/src/kernel/prelude.ts +9 -0
  205. package/src/kernel/profiler.ts +28 -4
  206. package/src/kernels.ts +356 -18
  207. package/src/layouts/calibrate.ts +187 -0
  208. package/src/layouts/force-simulation.ts +91 -23
  209. package/src/layouts/forceatlas2.ts +331 -106
  210. package/src/layouts/fruchterman-reingold.ts +255 -74
  211. package/src/layouts/model-common.ts +98 -3
  212. package/src/layouts/repulsion-grid.ts +451 -0
  213. package/src/layouts/spring-electrical.ts +257 -78
  214. package/src/memory/residency.ts +126 -20
  215. package/src/primitives/core-shape.ts +91 -4
  216. package/src/primitives/grid-pyramid.ts +221 -0
  217. package/src/primitives/grid.ts +349 -0
  218. package/src/primitives/histogram.ts +273 -0
  219. package/src/primitives/radix-sort.ts +246 -0
  220. package/src/primitives/scan.ts +197 -0
  221. package/src/primitives/segmented-reduce.ts +214 -56
  222. package/src/primitives/spmv.ts +125 -65
  223. package/src/primitives/verify.ts +249 -0
  224. package/src/types/context.ts +56 -0
  225. package/src/types/layout.ts +22 -0
  226. package/src/wgsl/counting-scatter.wgsl.ts +16 -0
  227. package/src/wgsl/fa2-attraction.wgsl.ts +98 -20
  228. package/src/wgsl/fa2-stats-finalize.wgsl.ts +22 -1
  229. package/src/wgsl/grid-cell-key.wgsl.ts +29 -0
  230. package/src/wgsl/grid-centroid-hub.wgsl.ts +28 -0
  231. package/src/wgsl/grid-centroid.wgsl.ts +28 -0
  232. package/src/wgsl/grid-downsample.wgsl.ts +27 -0
  233. package/src/wgsl/grid-far-field.wgsl.ts +97 -0
  234. package/src/wgsl/grid-near-field.wgsl.ts +128 -0
  235. package/src/wgsl/histogram.wgsl.ts +14 -0
  236. package/src/wgsl/indirect-finalize.wgsl.ts +25 -0
  237. package/src/wgsl/radix-hist.wgsl.ts +30 -0
  238. package/src/wgsl/radix-scatter.wgsl.ts +39 -0
  239. package/src/wgsl/scan-add.wgsl.ts +13 -0
  240. package/src/wgsl/scan-block.wgsl.ts +29 -0
  241. package/src/wgsl/segmented-reduce.wgsl.ts +84 -15
  242. package/src/wgsl/spmv-pull.wgsl.ts +110 -36
  243. package/dist/chunks/context-BR7fx3vR.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The spring-electrical preset on the exact repulsion tier (spec 7.20 "the preset"; contract 3.13): ngraph.forcelayout's
3
- * physics under ngraph's option names and defaults -- Coulomb repulsion `-g m_i m_j / d^2` (K3, LAW 2), Hooke springs
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
4
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
5
6
  * (K5, APPLY 2), the kinetic energy folded into the trace (K1, STATS_MODE 2; PD-4) -- as the ForceModel that
6
7
  * ForceSimulation drives over the four FA2 kernels K1 K2 K3 K5 per iteration and toScene per batch. No K4: there is no
@@ -10,9 +11,12 @@
10
11
  * `Fa2Params.coulomb` while FA2's centre gravity is 0 (PD-12). Settlement is the shared rule of spec 7.17
11
12
  * (DEP-P5-A); `reheat()` leaves the velocities alone (ngraph has no reheat).
12
13
  *
13
- * Model decisions this file shares with forceatlas2.ts: the K1-K5 dispatches of one batch share ONE compute pass and
14
- * toScene runs in a second pass that ends it; the fill kernel takes its FillParams from a model-owned 256-byte uniform
15
- * buffer; `repulsion: "grid"` is E_UNSUPPORTED at load() (the simulation's tier rule).
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).
16
20
  */
17
21
 
18
22
  import { type GraphSnapshot } from "@graphty/graph-format";
@@ -32,7 +36,9 @@ import { type DispatchPlan, plan1d } from "../kernel/dispatch.js";
32
36
  import { type BoundKernel, type Kernel } from "../kernel/kernel.js";
33
37
  import { type UniformBlock, type UniformValues } from "../kernel/struct-block.js";
34
38
  import { type WgslModuleSpec } from "../kernel/wgsl.js";
35
- import { FA2_PARAMS, FA2_STATE, FA2_TRACE, FILL_PARAMS, graphBindings, kernelSpec } from "../kernels.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";
36
42
  import {
37
43
  type GpuLayoutSimulation,
38
44
  type GpuLayoutTuning,
@@ -49,9 +55,12 @@ import {
49
55
  type ModelInputs,
50
56
  type ModelResources,
51
57
  type StateWriter,
58
+ tierFor,
52
59
  } from "./force-simulation.js";
53
- import { resolveLayoutTuning } from "./forceatlas2.js";
60
+ import { resolveLayoutTuning, writeGridFrame } from "./forceatlas2.js";
54
61
  import {
62
+ type AttractionBound,
63
+ bindAttraction,
55
64
  describeValue,
56
65
  FILL_PARAMS_BUFFER,
57
66
  FORCE_BYTES_PER_NODE,
@@ -62,16 +71,27 @@ import {
62
71
  pickDim,
63
72
  pickNumber,
64
73
  pickSeed,
74
+ recordAttraction,
65
75
  scalar,
66
76
  seedWord,
67
77
  subset,
68
78
  vector,
69
79
  } from "./model-common.js";
80
+ import { type GridStage, RepulsionGrid, type RepulsionGridOverrides } from "./repulsion-grid.js";
70
81
 
71
82
  // ============================================================ constants
72
83
 
73
- /** The stage names of one iteration in dispatch order plus the per-batch toScene (no K4: spec 7.20). */
74
- const SE_STAGES = ["K1", "K2", "K3", "K5", "toScene"] as const;
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";
75
95
 
76
96
  /** The one-workgroup dispatch of K1. */
77
97
  const ONE_WORKGROUP: DispatchPlan = { x: 1, y: 1, z: 1, items: 1, stride: null };
@@ -89,6 +109,14 @@ const SE_OVERRIDES: Overrides = Object.freeze({
89
109
  STATS_MODE: 2,
90
110
  });
91
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
+
92
120
  /** Every override K1 accepts, with its default. */
93
121
  const K1_DEFAULTS: Overrides = { STATS_MODE: 0 };
94
122
 
@@ -229,11 +257,13 @@ interface BoundModel {
229
257
  readonly fillPlan: DispatchPlan;
230
258
  readonly k1: Kernel;
231
259
  readonly k1Bound: BoundKernel;
232
- readonly k2: Kernel;
233
- /** null when arcCount === 0 (K2 is not recorded; the fill below zeroes force instead). */
234
- readonly k2Bound: BoundKernel | null;
235
- readonly k3: Kernel;
236
- readonly k3Bound: 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;
237
267
  readonly k5: Kernel;
238
268
  readonly k5Bound: BoundKernel;
239
269
  readonly toScene: Kernel;
@@ -258,12 +288,12 @@ function massOf(s: GraphSnapshot): Float32Array<ArrayBuffer> {
258
288
  return out;
259
289
  }
260
290
 
261
- /** The spring-electrical model (spec 7.20: K1 K2 K3 K5 per iteration; toScene once per batch). Stages: ["K1", "K2", "K3", "K5", "toScene"]. */
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. */
262
292
  export class SpringElectricalModel implements ForceModel<SpringElectricalOptions, SpringElectricalStats> {
263
293
  /** The model kind of spec 7.19. */
264
294
  readonly kind = "springElectrical";
265
295
  /** The stage names in dispatch order (the `upTo` vocabulary of recordIteration and debugRunStages). */
266
- readonly stages: readonly ["K1", "K2", "K3", "K5", "toScene"] = SE_STAGES;
296
+ readonly stages: typeof SE_STAGES = SE_STAGES;
267
297
  /** Fa2Params: the per-iteration uniform block (the simulation writes the shared fields into it). */
268
298
  readonly params: UniformBlock = FA2_PARAMS;
269
299
  /** Fa2State: the state header block. */
@@ -279,6 +309,8 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
279
309
  private resources: ModelResources | null = null;
280
310
  /** The kernels and bind groups of the last bind(), or null before it (and for n === 0). */
281
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;
282
314
  /** The K1-K5 compute pass of the batch being recorded, keyed by CommandBatch.id; null between batches. */
283
315
  private openPass: { readonly id: number; readonly pass: GPUComputePassEncoder } | null = null;
284
316
 
@@ -294,15 +326,18 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
294
326
 
295
327
  /**
296
328
  * force 12n (zeroed), velocity 12n (zeroed: every load() starts at rest, `allocate()` / `clearKept()` honour
297
- * `zero`) and the 256-byte FillParams uniform buffer. n = 0 reports one node's worth of bytes so no zero-length
298
- * buffer is ever created.
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.
299
332
  * @param n - the node count
300
- * @param _dim - the layout dimension (the arrays are stride 3 in both)
301
- * @returns the three model-owned buffer specs
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
302
335
  */
303
- buffers(n: number, _dim: 2 | 3): readonly BufferSpec[] {
336
+ buffers(n: number, dim: 2 | 3): readonly BufferSpec[] {
304
337
  const bytes = Math.max(1, n) * FORCE_BYTES_PER_NODE;
305
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)) : [];
306
341
  return [
307
342
  { name: "force", byteLength: bytes, usage, zero: true },
308
343
  { name: VELOCITY_BUFFER, byteLength: bytes, usage, zero: true },
@@ -312,18 +347,24 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
312
347
  usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST,
313
348
  zero: false,
314
349
  },
350
+ { name: HUB_COUNTERS_BUFFER, byteLength: 16, usage, zero: true },
351
+ ...grid,
315
352
  ];
316
353
  }
317
354
 
318
355
  /**
319
356
  * { mass: 1 + degree / 3, weights: none } (PD-11): the preset has no mass or weight option, and K2 compiles
320
- * HAS_WEIGHTS = false. No `fixed` (setFixed is the live API). The tier rule is the simulation's (load() throws
321
- * E_UNSUPPORTED for the grid tier before calling this).
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().
322
360
  * @param s - the snapshot being loaded
323
- * @param _options - the simulation's current option record (unused: nothing in it affects the inputs)
361
+ * @param options - the simulation's current option record (its dimension picks the grid's geometry)
324
362
  * @returns the per-load inputs
325
363
  */
326
- inputs(s: GraphSnapshot, _options: SpringElectricalOptions): ModelInputs {
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;
327
368
  return { mass: massOf(s), weights: { data: null, source: "none", column: null } };
328
369
  }
329
370
 
@@ -338,12 +379,15 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
338
379
 
339
380
  /**
340
381
  * The six module specs of an override set in dispatch order -- K1, K2, K3, K5, toScene, fill -- each with only the
341
- * override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix.
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).
342
385
  * @param overrides - the merged override set (the model's plus USE_PERM / HAS_WEIGHTS)
343
386
  * @param _subgroups - accepted for the ForceModel interface and unused (the composer picks the twin from caps)
344
387
  * @returns the specs
345
388
  */
346
389
  specs(overrides: Overrides, _subgroups: boolean): readonly WgslModuleSpec[] {
390
+ const grid = this.nextGrid === null ? [] : RepulsionGrid.specs(SE_GRID_OVERRIDES, this.nextGrid);
347
391
  return [
348
392
  kernelSpec("fa2-stats-finalize", subset(overrides, K1_DEFAULTS)),
349
393
  kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS)),
@@ -351,45 +395,64 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
351
395
  kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS)),
352
396
  kernelSpec("fa2-to-scene"),
353
397
  kernelSpec("fill"),
398
+ ...grid,
354
399
  ];
355
400
  }
356
401
 
357
402
  /**
358
- * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 (or the fill of
359
- * force when arcCount === 0), K3 and K5 with the velocity in their `oldForce` slot (PD-2), toScene; writes the
360
- * FillParams { count: 3n, value: 0, mode: 0 } into the model's uniform buffer. With n === 0 nothing is bound.
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).
361
408
  * @param resources - the graph, the shared and model buffers, the ring and the cache
362
409
  * @param overrides - the merged override set
363
410
  */
364
411
  async bind(resources: ModelResources, overrides: Overrides): Promise<void> {
365
412
  this.dropBound();
366
413
  this.resources = resources;
367
- const { n, pipelines, caps, core, perm, ring, device } = resources;
414
+ const { n, pipelines, caps, core, ring, device } = resources;
368
415
  if (n === 0) {
369
416
  return;
370
417
  }
371
- const [k1, k2, k3, k5, toScene, fill] = await Promise.all([
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([
372
423
  pipelines.kernel(kernelSpec("fa2-stats-finalize", subset(overrides, K1_DEFAULTS))),
373
- pipelines.kernel(kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS))),
374
- pipelines.kernel(kernelSpec("fa2-repulsion-exact", subset(overrides, K3_DEFAULTS))),
375
424
  pipelines.kernel(kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS))),
376
425
  pipelines.kernel(kernelSpec("fa2-to-scene")),
377
426
  pipelines.kernel(kernelSpec("fill")),
378
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;
379
444
  if (this.resources !== resources) {
380
445
  // a newer bind() superseded this one while the pipelines compiled; its own bind groups stand
446
+ grid?.dispose();
381
447
  return;
382
448
  }
383
- const pos = resources.buffer("positions");
384
449
  const scene = resources.buffer("scenePositions");
385
450
  const fixed = resources.buffer("fixed");
386
451
  const partials = resources.buffer("partials");
387
452
  const state = resources.buffer("state");
388
453
  const trace = resources.buffer("trace");
389
- const force = resources.buffer("force");
390
454
  const velocity = resources.buffer(VELOCITY_BUFFER);
391
455
  const fillParamsBuffer = resources.buffer(FILL_PARAMS_BUFFER);
392
- const params = ring.binding(FA2_PARAMS);
393
456
  const fillParams: Binding = {
394
457
  buffer: fillParamsBuffer.buffer,
395
458
  offset: fillParamsBuffer.offset,
@@ -399,20 +462,48 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
399
462
  const fillBytes = new ArrayBuffer(FILL_PARAMS.byteLength);
400
463
  FILL_PARAMS.write(new DataView(fillBytes), { count: 3 * n, value: 0, mode: 0 });
401
464
  device.queue.writeBuffer(fillParamsBuffer.buffer, fillParamsBuffer.offset, fillBytes);
402
- const hasArcs = core.colIdx !== null;
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
+ });
403
486
  const wg = k1.workgroupSize;
404
487
  this.bound = {
405
488
  n,
406
489
  plan: plan1d(n, wg, caps),
407
490
  fillPlan: plan1d(3 * n, wg, caps),
408
491
  k1,
409
- k1Bound: k1.bind({ partials, S: state, T: trace, P: params }),
410
- k2,
411
- k2Bound: hasArcs
412
- ? k2.bind({ ...graphBindings(core, perm, resources.weights), pos, force, P: params })
413
- : null,
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,
414
503
  k3,
415
- k3Bound: k3.bind({ pos, S: state, force, oldForce: velocity, fixedMask: fixed, partials, P: params }),
504
+ k3Bound:
505
+ k3?.bind({ pos, S: state, force, oldForce: velocity, fixedMask: fixed, partials, P: params }) ?? null,
506
+ grid,
416
507
  k5,
417
508
  k5Bound: k5.bind({ force, oldForce: velocity, fixedMask: fixed, S: state, pos, partials, P: params }),
418
509
  toScene,
@@ -430,14 +521,19 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
430
521
  * @returns the uniform values
431
522
  */
432
523
  paramsFor(iteration: number, options: SpringElectricalOptions): UniformValues {
433
- const { n } = this.requireResources();
524
+ const { n, core, tiers, tier, dim } = this.requireResources();
434
525
  const resolved = resolveSpringElectricalOptions(options, this.current);
435
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;
436
532
  return {
437
533
  n,
438
534
  dim: resolved.dim,
439
535
  flags: 0,
440
- tierStart: 0,
536
+ tierStart: midEnd,
441
537
  tierEnd: n,
442
538
  iterationIndex: iteration,
443
539
  seed: seedWord(resolved.seed),
@@ -449,9 +545,13 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
449
545
  center: [resolved.center[0], resolved.center[1], resolved.center[2], 0],
450
546
  settleThreshold: resolved.settleThreshold,
451
547
  extentFactor,
452
- gridMax: 0,
453
- levels: 0,
454
- pad: [0, 0, 0, 0],
548
+ gridMax: grid?.g ?? 0,
549
+ levels: grid?.levels ?? 0,
550
+ arcBase: 0,
551
+ arcEnd: arcCountOf(core),
552
+ accumulate: 0,
553
+ hiEnd,
554
+ midEnd,
455
555
  frK: 0,
456
556
  temperature: 0,
457
557
  springLength: resolved.springLength,
@@ -463,23 +563,20 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
463
563
  }
464
564
 
465
565
  /**
466
- * Records one iteration into the batch: K1, K2 (or the fill of force when arcCount === 0), K3, K5 in the batch's
467
- * ONE K1-K5 compute pass, then toScene in a second pass that ends it, stopping after stage `upTo` when given.
468
- * With n === 0 nothing is recorded; a call before bind() completed is E_NOT_LOADED.
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.
469
572
  * @param batch - the batch being recorded
470
573
  * @param slot - the UniformRing slot holding this iteration's Fa2Params
471
- * @param tier - "exact" (the grid tier is E_UNSUPPORTED; the simulation never passes "grid")
574
+ * @param tier - the tier the simulation resolved at load() (the same rule bind() applied, PD-18)
472
575
  * @param upTo - a stage name to stop after; undefined records every stage including toScene
473
576
  */
474
577
  recordIteration(batch: CommandBatch, slot: number, tier: "exact" | "grid", upTo?: string): void {
475
- if (tier === "grid") {
476
- throw new WebGpuGraphError("E_UNSUPPORTED", "the grid repulsion tier lands in P4", {
477
- feature: "repulsion.grid",
478
- hint: 'pass repulsion: "exact"',
479
- });
480
- }
481
578
  const resources = this.requireResources();
482
- const stop = upTo === undefined ? SE_STAGES.length - 1 : this.stageIndex(upTo);
579
+ const stop = upTo === undefined ? STAGE_TO_SCENE : this.stageIndex(upTo);
483
580
  const { bound } = this;
484
581
  if (bound === null) {
485
582
  if (resources.n === 0) {
@@ -492,41 +589,110 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
492
589
  );
493
590
  }
494
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
+ }
495
602
  const pass = this.openPass !== null && this.openPass.id === batch.id ? this.openPass.pass : batch.pass("se");
496
603
  this.openPass = { id: batch.id, pass };
497
604
  bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
498
605
  if (stop < 1) {
499
606
  return;
500
607
  }
501
- if (bound.k2Bound !== null) {
502
- bound.k2.dispatch(pass, bound.k2Bound, bound.plan, [offset]);
503
- } else if (bound.fillForceBound !== null) {
504
- bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
505
- }
608
+ this.recordK2(pass, bound, offset);
506
609
  if (stop < 2) {
507
610
  return;
508
611
  }
509
- bound.k3.dispatch(pass, bound.k3Bound, bound.plan, [offset]);
510
- if (stop < 3) {
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) {
511
650
  return;
512
651
  }
513
652
  bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
514
- if (stop < 4) {
653
+ if (stop < STAGE_TO_SCENE) {
515
654
  return;
516
655
  }
517
- // the second pass ends the K1-K5 pass; the batch is complete after toScene, so nothing reuses it
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 {
518
680
  this.openPass = null;
519
681
  const scenePass = batch.pass("se-to-scene");
520
682
  bound.toScene.dispatch(scenePass, bound.toSceneBound, bound.plan, [offset]);
521
683
  }
522
684
 
523
685
  /**
524
- * kineticEnergy = 0 and temperature = 0 in the header (the velocities start at 0 through the buffer's `zero`).
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).
525
688
  * @param state - the state writer of the simulation
526
689
  */
527
690
  onLoad(state: StateWriter): void {
528
691
  state.set("kineticEnergy", 0);
529
692
  state.set("temperature", 0);
693
+ if (this.nextGrid !== null) {
694
+ writeGridFrame(state, this.nextGrid, this.tuning.extentFactor);
695
+ }
530
696
  }
531
697
 
532
698
  /**
@@ -548,9 +714,10 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
548
714
  }
549
715
 
550
716
  /**
551
- * Decodes the state header and the k trace records of a completed batch into SpringElectricalStats: the exact
552
- * tier with null grid fields; msPerIteration null (the simulation owns the clock); `kineticEnergy` is the last
553
- * folded value (one iteration behind the last integrate, PD-4).
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).
554
721
  * @param state - a DataView over the 256-byte state header
555
722
  * @param trace - a DataView over the k Fa2Trace records of the batch
556
723
  * @returns the stats
@@ -568,15 +735,16 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
568
735
  settledCount: scalar(record, "settledCount"),
569
736
  });
570
737
  }
738
+ const grid = this.resources?.tier === "grid";
571
739
  return {
572
740
  iteration: scalar(header, "iteration"),
573
741
  meanDisplacement: scalar(header, "meanDisplacement"),
574
742
  rmsRadius: scalar(header, "rmsRadius"),
575
743
  layoutRadius: scalar(header, "radius"),
576
744
  centroid: [centroid[0], centroid[1], centroid[2]],
577
- repulsionTier: "exact",
578
- maxCellOccupancy: null,
579
- outsideGrid: null,
745
+ repulsionTier: grid ? "grid" : "exact",
746
+ maxCellOccupancy: grid ? scalar(header, "maxCellOccupancy") : null,
747
+ outsideGrid: grid ? scalar(header, "outsideGrid") : null,
580
748
  msPerIteration: null,
581
749
  kineticEnergy: scalar(header, "kineticEnergy"),
582
750
  trace: records,
@@ -612,16 +780,26 @@ export class SpringElectricalModel implements ForceModel<SpringElectricalOptions
612
780
  throw invalid("upTo", upTo, SE_STAGES.join(" | "));
613
781
  }
614
782
 
615
- /** Drops the bind groups of the previous bind() (the buffers changed) and forgets the pass of a batch recorded before the rebind. */
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. */
616
789
  private dropBound(): void {
617
790
  this.openPass = null;
618
791
  const { bound } = this;
619
792
  if (bound === null) {
620
793
  return;
621
794
  }
622
- for (const kernel of [bound.k1, bound.k2, bound.k3, bound.k5, bound.toScene, bound.fill]) {
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 ?? []) {
623
800
  kernel.invalidate();
624
801
  }
802
+ bound.grid?.dispose();
625
803
  this.bound = null;
626
804
  }
627
805
  }
@@ -643,7 +821,8 @@ function resolvePatch(
643
821
 
644
822
  /**
645
823
  * Spec 3.3 createSpringElectrical, verbatim: a GpuLayoutSimulation running ngraph's spring-electrical model on the
646
- * exact repulsion tier with ngraph's defaults (spec 7.20) and the GPU-only tuning of GpuLayoutTuning.
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.
647
826
  * @param ctx - the context (E_DISPOSED / E_DEVICE_LOST through assertReady)
648
827
  * @param options - the spring-electrical options and the GPU-only tuning knobs in one record
649
828
  * @returns the simulation in state "created"; load() next