@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
@@ -4,27 +4,40 @@
4
4
  * record (spec 7.2 with the 4.6 NetworkX corrections), the per-iteration Fa2Params values, the controller resets of
5
5
  * spec 7.17 and the stats decoder -- plus the two option resolvers and `createForceAtlas2`. Positions are vec4f
6
6
  * (xyz + mass) in layout units on the device (D23, 7.18); the speed controller runs on the device (D15); no
7
- * displacement clamp (D25); K2 runs the thread-per-row tier over [0, n) with USE_PERM false (P3, spec 7.5).
7
+ * displacement clamp (D25); K2 runs the degree tiers over `degreeOrder()` (P4 PD-7).
8
8
  *
9
9
  * Model decisions this file fixes (the plan of P3-T2 lists the reasons): recordIteration with no `upTo` records every
10
- * stage including toScene; the K1-K5 dispatches of every iteration of one batch share ONE compute pass (opened by the
11
- * batch's first recordIteration and remembered by batch id) and toScene runs in a second pass that ends it (contract
12
- * 4.4); the fill kernel takes its FillParams from a model-owned 256-byte uniform buffer ("fillParams"); the first
13
- * iteration after every load() zeroes oldForce with a fill (paper mode); `repulsion: "grid"` is E_UNSUPPORTED at
14
- * load() for any n and `"auto"` above exactMaxNodes.
10
+ * stage including toScene; on the exact tier the K1-K5 dispatches of every iteration of one batch share ONE compute
11
+ * pass (opened by the batch's first recordIteration and remembered by batch id) and toScene runs in a second pass
12
+ * that ends it (contract 4.4); the fill kernel takes its FillParams from a model-owned 256-byte uniform buffer
13
+ * ("fillParams"); the first iteration after every load() zeroes oldForce with a fill (paper mode). The grid tier
14
+ * (P4-T10) is reached through `RepulsionGrid` when `tierFor(tuning, n)` says so (PD-18): `buffers()` adds the grid
15
+ * buffers, K1 derives the frame under `gridMax > 0` (PD-14), the iteration is recorded as the three passes `fa2-k1`
16
+ * / `fa2-attraction` / `fa2-grid` before `fa2-to-scene` (PD-16), and `stages` is the union list of both tiers
17
+ * (PD-17: `upTo` stops after the last stage recorded at or before its position).
15
18
  */
16
- import { FA2_DEFAULTS, LAYOUT_TUNING_DEFAULTS, MAX_ITERATIONS_PER_STEP, TRACE_RECORD_BYTES, UNIFORM_SLOT_BYTES, } from "../constants.js";
19
+ import { EXACT_MAX_NODES, FA2_DEFAULTS, GRID_BBOX_MARGIN, GRID_EXTENT_FLOOR, LAYOUT_TUNING_DEFAULTS, MAX_ITERATIONS_PER_STEP, TRACE_RECORD_BYTES, UNIFORM_SLOT_BYTES, } from "../constants.js";
17
20
  import { BufferUsage } from "../device/webgpu-constants.js";
18
21
  import { WebGpuGraphError } from "../errors.js";
19
22
  import { plan1d } from "../kernel/dispatch.js";
20
- import { FA2_PARAMS, FA2_STATE, FA2_TRACE, FILL_PARAMS, graphBindings, kernelSpec } from "../kernels.js";
21
- import { ForceSimulation, } from "./force-simulation.js";
23
+ import { FA2_PARAMS, FA2_STATE, FA2_TRACE, FILL_PARAMS, kernelSpec } from "../kernels.js";
24
+ import { arcCountOf } from "../primitives/core-shape.js";
25
+ import { gridSpecFor } from "../primitives/grid.js";
26
+ import { ForceSimulation, tierFor, } from "./force-simulation.js";
22
27
  import { resolveNodeMass, resolveWeights } from "./inputs.js";
23
- import { describeValue, FILL_PARAMS_BUFFER, FORCE_BYTES_PER_NODE, invalid, isPositiveInteger, pickBoolean, pickCenter, pickDim, pickNumber, pickSeed, scalar, seedWord, subset, vector, } from "./model-common.js";
28
+ import { bindAttraction, describeValue, FILL_PARAMS_BUFFER, FORCE_BYTES_PER_NODE, invalid, isPositiveInteger, pickBoolean, pickCenter, pickDim, pickNumber, pickSeed, recordAttraction, scalar, seedWord, subset, vector, } from "./model-common.js";
24
29
  import { RepulsionExact } from "./repulsion-exact.js";
30
+ import { RepulsionGrid } from "./repulsion-grid.js";
25
31
  // ============================================================ constants and small helpers
26
- /** The stage names of one iteration in dispatch order plus the per-batch toScene (spec 7.4; contract 3.13). */
27
- const FA2_STAGES = ["K1", "K2", "K3", "K4", "K5", "toScene"];
32
+ /** The stage names of both tiers in dispatch order plus the per-batch toScene (spec 7.4; contract 3.13; P4 PD-17): the exact tier records K1 K2 K3 K4 K5, the grid tier K1 K2 G1..G7 K4 K5. */
33
+ const FA2_STAGES = ["K1", "K2", "K3", "G1", "G2", "G3", "G4", "G5", "G6", "G7", "K4", "K5", "toScene"];
34
+ /** The FA2_STAGES index of the first grid stage, of K4, of K5 and of toScene. */
35
+ const STAGE_G1 = 3;
36
+ const STAGE_K4 = 10;
37
+ const STAGE_K5 = 11;
38
+ const STAGE_TO_SCENE = 12;
39
+ /** The name of the model-owned hub-counter buffer K1 binds on every tier (P4 PD-14). */
40
+ const HUB_COUNTERS_BUFFER = "hubCounters";
28
41
  /** The one-workgroup dispatch of K1 (spec 7.4). */
29
42
  const ONE_WORKGROUP = { x: 1, y: 1, z: 1, items: 1, stride: null };
30
43
  /** Every override K2 accepts, with its default (contract 3.10.1 plus the two standard graph overrides). */
@@ -52,6 +65,14 @@ function repulsionOverrides(merged) {
52
65
  GRAVITY_CENTER: merged.GRAVITY_CENTER === 1 ? 1 : 0,
53
66
  };
54
67
  }
