@graphty/webgpu-graph-algorithms 0.5.1 → 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 (243) hide show
  1. package/README.md +98 -52
  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 +5 -4
  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,7 +1,8 @@
1
1
  /**
2
- * Fruchterman-Reingold on the exact repulsion tier (spec 7.20; contract 3.13): the ForceModel that ForceSimulation
3
- * drives -- the K1 K2 K3 K5 sequence per iteration and toScene per batch (no K4: no speed controller), the constant
4
- * override set (`LAW` 1 on K2 / K3, `APPLY` 1 on K5, `STATS_MODE` 1 on K1, PD-1), the per-iteration Fa2Params values
2
+ * Fruchterman-Reingold on the exact or the grid repulsion tier (spec 7.20, 7.8; contract 3.13): the ForceModel that
3
+ * ForceSimulation drives -- the K1 K2 K3 K5 sequence per iteration on the exact tier (K1 K2 G1..G7 K5 on the grid
4
+ * tier, P4-T13) and toScene per batch (no K4: no speed controller), the constant override set (`LAW` 1 on K2 / K3
5
+ * and on G6 / G7, `APPLY` 1 on K5, `STATS_MODE` 1 on K1, PD-1), the per-iteration Fa2Params values
5
6
  * with the cooling schedule's temperature (PD-5), the controller resets and the stats decoder -- plus the option
6
7
  * resolver and `createFruchtermanReingold`. The model shares FA2's four kernels, buffers and blocks: `oldForce` is
7
8
  * allocated, bound and never read (K5 compiles `SWING_MODE = 1`, PD-2 / PD-20); the mass lane is 1 for every node
@@ -15,6 +16,11 @@
15
16
  * the CPU's reheat() does. The temperature is `max(0, 0.1 - dt * (global - tempOrigin))`, dt = 0.1 / (iterations + 1);
16
17
  * at 0 nothing moves and the settle window closes the run (DEP-P5-C: the budget restarts at 0, the temperature does
17
18
  * not).
19
+ *
20
+ * The grid tier (P4-T13, PD-22) is FA2's: `RepulsionGrid` with `LAW` 1 when `tierFor(tuning, n)` says so (PD-18),
21
+ * the grid buffers from `buffers()`, K1's grid block under `gridMax > 0` (PD-14) over the model's own `hubCounters`,
22
+ * the three passes `fr-k1` / `fr-attraction` / `fr-grid` before `fa2-to-scene` (PD-16), and the union stage list
23
+ * (PD-17: `upTo` stops after the last stage recorded at or before its position; K4 is never recorded).
18
24
  */
19
25
 
20
26
  import { type GraphSnapshot, type NodeMask } from "@graphty/graph-format";
@@ -37,7 +43,9 @@ import { type DispatchPlan, plan1d } from "../kernel/dispatch.js";
37
43
  import { type BoundKernel, type Kernel } from "../kernel/kernel.js";
38
44
  import { type UniformBlock, type UniformValues } from "../kernel/struct-block.js";
39
45
  import { type WgslModuleSpec } from "../kernel/wgsl.js";