68
+ /**
69
+ * The G6 / G7 / K4 override values of a merged set (typed for RepulsionGrid): K3's three and the FA2 law (P4-T13).
70
+ * @param merged - the merged override set
71
+ * @returns the grid stage's overrides
72
+ */
73
+ function gridOverrides(merged) {
74
+ return { ...repulsionOverrides(merged), LAW: 0 };
75
+ }
55
76
  // ============================================================ the resolvers
56
77
  /**
57
78
  * Applies FA2_DEFAULTS to the option record; validates ranges (spec 7.14; contract 3.13). With `previous` the record
@@ -96,8 +117,8 @@ export function resolveForceAtlas2Options(options, previous) {
96
117
  return Object.freeze(resolved);
97
118
  }
98
119
  /**
99
- * Applies LAYOUT_TUNING_DEFAULTS (spec 7.14); the grid knobs are validated and stored but only `repulsion`,
100
- * `exactMaxNodes`, `deterministic` and `compat` have an effect in P3 (contract 3.3).
120
+ * Applies LAYOUT_TUNING_DEFAULTS (spec 7.14; contract 3.3). `nearMax` is an integer >= 2 (P4 DEP-P4-M: the
121
+ * near-field estimator needs at least one sampled entry besides the node itself).
101
122
  * @param tuning - the GPU-only knobs given (any object carrying them, e.g. the createForceAtlas2 options)
102
123
  * @returns the frozen resolved tuning
103
124
  */
@@ -114,7 +135,7 @@ export function resolveLayoutTuning(tuning) {
114
135
  const resolved = {
115
136
  repulsion,
116
137
  exactMaxNodes: pickNumber("exactMaxNodes", t.exactMaxNodes, LAYOUT_TUNING_DEFAULTS.exactMaxNodes, isPositiveInteger, "an integer >= 1"),
117
- nearMax: pickNumber("nearMax", t.nearMax, LAYOUT_TUNING_DEFAULTS.nearMax, isPositiveInteger, "an integer >= 1"),
138
+ nearMax: pickNumber("nearMax", t.nearMax, LAYOUT_TUNING_DEFAULTS.nearMax, (v) => isPositiveInteger(v) && v >= 2, "an integer >= 2"),
118
139
  deterministic: pickBoolean("deterministic", t.deterministic, LAYOUT_TUNING_DEFAULTS.deterministic),
119
140
  gridMax2D: pickNumber("gridMax2D", t.gridMax2D, LAYOUT_TUNING_DEFAULTS.gridMax2D, isPositiveInteger, "an integer >= 1"),
120
141
  gridMax3D: pickNumber("gridMax3D", t.gridMax3D, LAYOUT_TUNING_DEFAULTS.gridMax3D, isPositiveInteger, "an integer >= 1"),
@@ -123,7 +144,7 @@ export function resolveLayoutTuning(tuning) {
123
144
  };
124
145
  return Object.freeze(resolved);
125
146
  }
126
- /** The ForceAtlas2 model (spec 7.4: K1 K2 K3 K4 K5 per iteration; toScene once per batch). Stages: ["K1", "K2", "K3", "K4", "K5", "toScene"]. */
147
+ /** The ForceAtlas2 model (spec 7.4: K1 K2 K3 K4 K5 per iteration on the exact tier, K1 K2 G1..G7 K4 K5 on the grid tier; toScene once per batch). Stages: the union list of PD-17. */
127
148
  export class ForceAtlas2Model {
128
149
  /**
129
150
  * Creates the model for one simulation.
@@ -148,6 +169,8 @@ export class ForceAtlas2Model {
148
169
  this.bound = null;
149
170
  /** Armed by onLoad(): the next recordIteration zeroes oldForce first (paper mode). */
150
171
  this.resetOldForce = false;
172
+ /** The grid of the load inputs() last resolved (null on the exact tier): onLoad() writes its frame, specs() lists its kernels. */
173
+ this.nextGrid = null;
151
174
  /**
152
175
  * The K1-K5 compute pass of the batch being recorded, keyed by CommandBatch.id (unique per batch): every
153
176
  * recordIteration of one batch dispatches into it (ONE pass per batch, contract 4.4); null between batches and
@@ -166,15 +189,18 @@ export class ForceAtlas2Model {
166
189
  }
167
190
  /**
168
191
  * force 12n and oldForce 12n (zeroed) in BOTH swing modes (3.10.1: a writable slot is never aliased; mode 1 leaves
169
- * oldForce unread and unwritten), plus the 256-byte FillParams uniform buffer the fill dispatches read. n = 0
170
- * reports one node's worth of bytes so no zero-length buffer is ever created (spec 3.6).
192
+ * oldForce unread and unwritten), the 256-byte FillParams uniform buffer the fill dispatches read, the 16-byte
193
+ * `hubCounters` K1 binds on every tier (P4 PD-14), and the grid buffers of `RepulsionGrid.buffers` exactly when
194
+ * `tierFor(tuning, n)` is the grid tier (PD-18). n = 0 reports one node's worth of bytes so no zero-length buffer
195
+ * is ever created (spec 3.6).
171
196
  * @param n - the node count
172
- * @param _dim - the layout dimension (the force arrays are stride 3 in both)
173
- * @returns the three model-owned buffer specs
197
+ * @param dim - the layout dimension (the force arrays are stride 3 in both; the grid's geometry differs)
198
+ * @returns the model-owned buffer specs
174
199
  */
175
- buffers(n, _dim) {
200
+ buffers(n, dim) {
176
201
  const bytes = Math.max(1, n) * FORCE_BYTES_PER_NODE;
177
202
  const usage = BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST;
203
+ const grid = tierFor(this.tuning, n) === "grid" ? RepulsionGrid.buffers(n, gridSpecFor(n, dim, this.tuning)) : [];
178
204
  return [
179
205
  { name: "force", byteLength: bytes, usage, zero: true },
180
206
  { name: "oldForce", byteLength: bytes, usage, zero: true },
@@ -184,29 +210,25 @@ export class ForceAtlas2Model {
184
210
  usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST,
185
211
  zero: false,
186
212
  },
213
+ { name: HUB_COUNTERS_BUFFER, byteLength: 16, usage, zero: true },
214
+ ...grid,
187
215
  ];
188
216
  }
189
217
  /**
190
- * { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) } (3.13 inputs.ts),
191
- * after the tier rule of spec 7.8 / lead f: `repulsion: "grid"` is E_UNSUPPORTED { feature: "repulsion.grid" }
192
- * for any n and `"auto"` when n > exactMaxNodes (the grid tier lands in P4); `"exact"` always runs.
218
+ * { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) } (3.13 inputs.ts).
219
+ * Also remembers the grid of this load (`tierFor(tuning, n)`, spec 7.8) for onLoad() and specs(): the simulation
220
+ * calls inputs() first, then onLoad() before bind().
193
221
  * @param s - the snapshot being loaded
194
222
  * @param options - the simulation's current option record
195
223
  * @returns the per-load inputs
196
224
  */
197
225
  inputs(s, options) {
198
226
  const resolved = resolveForceAtlas2Options(options, this.current);
199
- const { repulsion, exactMaxNodes } = this.tuning;
200
227
  const n = s.nodeCount;
201
- if (repulsion === "grid" || (repulsion === "auto" && n > exactMaxNodes)) {
202
- throw new WebGpuGraphError("E_UNSUPPORTED", repulsion === "grid"
203
- ? 'repulsion: "grid" is not available yet (the grid tier lands in P4)'
204
- : `the graph has ${n} nodes, above exactMaxNodes ${exactMaxNodes}, and the grid tier lands in P4`, {
205
- feature: "repulsion.grid",
206
- hint: 'pass repulsion: "exact" (or raise exactMaxNodes) to run the exact tier at this size',
207
- });
208
- }
209
- return { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) };
228
+ // resolve first: a throwing mass / weight resolution leaves the remembered grid of the previous load intact
229
+ const inputs = { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) };
230
+ this.nextGrid = tierFor(this.tuning, n) === "grid" ? gridSpecFor(n, resolved.dim, this.tuning) : null;
231
+ return inputs;
210
232
  }
211
233
  /**
212
234
  * { LINLOG, DISTRIBUTED, TIER: 0, SWING_MODE: compat === "networkx" ? 1 : 0, STRONG_GRAVITY, GRAVITY_CENTER:
@@ -230,7 +252,9 @@ export class ForceAtlas2Model {
230
252
  }
231
253
  /**
232
254
  * The seven module specs of an override set in dispatch order -- K1, K2, K3, K4, K5, toScene, fill -- each with
233
- * only the override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix.
255
+ * only the override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix,
256
+ * followed by the grid tier's specs (`RepulsionGrid.specs`) when the load inputs() last resolved is a grid load
257
+ * (the pipeline key carries no geometry, so the spec's size is immaterial).
234
258
  * @param overrides - the merged override set (the model's plus USE_PERM / HAS_WEIGHTS)
235
259
  * @param _subgroups - accepted for the ForceModel interface and unused: every reducing FA2 body carries
236
260
  * needs: ["subgroups"] in its registry entry and the composer picks the twin from caps.features (contract 4.3)
@@ -238,6 +262,9 @@ export class ForceAtlas2Model {
238
262
  */
239
263
  specs(overrides, _subgroups) {
240
264
  const [repulsionSpec, speedSpec] = RepulsionExact.specs(repulsionOverrides(overrides));
265
+ const grid = this.nextGrid === null
266
+ ? []
267
+ : RepulsionGrid.specs(gridOverrides(overrides), gridSpecFor(EXACT_MAX_NODES + 1, 2, this.tuning));
241
268
  return [
242
269
  kernelSpec("fa2-stats-finalize"),
243
270
  kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS)),
@@ -246,44 +273,56 @@ export class ForceAtlas2Model {
246
273
  kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS)),