40
- import { FA2_PARAMS, FA2_STATE, FA2_TRACE, FILL_PARAMS, graphBindings, kernelSpec } from "../kernels.js";
46
+ import { FA2_PARAMS, FA2_STATE, FA2_TRACE, FILL_PARAMS, kernelSpec } from "../kernels.js";
47
+ import { arcCountOf } from "../primitives/core-shape.js";
48
+ import { type GridSpec, gridSpecFor } from "../primitives/grid.js";
41
49
  import {
42
50
  type FruchtermanReingoldStats,
43
51
  type FruchtermanReingoldTraceRecord,
@@ -54,9 +62,12 @@ import {
54
62
  type ModelInputs,
55
63
  type ModelResources,
56
64
  type StateWriter,
65
+ tierFor,
57
66
  } from "./force-simulation.js";
58
- import { resolveLayoutTuning } from "./forceatlas2.js";
67
+ import { resolveLayoutTuning, writeGridFrame } from "./forceatlas2.js";
59
68
  import {
69
+ type AttractionBound,
70
+ bindAttraction,
60
71
  describeValue,
61
72
  FILL_PARAMS_BUFFER,
62
73
  FORCE_BYTES_PER_NODE,
@@ -67,16 +78,27 @@ import {
67
78
  pickDim,
68
79
  pickNumber,
69
80
  pickSeed,
81
+ recordAttraction,
70
82
  scalar,
71
83
  seedWord,
72
84
  subset,
73
85
  vector,
74
86
  } from "./model-common.js";
87
+ import { type GridStage, RepulsionGrid, type RepulsionGridOverrides } from "./repulsion-grid.js";
75
88
 
76
89
  // ============================================================ constants
77
90
 
78
- /** The stage names of one iteration in dispatch order plus the per-batch toScene (spec 7.20: no K4). */
79
- const FR_STAGES = ["K1", "K2", "K3", "K5", "toScene"] as const;
91
+ /** 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). */
92
+ const FR_STAGES = ["K1", "K2", "K3", "G1", "G2", "G3", "G4", "G5", "G6", "G7", "K4", "K5", "toScene"] as const;
93
+
94
+ /** The FR_STAGES index of the first grid stage, of K4, of K5 and of toScene. */
95
+ const STAGE_G1 = 3;
96
+ const STAGE_K4 = 10;
97
+ const STAGE_K5 = 11;
98
+ const STAGE_TO_SCENE = 12;
99
+
100
+ /** The name of the model-owned hub-counter buffer K1 binds on every tier (P4 PD-14). */
101
+ const HUB_COUNTERS_BUFFER = "hubCounters";
80
102
 
81
103
  /** The one-workgroup dispatch of K1 (spec 7.4). */
82
104
  const ONE_WORKGROUP: DispatchPlan = { x: 1, y: 1, z: 1, items: 1, stride: null };
@@ -94,6 +116,14 @@ const FR_OVERRIDES: Overrides = Object.freeze({
94
116
  STATS_MODE: 1,
95
117
  });
96
118
 
119
+ /** The grid stage's override set (G6 / G7 / K4; P4-T13, PD-22): K3's constant three and the FR law. */
120
+ const FR_GRID_OVERRIDES: RepulsionGridOverrides = Object.freeze({
121
+ SWING_MODE: 1,
122
+ STRONG_GRAVITY: false,
123
+ GRAVITY_CENTER: 0,
124
+ LAW: 1,
125
+ });
126
+
97
127
  /** Every override each kernel accepts, with its default (the names its registry entry declares). */
98
128
  const K1_DEFAULTS: Overrides = { STATS_MODE: 0 };
99
129
  const K2_DEFAULTS: Overrides = {
@@ -267,11 +297,13 @@ interface BoundModel {
267
297
  readonly fillPlan: DispatchPlan;
268
298
  readonly k1: Kernel;
269
299
  readonly k1Bound: BoundKernel;
270
- readonly k2: Kernel;
271
- /** null when arcCount === 0 (K2 is not recorded; the fill below zeroes force instead, spec 7.5). */
272
- readonly k2Bound: BoundKernel | null;
273
- readonly k3: Kernel;
274
- readonly k3Bound: BoundKernel;
300
+ /** The K2 tier dispatches (P4 PD-7); null when arcCount === 0 (K2 is not recorded; the fill below zeroes force instead, spec 7.5). */
301
+ readonly attraction: AttractionBound | null;
302
+ /** K3 and its bind group, or null on the grid tier (P4 PD-18): one tier's kernels compile per load. */
303
+ readonly k3: Kernel | null;
304
+ readonly k3Bound: BoundKernel | null;
305
+ /** The grid-tier stage (G1-G7), or null on the exact tier (P4 PD-18). */
306
+ readonly grid: RepulsionGrid | null;
275
307
  readonly k5: Kernel;
276
308
  readonly k5Bound: BoundKernel;
277
309
  readonly toScene: Kernel;
@@ -281,12 +313,12 @@ interface BoundModel {
281
313
  readonly fillForceBound: BoundKernel | null;
282
314
  }
283
315
 
284
- /** The Fruchterman-Reingold model (spec 7.20: K1 K2 K3 K5 per iteration; toScene once per batch). Stages: ["K1", "K2", "K3", "K5", "toScene"]. */
316
+ /** The Fruchterman-Reingold 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. */
285
317
  export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldOptions, FruchtermanReingoldStats> {
286
318
  /** The model kind of spec 7.19. */
287
319
  readonly kind = "fruchtermanReingold";
288
320
  /** The stage names in dispatch order (the `upTo` vocabulary of recordIteration and debugRunStages). */
289
- readonly stages: readonly ["K1", "K2", "K3", "K5", "toScene"] = FR_STAGES;
321
+ readonly stages: typeof FR_STAGES = FR_STAGES;
290
322
  /** Fa2Params: the per-iteration uniform block (the simulation writes the shared fields into it). */
291
323
  readonly params: UniformBlock = FA2_PARAMS;
292
324
  /** Fa2State: the state header block. */
@@ -304,6 +336,8 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
304
336
  private pendingReheat = false;
305
337
  private resources: ModelResources | null = null;
306
338
  private bound: BoundModel | null = null;
339
+ /** The grid of the load inputs() last resolved (null on the exact tier): onLoad() writes its frame, specs() lists its kernels. */
340
+ private nextGrid: GridSpec | null = null;
307
341
  /** The K1-K5 compute pass of the batch being recorded, keyed by CommandBatch.id (one pass per batch, contract 4.4). */
308
342
  private openPass: { readonly id: number; readonly pass: GPUComputePassEncoder } | null = null;
309
343
 
@@ -318,15 +352,19 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
318
352
  }
319
353
 
320
354
  /**
321
- * force 12n and oldForce 12n (zeroed; bound and never read, PD-2) plus the 256-byte FillParams uniform buffer the
322
- * fill dispatch reads. n = 0 reports one node's worth of bytes so no zero-length buffer is ever created.
355
+ * force 12n and oldForce 12n (zeroed; bound and never read, PD-2), the 256-byte FillParams uniform buffer the
356
+ * fill dispatch reads, the 16-byte `hubCounters` K1 binds on every tier (P4 PD-14), and the grid buffers of
357
+ * `RepulsionGrid.buffers` exactly when `tierFor(tuning, n)` is the grid tier (PD-18). n = 0 reports one node's
358
+ * worth of bytes so no zero-length buffer is ever created.
323
359
  * @param n - the node count
324
- * @param _dim - the layout dimension (the force arrays are stride 3 in both)
325
- * @returns the three model-owned buffer specs
360
+ * @param dim - the layout dimension (the force arrays are stride 3 in both; the grid's geometry differs)
361
+ * @returns the model-owned buffer specs
326
362
  */
327
- buffers(n: number, _dim: 2 | 3): readonly BufferSpec[] {
363
+ buffers(n: number, dim: 2 | 3): readonly BufferSpec[] {
328
364
  const bytes = Math.max(1, n) * FORCE_BYTES_PER_NODE;
329
365
  const usage = BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST;
366
+ const grid =
367
+ tierFor(this.tuning, n) === "grid" ? RepulsionGrid.buffers(n, gridSpecFor(n, dim, this.tuning)) : [];
330
368
  return [
331
369
  { name: "force", byteLength: bytes, usage, zero: true },
332
370
  { name: "oldForce", byteLength: bytes, usage, zero: true },
@@ -336,24 +374,30 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
336
374
  usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST,
337
375
  zero: false,
338
376
  },
377
+ { name: HUB_COUNTERS_BUFFER, byteLength: 16, usage, zero: true },
378
+ ...grid,
339
379
  ];
340
380
  }
341
381
 
342
382
  /**
343
383
  * Mass 1 for every node and no weights (PD-11: FR has no mass and ignores weights), the fixed mask of the
344
- * `fixed` option applied at load (PD-6). The tier rule is the simulation's (load() throws for the grid tier
345
- * before calling this).
384
+ * `fixed` option applied at load (PD-6). Also remembers the grid of this load (`tierFor(tuning, n)`, spec 7.8)
385
+ * for onLoad() and specs(): the simulation calls inputs() first, then onLoad() before bind().
346
386
  * @param s - the snapshot being loaded
347
387
  * @param options - the simulation's current option record
348
388
  * @returns the per-load inputs
349
389
  */
350
390
  inputs(s: GraphSnapshot, options: FruchtermanReingoldOptions): ModelInputs {
351
391
  const resolved = resolveFruchtermanReingoldOptions(options, this.current);
352
- return {
392
+ // resolve first: a throwing mask resolution leaves the remembered grid of the previous load intact
393
+ const inputs: ModelInputs = {
353
394
  mass: new Float32Array(s.nodeCount).fill(1),
354
395
  weights: { data: null, source: "none", column: null },
355
396
  fixed: resolveFixed(s, resolved.fixed),
356
397
  };
398
+ const n = s.nodeCount;
399
+ this.nextGrid = tierFor(this.tuning, n) === "grid" ? gridSpecFor(n, resolved.dim, this.tuning) : null;
400
+ return inputs;
357
401
  }
358
402
 
359
403
  /**
@@ -367,12 +411,15 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
367
411
 
368
412
  /**
369
413
  * The six module specs of an override set in dispatch order -- K1, K2, K3, K5, toScene, fill -- each with only the
370
- * override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix.
414
+ * override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix,
415
+ * followed by the grid tier's specs (`RepulsionGrid.specs` under FR_GRID_OVERRIDES) when the load inputs() last
416
+ * resolved is a grid load (the pipeline key carries no geometry).
371
417
  * @param overrides - the merged override set (the model's plus USE_PERM / HAS_WEIGHTS)
372
418
  * @param _subgroups - accepted for the ForceModel interface and unused (the composer picks the twin from caps)
373
419
  * @returns the specs
374
420
  */
375
421
  specs(overrides: Overrides, _subgroups: boolean): readonly WgslModuleSpec[] {
422
+ const grid = this.nextGrid === null ? [] : RepulsionGrid.specs(FR_GRID_OVERRIDES, this.nextGrid);
376
423
  return [
377
424
  kernelSpec("fa2-stats-finalize", subset(overrides, K1_DEFAULTS)),
378
425
  kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS)),
@@ -380,44 +427,63 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
380
427
  kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS)),
381
428
  kernelSpec("fa2-to-scene"),
382
429
  kernelSpec("fill"),
430
+ ...grid,
383
431
  ];
384
432
  }
385
433
 
386
434
  /**
387
- * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 (or the fill of
388
- * force when arcCount === 0), K3, K5, toScene; writes the FillParams { count: 3n, value: 0, mode: 0 } into the
389
- * model's uniform buffer. With n === 0 nothing is bound.
435
+ * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 over the degree
436
+ * tiers through bindAttraction (or the fill of force when arcCount === 0), K3 on the exact tier or G1-G7 through
437
+ * RepulsionGrid on the grid tier (PD-18), K5, toScene; writes the FillParams { count: 3n, value: 0, mode: 0 }
438
+ * into the model's uniform buffer. With n === 0 nothing is bound. The K2 TIER 1 / 2 pipelines compile on the
439
+ * first load whose degrees need them (P4 PD-7).
390
440
  * @param resources - the graph, the shared and model buffers, the ring and the cache
391
441
  * @param overrides - the merged override set
392
442
  */
393
443
  async bind(resources: ModelResources, overrides: Overrides): Promise<void> {
394
444
  this.dropBound();
395
445
  this.resources = resources;
396
- const { n, pipelines, caps, core, perm, ring, device } = resources;
446
+ const { n, pipelines, caps, core, ring, device } = resources;
397
447
  if (n === 0) {
398
448
  return;
399
449
  }
450
+ const pos = resources.buffer("positions");
451
+ const force = resources.buffer("force");
452
+ const params = ring.binding(FA2_PARAMS);
453
+ const hasArcs = core.colIdx !== null;
400
454
  // sequential on purpose: PipelineCache.get compiles inside a validation scope, one stack per device
401
455
  const k1 = await pipelines.kernel(kernelSpec("fa2-stats-finalize", subset(overrides, K1_DEFAULTS)));
402
- const k2 = await pipelines.kernel(kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS)));
403
- const k3 = await pipelines.kernel(kernelSpec("fa2-repulsion-exact", subset(overrides, K3_DEFAULTS)));
456
+ const attraction = hasArcs
457
+ ? await bindAttraction(resources, subset(overrides, K2_DEFAULTS), { pos, force, params })
458
+ : null;
459
+ const k3 =
460
+ resources.tier === "grid"
461
+ ? null
462
+ : await pipelines.kernel(kernelSpec("fa2-repulsion-exact", subset(overrides, K3_DEFAULTS)));
404
463
  const k5 = await pipelines.kernel(kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS)));