247
274
  kernelSpec("fa2-to-scene"),
248
275
  kernelSpec("fill"),
276
+ ...grid,
249
277
  ];
250
278
  }
251
279
  /**
252
- * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 (or the fill of
253
- * force when arcCount === 0), K3 + K4 through RepulsionExact, K5, toScene, and the fill of oldForce; writes the
254
- * FillParams { count: 3n, value: 0, mode: 0 } into the model's uniform buffer. With n === 0 nothing is bound.
280
+ * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 over the degree
281
+ * tiers through bindAttraction (or the fill of force when arcCount === 0), K3 + K4 through RepulsionExact, K5,
282
+ * toScene, and the fill of oldForce; writes the FillParams { count: 3n, value: 0, mode: 0 } into the model's
283
+ * uniform buffer. With n === 0 nothing is bound. The K2 TIER 1 / 2 pipelines compile on the first load whose
284
+ * degrees need them (P4 PD-7), a one-time cost at that load.
255
285
  * @param resources - the graph, the shared and model buffers, the ring and the cache
256
286
  * @param overrides - the merged override set
257
287
  */
258
288
  async bind(resources, overrides) {
259
289
  this.dropBound();
260
290
  this.resources = resources;
261
- const { n, pipelines, caps, core, perm, ring, device } = resources;
291
+ const { n, pipelines, caps, core, ring, device } = resources;
262
292
  if (n === 0) {
263
293
  return;
264
294
  }
265
- const [k1, k2, k5, toScene, fill] = await Promise.all([
295
+ const pos = resources.buffer("positions");
296
+ const force = resources.buffer("force");
297
+ const params = ring.binding(FA2_PARAMS);
298
+ const hasArcs = core.colIdx !== null;
299
+ const [k1, k5, toScene, fill] = await Promise.all([
266
300
  pipelines.kernel(kernelSpec("fa2-stats-finalize")),
267
- pipelines.kernel(kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS))),
268
301
  pipelines.kernel(kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS))),
269
302
  pipelines.kernel(kernelSpec("fa2-to-scene")),
270
303
  pipelines.kernel(kernelSpec("fill")),
271
304
  ]);
272
- const repulsion = await RepulsionExact.create(pipelines, caps, repulsionOverrides(overrides));
305
+ const repulsion = resources.tier === "grid"
306
+ ? null
307
+ : await RepulsionExact.create(pipelines, caps, repulsionOverrides(overrides));
308
+ const attraction = hasArcs
309
+ ? await bindAttraction(resources, subset(overrides, K2_DEFAULTS), { pos, force, params })
310
+ : null;
311
+ const grid = resources.tier === "grid"
312
+ ? await RepulsionGrid.create(resources, k1.workgroupSize, gridOverrides(overrides), gridSpecFor(n, resources.dim, this.tuning))
313
+ : null;
273
314
  if (this.resources !== resources) {
274
315
  // a newer bind() superseded this one while the pipelines compiled; its own bind groups stand
316
+ grid?.dispose();
275
317
  return;
276
318
  }
277
- const pos = resources.buffer("positions");
278
319
  const scene = resources.buffer("scenePositions");
279
320
  const fixed = resources.buffer("fixed");
280
321
  const partials = resources.buffer("partials");
281
322
  const state = resources.buffer("state");
282
323
  const trace = resources.buffer("trace");
283
- const force = resources.buffer("force");
284
324
  const oldForce = resources.buffer("oldForce");
285
325
  const fillParamsBuffer = resources.buffer(FILL_PARAMS_BUFFER);