405
464
  const toScene = await pipelines.kernel(kernelSpec("fa2-to-scene"));
406
465
  const fill = await pipelines.kernel(kernelSpec("fill"));
466
+ const grid =
467
+ resources.tier === "grid"
468
+ ? await RepulsionGrid.create(
469
+ resources,
470
+ k1.workgroupSize,
471
+ FR_GRID_OVERRIDES,
472
+ gridSpecFor(n, resources.dim, this.tuning),
473
+ )
474
+ : null;
407
475
  if (this.resources !== resources) {
408
476
  // a newer bind() superseded this one while the pipelines compiled; its own bind groups stand
477
+ grid?.dispose();
409
478
  return;
410
479
  }
411
- const pos = resources.buffer("positions");
412
480
  const scene = resources.buffer("scenePositions");
413
481
  const fixed = resources.buffer("fixed");
414
482
  const partials = resources.buffer("partials");
415
483
  const state = resources.buffer("state");
416
484
  const trace = resources.buffer("trace");
417
- const force = resources.buffer("force");
418
485
  const oldForce = resources.buffer("oldForce");
419
486
  const fillParamsBuffer = resources.buffer(FILL_PARAMS_BUFFER);
420
- const params = ring.binding(FA2_PARAMS);
421
487
  const fillParams: Binding = {
422
488
  buffer: fillParamsBuffer.buffer,
423
489
  offset: fillParamsBuffer.offset,
@@ -427,20 +493,47 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
427
493
  const fillBytes = new ArrayBuffer(FILL_PARAMS.byteLength);
428
494
  FILL_PARAMS.write(new DataView(fillBytes), { count: 3 * n, value: 0, mode: 0 });
429
495
  device.queue.writeBuffer(fillParamsBuffer.buffer, fillParamsBuffer.offset, fillBytes);
430
- const hasArcs = core.colIdx !== null;
496
+ const hubCounters = resources.buffer(HUB_COUNTERS_BUFFER);
497
+ grid?.bind({
498
+ pos,
499
+ state,
500
+ trace,
501
+ force,
502
+ oldForce,
503
+ fixedMask: fixed,
504
+ partials,
505
+ params,
506
+ cellKey: resources.buffer("cellKey"),
507
+ cellVal: resources.buffer("cellVal"),
508
+ sortedKey: resources.buffer("sortedKey"),
509
+ sortedIdx: resources.buffer("sortedIdx"),
510
+ cellHist: resources.buffer("cellHist"),
511
+ cellStart: resources.buffer("cellStart"),
512
+ hubList: resources.buffer("hubList"),
513
+ hubCounters,
514
+ hubArgs: resources.buffer("hubArgs"),
515
+ pyramid: resources.buffer("pyramid"),
516
+ });
431
517
  const wg = k1.workgroupSize;
432
518
  this.bound = {
433
519
  n,
434
520
  plan: plan1d(n, wg, caps),
435
521
  fillPlan: plan1d(3 * n, wg, caps),
436
522
  k1,
437
- k1Bound: k1.bind({ partials, S: state, T: trace, P: params }),
438
- k2,
439
- k2Bound: hasArcs
440
- ? k2.bind({ ...graphBindings(core, perm, resources.weights), pos, force, P: params })
441
- : null,
523
+ // PD-14: on the exact tier K1's cellHist slot takes a dummy (partials, both read-only) and the block is
524
+ // dead under gridMax 0; hubCounters is the model's 16-byte buffer on every tier
525
+ k1Bound: k1.bind({
526
+ partials,
527
+ S: state,
528
+ T: trace,
529
+ cellHist: grid === null ? partials : resources.buffer("cellHist"),
530
+ hubCounters,
531
+ P: params,
532
+ }),
533
+ attraction,
442
534
  k3,
443
- k3Bound: k3.bind({ pos, S: state, force, oldForce, fixedMask: fixed, partials, P: params }),
535
+ k3Bound: k3?.bind({ pos, S: state, force, oldForce, fixedMask: fixed, partials, P: params }) ?? null,
536
+ grid,
444
537
  k5,
445
538
  k5Bound: k5.bind({ force, oldForce, fixedMask: fixed, S: state, pos, partials, P: params }),
446
539
  toScene,
@@ -461,15 +554,20 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
461
554
  * @returns the uniform values
462
555
  */
463
556
  paramsFor(iteration: number, options: FruchtermanReingoldOptions): UniformValues {
464
- const { n } = this.requireResources();
557
+ const { n, core, tiers, tier, dim } = this.requireResources();
465
558
  const resolved = resolveFruchtermanReingoldOptions(options, this.current);
466
559
  const { nearMax, extentFactor } = this.tuning;
467
560
  const adaptive = resolved.cooling === "adaptive";
561
+ const grid = tier === "grid" ? gridSpecFor(n, dim, this.tuning) : null;
562
+ // P4 PD-7: TIER 2 reads [0, hiEnd), TIER 1 [hiEnd, midEnd), TIER 0 [tierStart, tierEnd) = [midEnd, n)
563
+ const so = tiers?.segmentOffsets;
564
+ const hiEnd = so?.[1] ?? 0;
565
+ const midEnd = so?.[2] ?? 0;
468
566
  return {
469
567
  n,
470
568
  dim: resolved.dim,
471
569
  flags: adaptive ? FA2_FLAG_ADAPTIVE : 0,
472
- tierStart: 0,
570
+ tierStart: midEnd,
473
571
  tierEnd: n,
474
572
  iterationIndex: iteration,
475
573
  seed: seedWord(resolved.seed),
@@ -481,33 +579,35 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
481
579
  center: [resolved.center[0], resolved.center[1], resolved.center[2], 0],
482
580
  settleThreshold: resolved.settleThreshold,
483
581
  extentFactor,
484
- gridMax: 0,
485
- levels: 0,
486
- pad: [0, 0, 0, 0],
582
+ gridMax: grid?.g ?? 0,
583
+ levels: grid?.levels ?? 0,
584
+ arcBase: 0,
585
+ arcEnd: arcCountOf(core),
586
+ accumulate: 0,
587
+ hiEnd,
588
+ midEnd,
487
589
  frK: resolved.k ?? 1 / Math.sqrt(n),
488
590
  temperature: adaptive ? FR_START_TEMPERATURE : this.temperatureAt(iteration, resolved),
489
591
  };
490
592
  }
491
593
 
492
594
  /**
493
- * Records one iteration into the batch: K1, K2 (or the fill of force when arcCount === 0), K3, K5 in the batch's
494
- * ONE compute pass (opened by the first call of a batch and reused by every later call with the same batch.id),
495
- * then toScene in a second pass that ends it, stopping after stage `upTo` when given. With n === 0 nothing is
496
- * recorded; a call before bind() completed is E_NOT_LOADED (never a silent no-op).
595
+ * Records one iteration into the batch, stopping after stage `upTo` when given (PD-17: `upTo` names a position
596
+ * in the union list and the recording stops after the last stage recorded at or before it, so "K3" on the grid
597
+ * tier stops after K2 and "G5" on the exact tier after K3). The exact tier: K1, K2 (or the fill of force when
598
+ * arcCount === 0), K3, K5 in the batch's ONE compute pass (opened by the first call of a batch and reused by
599
+ * every later call with the same batch.id), then toScene in a second pass that ends it. The grid tier (PD-16):
600
+ * the passes `fr-k1` (K1), `fr-attraction` (K2's tiers) and `fr-grid` (G1-G7, K5) per iteration, then
601
+ * `fa2-to-scene`. With n === 0 nothing is recorded; a call before bind() completed is E_NOT_LOADED (never a
602
+ * silent no-op).
497
603
  * @param batch - the batch being recorded
498
604
  * @param slot - the UniformRing slot holding this iteration's Fa2Params
499
- * @param tier - "exact" (the grid tier is E_UNSUPPORTED until P4)
605
+ * @param tier - the tier the simulation resolved at load() (the same rule bind() applied, PD-18)
500
606
  * @param upTo - a stage name to stop after; undefined records every stage including toScene
501
607
  */
502
608
  recordIteration(batch: CommandBatch, slot: number, tier: "exact" | "grid", upTo?: string): void {
503
- if (tier === "grid") {
504
- throw new WebGpuGraphError("E_UNSUPPORTED", "the grid repulsion tier lands in P4", {
505
- feature: "repulsion.grid",
506
- hint: 'pass repulsion: "exact"',
507
- });
508
- }
509
609
  const resources = this.requireResources();
510
- const stop = upTo === undefined ? FR_STAGES.length - 1 : this.stageIndex(upTo);
610
+ const stop = upTo === undefined ? STAGE_TO_SCENE : this.stageIndex(upTo);
511
611
  const { bound } = this;
512
612
  if (bound === null) {
513
613
  if (resources.n === 0) {
@@ -520,28 +620,94 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
520
620
  );
521
621
  }
522
622
  const offset = resources.ring.offsetOf(slot);
623
+ if (tier === "grid") {
624
+ this.recordGridIteration(batch, bound, offset, stop);
625
+ return;
626
+ }
627
+ const { k3, k3Bound } = bound;
628
+ if (k3 === null || k3Bound === null) {
629
+ throw new WebGpuGraphError("E_NOT_LOADED", "the Fruchterman-Reingold model was bound on the grid tier", {
630
+ state: "loaded",
631
+ });
632
+ }
523
633
  const pass = this.openPass !== null && this.openPass.id === batch.id ? this.openPass.pass : batch.pass("fr");
524
634
  this.openPass = { id: batch.id, pass };
525
635
  bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
526
636
  if (stop < 1) {
527
637
  return;
528
638
  }
529
- if (bound.k2Bound !== null) {
530
- bound.k2.dispatch(pass, bound.k2Bound, bound.plan, [offset]);
531
- } else if (bound.fillForceBound !== null) {
532
- bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
533
- }
639
+ this.recordK2(pass, bound, offset);
534
640
  if (stop < 2) {
535
641
  return;
536
642
  }
537
- bound.k3.dispatch(pass, bound.k3Bound, bound.plan, [offset]);
538
- if (stop < 3) {
643
+ k3.dispatch(pass, k3Bound, bound.plan, [offset]);
644
+ if (stop < STAGE_K5) {
539
645
  return;
540
646
  }
541
647
  bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
542
- if (stop < 4) {
648
+ if (stop < STAGE_TO_SCENE) {
543
649
  return;
544
650
  }
651
+ this.recordToScene(batch, bound, offset);
652
+ }
653
+
654
+ /**
655
+ * The grid tier's iteration (PD-16): three compute passes before toScene; no K4.
656
+ * @param batch - the batch being recorded
657
+ * @param bound - the bound model
658
+ * @param offset - the Fa2Params dynamic offset of the iteration
659
+ * @param stop - the FR_STAGES index to stop after
660
+ */
661
+ private recordGridIteration(batch: CommandBatch, bound: BoundModel, offset: number, stop: number): void {
662
+ const { grid } = bound;
663
+ if (grid === null) {
664
+ throw new WebGpuGraphError("E_NOT_LOADED", "the Fruchterman-Reingold model was bound on the exact tier", {
665
+ state: "loaded",
666
+ });
667
+ }
668
+ this.openPass = null;
669
+ bound.k1.dispatch(batch.pass("fr-k1"), bound.k1Bound, ONE_WORKGROUP, [offset]);
670
+ if (stop < 1) {
671
+ return;
672
+ }
673
+ this.recordK2(batch.pass("fr-attraction"), bound, offset);
674
+ if (stop < STAGE_G1) {
675
+ return;
676
+ }
677
+ const pass = batch.pass("fr-grid");
678
+ const gridStop = stop < STAGE_K4 ? (FR_STAGES[stop] as GridStage) : undefined;
679
+ grid.recordRepulsion(pass, bound.n, offset, gridStop);
680
+ if (stop < STAGE_K5) {
681
+ return;
682
+ }
683
+ bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
684
+ if (stop < STAGE_TO_SCENE) {
685
+ return;
686
+ }
687
+ this.recordToScene(batch, bound, offset);
688
+ }
689
+
690
+ /**
691
+ * K2's tier dispatches, or the fill of force when the graph has no arcs (spec 7.5).
692
+ * @param pass - the open compute pass
693
+ * @param bound - the bound model
694
+ * @param offset - the Fa2Params dynamic offset
695
+ */
696
+ private recordK2(pass: GPUComputePassEncoder, bound: BoundModel, offset: number): void {
697
+ if (bound.attraction !== null) {
698
+ recordAttraction(pass, bound.attraction, offset);
699
+ } else if (bound.fillForceBound !== null) {
700
+ bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * The toScene pass that ends the iteration's pass; the batch is complete after it, so nothing reuses the pass.
706
+ * @param batch - the batch
707
+ * @param bound - the bound model
708
+ * @param offset - the Fa2Params dynamic offset
709
+ */
710
+ private recordToScene(batch: CommandBatch, bound: BoundModel, offset: number): void {
545
711
  this.openPass = null;
546
712
  const scenePass = batch.pass("fa2-to-scene");
547
713
  bound.toScene.dispatch(scenePass, bound.toSceneBound, bound.plan, [offset]);
@@ -549,7 +715,7 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
549
715
 
550
716
  /**
551
717
  * temperature = 0.1 (what stats reads before the first batch lands), kineticEnergy = 0; the temperature index
552
- * restarts at 0 (PD-5).
718
+ * restarts at 0 (PD-5). On a grid load the frame of the first build (K1 folds nothing on the first iteration).
553
719
  * @param state - the state writer of the simulation
554
720
  */
555
721
  onLoad(state: StateWriter): void {
@@ -557,6 +723,9 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
557
723
  state.set("kineticEnergy", 0);
558
724
  this.tempOrigin = 0;
559
725
  this.pendingReheat = false;
726
+ if (this.nextGrid !== null) {
727
+ writeGridFrame(state, this.nextGrid, this.tuning.extentFactor);
728
+ }
560
729
  }
561
730
 
562
731
  /**
@@ -594,8 +763,9 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
594
763
  }
595
764
 
596
765
  /**
597
- * Decodes the state header and the k trace records of a completed batch into FruchtermanReingoldStats: the exact
598
- * tier with null grid fields, msPerIteration null (the simulation owns the clock), the temperature K1 wrote.
766
+ * Decodes the state header and the k trace records of a completed batch into FruchtermanReingoldStats:
767
+ * `repulsionTier` is the bound tier, the grid fields are the header's on the grid tier and null on the exact
768
+ * tier, msPerIteration null (the simulation owns the clock), the temperature K1 wrote.
599
769
  * @param state - a DataView over the 256-byte state header
600
770
  * @param trace - a DataView over the k Fa2Trace records of the batch
601
771
  * @returns the stats
@@ -613,15 +783,16 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
613
783
  settledCount: scalar(record, "settledCount"),
614
784
  });
615
785
  }
786
+ const grid = this.resources?.tier === "grid";
616
787
  return {
617
788
  iteration: scalar(header, "iteration"),
618
789
  meanDisplacement: scalar(header, "meanDisplacement"),
619
790
  rmsRadius: scalar(header, "rmsRadius"),
620
791
  layoutRadius: scalar(header, "radius"),
621
792
  centroid: [centroid[0], centroid[1], centroid[2]],
622
- repulsionTier: "exact",
623
- maxCellOccupancy: null,
624
- outsideGrid: null,
793
+ repulsionTier: grid ? "grid" : "exact",
794
+ maxCellOccupancy: grid ? scalar(header, "maxCellOccupancy") : null,
795
+ outsideGrid: grid ? scalar(header, "outsideGrid") : null,
625
796
  msPerIteration: null,
626
797
  temperature: scalar(header, "temperature"),
627
798
  trace: records,
@@ -673,16 +844,26 @@ export class FruchtermanReingoldModel implements ForceModel<FruchtermanReingoldO
673
844
  throw invalid("upTo", upTo, FR_STAGES.join(" | "));
674
845
  }
675
846
 
676
- /** Drops the bind groups of the previous bind() (the buffers changed) and forgets the pass of a batch recorded before the rebind. */
847
+ /** Releases the grid stage's lease and the bind groups (the simulation calls it from dispose() once every in-flight batch has settled). */
848
+ dispose(): void {
849
+ this.dropBound();
850
+ }
851
+
852
+ /** 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. */
677
853
  private dropBound(): void {
678
854
  this.openPass = null;
679
855
  const { bound } = this;
680
856
  if (bound === null) {
681
857
  return;
682
858
  }
683
- for (const kernel of [bound.k1, bound.k2, bound.k3, bound.k5, bound.toScene, bound.fill]) {
859
+ for (const kernel of [bound.k1, bound.k5, bound.toScene, bound.fill]) {
860
+ kernel.invalidate();
861
+ }
862
+ bound.k3?.invalidate();
863
+ for (const [kernel] of bound.attraction?.kernels ?? []) {
684
864
  kernel.invalidate();
685
865
  }
866
+ bound.grid?.dispose();
686
867
  this.bound = null;
687
868
  }
688
869
  }
@@ -713,8 +894,8 @@ function resolvePatch(
713
894
  }
714
895
 
715
896
  /**
716
- * Spec 3.3 createFruchtermanReingold, verbatim: a GpuLayoutSimulation running Fruchterman-Reingold on the exact
717
- * repulsion tier with the option defaults of spec 7.20 and the GPU-only tuning of GpuLayoutTuning.
897
+ * Spec 3.3 createFruchtermanReingold, verbatim: a GpuLayoutSimulation running Fruchterman-Reingold on the exact or
898
+ * the grid repulsion tier (spec 7.8) with the option defaults of spec 7.20 and the GPU-only tuning of GpuLayoutTuning.
718
899
  * @param ctx - the context (E_DISPOSED / E_DEVICE_LOST through assertReady)
719
900
  * @param options - the Fruchterman-Reingold options and the GPU-only tuning knobs in one record
720
901
  * @returns the simulation in state "created"; load() next