286
- const params = ring.binding(FA2_PARAMS);
287
326
  const fillParams = {
288
327
  buffer: fillParamsBuffer.buffer,
289
328
  offset: fillParamsBuffer.offset,
@@ -293,20 +332,41 @@ export class ForceAtlas2Model {
293
332
  const fillBytes = new ArrayBuffer(FILL_PARAMS.byteLength);
294
333
  FILL_PARAMS.write(new DataView(fillBytes), { count: 3 * n, value: 0, mode: 0 });
295
334
  device.queue.writeBuffer(fillParamsBuffer.buffer, fillParamsBuffer.offset, fillBytes);
296
- const hasArcs = core.colIdx !== null;
297
- repulsion.bind({ pos, state, trace, force, oldForce, fixedMask: fixed, partials, params });
335
+ const hubCounters = resources.buffer(HUB_COUNTERS_BUFFER);
336
+ const exact = { pos, state, trace, force, oldForce, fixedMask: fixed, partials, params };
337
+ repulsion?.bind(exact);
338
+ grid?.bind({
339
+ ...exact,
340
+ cellKey: resources.buffer("cellKey"),
341
+ cellVal: resources.buffer("cellVal"),
342
+ sortedKey: resources.buffer("sortedKey"),
343
+ sortedIdx: resources.buffer("sortedIdx"),
344
+ cellHist: resources.buffer("cellHist"),
345
+ cellStart: resources.buffer("cellStart"),
346
+ hubList: resources.buffer("hubList"),
347
+ hubCounters,
348
+ hubArgs: resources.buffer("hubArgs"),
349
+ pyramid: resources.buffer("pyramid"),
350
+ });
298
351
  const wg = k1.workgroupSize;
299
352
  this.bound = {
300
353
  n,
301
354
  plan: plan1d(n, wg, caps),
302
355
  fillPlan: plan1d(3 * n, wg, caps),
303
356
  k1,
304
- k1Bound: k1.bind({ partials, S: state, T: trace, P: params }),
305
- k2,
306
- k2Bound: hasArcs
307
- ? k2.bind({ ...graphBindings(core, perm, resources.weights), pos, force, P: params })
308
- : null,
357
+ // PD-14: on the exact tier K1's grid slots take dummies (cellHist := partials, both read-only; hubCounters
358
+ // is the model's 16-byte buffer on every tier) and the block is dead under gridMax 0
359
+ k1Bound: k1.bind({
360
+ partials,
361
+ S: state,
362
+ T: trace,
363
+ cellHist: grid === null ? partials : resources.buffer("cellHist"),
364
+ hubCounters,
365
+ P: params,
366
+ }),
367
+ attraction,
309
368
  repulsion,
369
+ grid,
310
370
  k5,
311
371
  k5Bound: k5.bind({ force, oldForce, fixedMask: fixed, S: state, pos, partials, P: params }),
312
372
  toScene,
@@ -324,14 +384,19 @@ export class ForceAtlas2Model {
324
384
  * @returns the uniform values
325
385
  */
326
386
  paramsFor(iteration, options) {
327
- const { n } = this.requireResources();
387
+ const { n, core, tiers, tier, dim } = this.requireResources();
328
388
  const resolved = resolveForceAtlas2Options(options, this.current);
329
389
  const { nearMax, extentFactor } = this.tuning;
390
+ const grid = tier === "grid" ? gridSpecFor(n, dim, this.tuning) : null;
391
+ // P4 PD-7: TIER 2 reads [0, hiEnd), TIER 1 [hiEnd, midEnd), TIER 0 [tierStart, tierEnd) = [midEnd, n)
392
+ const so = tiers?.segmentOffsets;
393
+ const hiEnd = so?.[1] ?? 0;
394
+ const midEnd = so?.[2] ?? 0;
330
395
  return {
331
396
  n,
332
397
  dim: resolved.dim,
333
398
  flags: 0,
334
- tierStart: 0,
399
+ tierStart: midEnd,
335
400
  tierEnd: n,
336
401
  iterationIndex: iteration,
337
402
  seed: seedWord(resolved.seed),
@@ -343,33 +408,37 @@ export class ForceAtlas2Model {
343
408
  center: [resolved.center[0], resolved.center[1], resolved.center[2], 0],
344
409
  settleThreshold: resolved.settleThreshold,
345
410
  extentFactor,
346
- gridMax: 0,
347
- levels: 0,
348
- pad: [0, 0, 0, 0],
411
+ gridMax: grid?.g ?? 0,
412
+ levels: grid?.levels ?? 0,
413
+ arcBase: 0,
414
+ arcEnd: arcCountOf(core),
415
+ accumulate: 0,
416
+ hiEnd,
417
+ midEnd,
349
418
  };
350
419
  }
351
420
  /**
352
- * Records one iteration into the batch: K1, K2 (or the fill of force when arcCount === 0), K3, K4, K5 in the
353
- * batch's ONE K1-K5 compute pass (opened by the first call of a batch and reused by every later call with the
354
- * same batch.id, PLAN DECISION 2), then toScene in a second pass that ends it, stopping after stage `upTo` when
355
- * given (spec 7.4; debugRunStages / inspect, spec 11.9 item 2). The simulation passes "K5" for iterations
356
- * 0..k-2 and undefined for the last, so toScene runs once per batch. The first call after load() zeroes
357
- * oldForce before K1 (paper mode). With n === 0 nothing is recorded (PLAN DECISION 9); a call before bind()
358
- * completed is E_NOT_LOADED (never a silent no-op).
421
+ * Records one iteration into the batch, stopping after stage `upTo` when given (spec 7.4; debugRunStages /
422
+ * inspect, spec 11.9 item 2; PD-17: `upTo` names a position in the union list and the recording stops after the
423
+ * last stage recorded at or before it, so "K3" on the grid tier stops after K2 and "G5" on the exact tier after
424
+ * K3). The exact tier: K1, K2 (or the fill of force when arcCount === 0), K3, K4, K5 in the batch's ONE compute
425
+ * pass (opened by the first call of a batch and reused by every later call with the same batch.id, PLAN
426
+ * DECISION 2), then toScene in a second pass that ends it. The grid tier (PD-16): the passes `fa2-k1` (K1),
427
+ * `fa2-attraction` (K2's tiers) and `fa2-grid` (G1-G7, K4, K5) per iteration, then `fa2-to-scene`. The profiler
428
+ * budgets PROFILER_QUERY_SLOTS / 2 = 128 passes per batch, so a grid batch above 42 iterations is timed only in
429
+ * part (the exact tier's two passes per batch always fit): the simulation then reports msPerIteration from the
430
+ * wall time, never from the sum of the timed prefix (`ForceSimulation.batchMilliseconds`). The simulation
431
+ * passes "K5" for iterations 0..k-2 and undefined for the last, so toScene runs once per batch. The first call
432
+ * after load() zeroes oldForce before K1 (paper mode). With n === 0 nothing is recorded (PLAN DECISION 9); a
433
+ * call before bind() completed is E_NOT_LOADED (never a silent no-op).
359
434
  * @param batch - the batch being recorded
360
435
  * @param slot - the UniformRing slot holding this iteration's Fa2Params
361
- * @param tier - "exact" (the grid tier is E_UNSUPPORTED until P4; the simulation never passes "grid")
436
+ * @param tier - the tier the simulation resolved at load() (the same rule bind() applied, PD-18)
362
437
  * @param upTo - a stage name to stop after; undefined records every stage including toScene
363
438
  */
364
439
  recordIteration(batch, slot, tier, upTo) {
365
- if (tier === "grid") {
366
- throw new WebGpuGraphError("E_UNSUPPORTED", "the grid repulsion tier lands in P4", {
367
- feature: "repulsion.grid",
368
- hint: 'pass repulsion: "exact"',
369
- });
370
- }
371
440
  const resources = this.requireResources();
372
- const stop = upTo === undefined ? FA2_STAGES.length - 1 : this.stageIndex(upTo);
441
+ const stop = upTo === undefined ? STAGE_TO_SCENE : this.stageIndex(upTo);
373
442
  const { bound } = this;
374
443
  if (bound === null) {
375
444
  if (resources.n === 0) {
@@ -380,47 +449,124 @@ export class ForceAtlas2Model {
380
449
  });
381
450
  }
382
451
  const offset = resources.ring.offsetOf(slot);
452
+ if (tier === "grid") {
453
+ this.recordGridIteration(batch, bound, offset, stop);
454
+ return;
455
+ }
456
+ const { repulsion } = bound;
457
+ if (repulsion === null) {
458
+ throw new WebGpuGraphError("E_NOT_LOADED", "the ForceAtlas2 model was bound on the grid tier", {
459
+ state: "loaded",
460
+ });
461
+ }
383
462
  const pass = this.openPass !== null && this.openPass.id === batch.id ? this.openPass.pass : batch.pass("fa2");
384
463
  this.openPass = { id: batch.id, pass };
385
- if (this.resetOldForce) {
386
- this.resetOldForce = false;
387
- if (bound.fillOldBound !== null) {
388
- bound.fill.dispatch(pass, bound.fillOldBound, bound.fillPlan, [0]);
389
- }
390
- }
391
- bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
464
+ this.recordK1(pass, bound, offset);
392
465
  if (stop < 1) {
393
466
  return;
394
467
  }
395
- if (bound.k2Bound !== null) {
396
- bound.k2.dispatch(pass, bound.k2Bound, bound.plan, [offset]);
468
+ this.recordK2(pass, bound, offset);
469
+ if (stop < 2) {
470
+ return;
397
471
  }
398
- else if (bound.fillForceBound !== null) {
399
- bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
472
+ repulsion.recordRepulsion(pass, bound.n, offset);
473
+ if (stop < STAGE_K4) {
474
+ return;
400
475
  }
401
- if (stop < 2) {
476
+ repulsion.recordSpeedFinalize(pass, offset);
477
+ if (stop < STAGE_K5) {
402
478
  return;
403
479
  }
404
- bound.repulsion.recordRepulsion(pass, bound.n, offset);
405
- if (stop < 3) {
480
+ bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
481
+ if (stop < STAGE_TO_SCENE) {
406
482
  return;
407
483
  }
408
- bound.repulsion.recordSpeedFinalize(pass, offset);
409
- if (stop < 4) {
484
+ this.recordToScene(batch, bound, offset);
485
+ }
486
+ /**
487
+ * The grid tier's iteration (PD-16): three compute passes before toScene.
488
+ * @param batch - the batch being recorded
489
+ * @param bound - the bound model
490
+ * @param offset - the Fa2Params dynamic offset of the iteration
491
+ * @param stop - the FA2_STAGES index to stop after
492
+ */
493
+ recordGridIteration(batch, bound, offset, stop) {
494
+ const { grid } = bound;
495
+ if (grid === null) {
496
+ throw new WebGpuGraphError("E_NOT_LOADED", "the ForceAtlas2 model was bound on the exact tier", {
497
+ state: "loaded",
498
+ });
499
+ }
500
+ this.openPass = null;
501
+ this.recordK1(batch.pass("fa2-k1"), bound, offset);
502
+ if (stop < 1) {
503
+ return;
504
+ }
505
+ this.recordK2(batch.pass("fa2-attraction"), bound, offset);
506
+ if (stop < STAGE_G1) {
507
+ return;
508
+ }
509
+ const pass = batch.pass("fa2-grid");
510
+ const gridStop = stop < STAGE_K4 ? FA2_STAGES[stop] : undefined;
511
+ grid.recordRepulsion(pass, bound.n, offset, gridStop);
512
+ if (stop < STAGE_K4) {
513
+ return;
514
+ }
515
+ grid.recordSpeedFinalize(pass, offset);
516
+ if (stop < STAGE_K5) {
410
517
  return;
411
518
  }
412
519
  bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
413
- if (stop < 5) {
520
+ if (stop < STAGE_TO_SCENE) {
414
521
  return;
415
522
  }
416
- // the second pass ends the K1-K5 pass; the batch is complete after toScene, so nothing reuses it
523
+ this.recordToScene(batch, bound, offset);
524
+ }
525
+ /**
526
+ * The oldForce reset of the first iteration after load() (paper mode), then K1 (one workgroup).
527
+ * @param pass - the open compute pass
528
+ * @param bound - the bound model
529
+ * @param offset - the Fa2Params dynamic offset
530
+ */
531
+ recordK1(pass, bound, offset) {
532
+ if (this.resetOldForce) {
533
+ this.resetOldForce = false;
534
+ if (bound.fillOldBound !== null) {
535
+ bound.fill.dispatch(pass, bound.fillOldBound, bound.fillPlan, [0]);
536
+ }
537
+ }
538
+ bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
539
+ }
540
+ /**
541
+ * K2's tier dispatches, or the fill of force when the graph has no arcs (spec 7.5).
542
+ * @param pass - the open compute pass
543
+ * @param bound - the bound model
544
+ * @param offset - the Fa2Params dynamic offset
545
+ */
546
+ recordK2(pass, bound, offset) {
547
+ if (bound.attraction !== null) {
548
+ recordAttraction(pass, bound.attraction, offset);
549
+ }
550
+ else if (bound.fillForceBound !== null) {
551
+ bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
552
+ }
553
+ }
554
+ /**
555
+ * The toScene pass that ends the iteration's pass; the batch is complete after it, so nothing reuses the pass.
556
+ * @param batch - the batch
557
+ * @param bound - the bound model
558
+ * @param offset - the Fa2Params dynamic offset
559
+ */
560
+ recordToScene(batch, bound, offset) {
417
561
  this.openPass = null;
418
562
  const scenePass = batch.pass("fa2-to-scene");
419
563
  bound.toScene.dispatch(scenePass, bound.toSceneBound, bound.plan, [offset]);
420
564
  }
421
565
  /**
422
566
  * speed = 1, speedEfficiency = 1, swing = 1, traction = 1 (mode 1 accumulates from 1; mode 0 overwrites them each
423
- * iteration, the initial value is irrelevant); arms the oldForce reset of the next recordIteration.
567
+ * iteration, the initial value is irrelevant); arms the oldForce reset of the next recordIteration. On a grid
568
+ * load the frame of the first build (K1 folds nothing on the first iteration): the same six values K1 derives,
569
+ * in f32 with the kernel's order of operations, from the host-written min / max / centroid / rmsRadius.
424
570
  * @param state - the state writer of the simulation
425
571
  */
426
572
  onLoad(state) {
@@ -429,6 +575,9 @@ export class ForceAtlas2Model {
429
575
  state.set("swing", 1);
430
576
  state.set("traction", 1);
431
577
  this.resetOldForce = true;
578
+ if (this.nextGrid !== null) {
579
+ writeGridFrame(state, this.nextGrid, this.tuning.extentFactor);
580
+ }
432
581
  }
433
582
  /**
434
583
  * Mode 0: nothing (D8). Mode 1 (networkx): swing = traction = 1 (spec 7.2 "load() and reheat() reset them to 1").
@@ -462,7 +611,9 @@ export class ForceAtlas2Model {
462
611
  }
463
612
  /**
464
613
  * Decodes the state header and the k trace records of a completed batch (k = trace.byteLength / 32) into
465
- * ForceAtlas2Stats: the exact tier with null grid fields; msPerIteration null (the simulation owns the clock).
614
+ * ForceAtlas2Stats: `repulsionTier` is the bound tier, the grid fields are the header's on the grid tier
615
+ * (`maxCellOccupancy` / `outsideGrid`: the counts of the iteration before the last K1) and null on the exact
616
+ * tier; msPerIteration null (the simulation owns the clock).
466
617
  * @param state - a DataView over the 256-byte state header
467
618
  * @param trace - a DataView over the k Fa2Trace records of the batch
468
619
  * @returns the stats
@@ -483,15 +634,16 @@ export class ForceAtlas2Model {
483
634
  settledCount: scalar(record, "settledCount"),
484
635
  });
485
636
  }
637
+ const grid = this.resources?.tier === "grid";
486
638
  return {
487
639
  iteration: scalar(header, "iteration"),
488
640
  meanDisplacement: scalar(header, "meanDisplacement"),
489
641
  rmsRadius: scalar(header, "rmsRadius"),
490
642
  layoutRadius: scalar(header, "radius"),
491
643
  centroid: [centroid[0], centroid[1], centroid[2]],
492
- repulsionTier: "exact",
493
- maxCellOccupancy: null,
494
- outsideGrid: null,
644
+ repulsionTier: grid ? "grid" : "exact",
645
+ maxCellOccupancy: grid ? scalar(header, "maxCellOccupancy") : null,
646
+ outsideGrid: grid ? scalar(header, "outsideGrid") : null,
495
647
  msPerIteration: null,
496
648
  swing: scalar(header, "swing"),
497
649
  traction: scalar(header, "traction"),
@@ -525,10 +677,17 @@ export class ForceAtlas2Model {
525
677
  }
526
678
  throw invalid("upTo", upTo, FA2_STAGES.join(" | "));
527
679
  }
680
+ /**
681
+ * Releases the grid stage's lease and the bind groups (the simulation calls it from dispose() once every
682
+ * in-flight batch has settled).
683
+ */
684
+ dispose() {
685
+ this.dropBound();
686
+ }
528
687
  /**
529
688
  * Drops the bind groups of the previous bind() (the buffers changed) so the cached kernels do not accumulate stale
530
- * groups across reloads; K3 / K4 live inside RepulsionExact and keep the P1-T6 behaviour. Also forgets the pass
531
- * of a batch recorded before the rebind.
689
+ * groups across reloads; K3 / K4 live inside RepulsionExact and keep the P1-T6 behaviour; the grid stage
690
+ * releases its lease. Also forgets the pass of a batch recorded before the rebind.
532
691
  */
533
692
  dropBound() {
534
693
  this.openPass = null;
@@ -536,12 +695,48 @@ export class ForceAtlas2Model {
536
695
  if (bound === null) {
537
696
  return;
538
697
  }
539
- for (const kernel of [bound.k1, bound.k2, bound.k5, bound.toScene, bound.fill]) {
698
+ for (const kernel of [bound.k1, bound.k5, bound.toScene, bound.fill]) {
699
+ kernel.invalidate();
700
+ }
701
+ for (const [kernel] of bound.attraction?.kernels ?? []) {
540
702
  kernel.invalidate();
541
703
  }
704
+ bound.grid?.dispose();
542
705
  this.bound = null;
543
706
  }
544
707
  }
708
+ /**
709
+ * The grid frame of the first build after load() (spec 7.7 geometry table; PD-10): K1's text in f32 with the same
710
+ * order of operations -- `box = (max - min) * GRID_BBOX_MARGIN`, `extent = max(min(max(box), extentFactor *
711
+ * rmsRadius), GRID_EXTENT_FLOOR)`, `cellSize = extent / G`, `gridMin = centroid - extent / 2` (cellSize in `.w`),
712
+ * `invCellSize = 1 / cellSize`, `eps = 0.25 cellSize` -- plus zero counts. Shared with the FR and spring-electrical
713
+ * models' onLoad (P4-T13).
714
+ * @param state - the state writer (min / max / centroid / rmsRadius already written by the simulation)
715
+ * @param spec - the grid of the load
716
+ * @param extentFactor - the tuning's extent factor
717
+ */
718
+ export function writeGridFrame(state, spec, extentFactor) {
719
+ const f = Math.fround;
720
+ const axis = (name) => {
721
+ const value = state.get(name);
722
+ return typeof value === "number" ? [value, value, value] : value;
723
+ };
724
+ const min = axis("min");
725
+ const max = axis("max");
726
+ const centroid = axis("centroid");
727
+ const rms = state.get("rmsRadius");
728
+ const box = [0, 1, 2].map((a) => f(f(f(max[a]) - f(min[a])) * f(GRID_BBOX_MARGIN)));
729
+ const bboxExtent = spec.dim === 3 ? Math.max(box[0], box[1], box[2]) : Math.max(box[0], box[1]);
730
+ const rmsTerm = f(f(extentFactor) * f(typeof rms === "number" ? rms : 0));
731
+ const extent = Math.max(Math.min(bboxExtent, rmsTerm), f(GRID_EXTENT_FLOOR));
732
+ const cellSize = f(extent / spec.g);
733
+ const half = f(0.5 * extent);
734
+ state.set("gridMin", [f(f(centroid[0]) - half), f(f(centroid[1]) - half), f(f(centroid[2]) - half), cellSize]);
735
+ state.set("invCellSize", f(1 / cellSize));
736
+ state.set("eps", f(0.25 * cellSize));
737
+ state.set("outsideGrid", 0);
738
+ state.set("maxCellOccupancy", 0);
739
+ }
545
740
  // ============================================================ the factory
546
741
  /**
547
742
  * The resolve callback of the simulation's setParams: the patch over the current record, re-validated (the current
@@ -554,8 +749,9 @@ function resolvePatch(patch, current) {
554
749
  return resolveForceAtlas2Options(patch, resolveForceAtlas2Options(current));
555
750
  }
556
751
  /**
557
- * Spec 3.3 createForceAtlas2, verbatim: a GpuLayoutSimulation running ForceAtlas2 on the exact repulsion tier with
558
- * the option defaults of spec 7.14 and the GPU-only tuning of GpuLayoutTuning (contract 3.13 "Contracts").
752
+ * Spec 3.3 createForceAtlas2, verbatim: a GpuLayoutSimulation running ForceAtlas2 on the exact or the grid repulsion
753
+ * tier (spec 7.8) with the option defaults of spec 7.14 and the GPU-only tuning of GpuLayoutTuning (contract 3.13
754
+ * "Contracts").
559
755
  * @param ctx - the context (E_DISPOSED / E_DEVICE_LOST through assertReady)
560
756
  * @param options - the ForceAtlas2 options and the GPU-only tuning knobs in one record
561
757
  * @returns the simulation in state "created"; load() next