@graphty/webgpu-graph-algorithms 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/README.md +104 -52
  2. package/dist/browser.js +1 -1
  3. package/dist/chunks/{context-CRbw2Wyo.js → context-BXqgCifx.js} +225 -33
  4. package/dist/chunks/context-BXqgCifx.js.map +1 -0
  5. package/dist/node.js +1 -1
  6. package/dist/src/accelerator.d.ts +12 -10
  7. package/dist/src/accelerator.d.ts.map +1 -1
  8. package/dist/src/accelerator.js +32 -10
  9. package/dist/src/accelerator.js.map +1 -1
  10. package/dist/src/algorithms/components.d.ts.map +1 -1
  11. package/dist/src/algorithms/components.js +12 -13
  12. package/dist/src/algorithms/components.js.map +1 -1
  13. package/dist/src/algorithms/degree.d.ts +6 -8
  14. package/dist/src/algorithms/degree.d.ts.map +1 -1
  15. package/dist/src/algorithms/degree.js +58 -35
  16. package/dist/src/algorithms/degree.js.map +1 -1
  17. package/dist/src/algorithms/pagerank.d.ts.map +1 -1
  18. package/dist/src/algorithms/pagerank.js +16 -14
  19. package/dist/src/algorithms/pagerank.js.map +1 -1
  20. package/dist/src/algorithms/power-iteration.d.ts +2 -2
  21. package/dist/src/algorithms/power-iteration.d.ts.map +1 -1
  22. package/dist/src/algorithms/power-iteration.js +17 -14
  23. package/dist/src/algorithms/power-iteration.js.map +1 -1
  24. package/dist/src/constants.d.ts +85 -8
  25. package/dist/src/constants.d.ts.map +1 -1
  26. package/dist/src/constants.js +85 -8
  27. package/dist/src/constants.js.map +1 -1
  28. package/dist/src/errors.d.ts +3 -2
  29. package/dist/src/errors.d.ts.map +1 -1
  30. package/dist/src/errors.js +2 -1
  31. package/dist/src/errors.js.map +1 -1
  32. package/dist/src/index.d.ts +10 -5
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +14 -5
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/kernel/dispatch.d.ts +8 -3
  37. package/dist/src/kernel/dispatch.d.ts.map +1 -1
  38. package/dist/src/kernel/dispatch.js +18 -7
  39. package/dist/src/kernel/dispatch.js.map +1 -1
  40. package/dist/src/kernel/kernel.d.ts +30 -1
  41. package/dist/src/kernel/kernel.d.ts.map +1 -1
  42. package/dist/src/kernel/kernel.js +49 -5
  43. package/dist/src/kernel/kernel.js.map +1 -1
  44. package/dist/src/kernel/prelude.d.ts.map +1 -1
  45. package/dist/src/kernel/prelude.js +9 -1
  46. package/dist/src/kernel/prelude.js.map +1 -1
  47. package/dist/src/kernel/profiler.d.ts +15 -3
  48. package/dist/src/kernel/profiler.d.ts.map +1 -1
  49. package/dist/src/kernel/profiler.js +27 -4
  50. package/dist/src/kernel/profiler.js.map +1 -1
  51. package/dist/src/kernels.d.ts +18 -8
  52. package/dist/src/kernels.d.ts.map +1 -1
  53. package/dist/src/kernels.js +345 -22
  54. package/dist/src/kernels.js.map +1 -1
  55. package/dist/src/layouts/calibrate.d.ts +51 -0
  56. package/dist/src/layouts/calibrate.d.ts.map +1 -0
  57. package/dist/src/layouts/calibrate.js +172 -0
  58. package/dist/src/layouts/calibrate.js.map +1 -0
  59. package/dist/src/layouts/force-simulation.d.ts +42 -5
  60. package/dist/src/layouts/force-simulation.d.ts.map +1 -1
  61. package/dist/src/layouts/force-simulation.js +84 -22
  62. package/dist/src/layouts/force-simulation.js.map +1 -1
  63. package/dist/src/layouts/forceatlas2.d.ts +107 -38
  64. package/dist/src/layouts/forceatlas2.d.ts.map +1 -1
  65. package/dist/src/layouts/forceatlas2.js +297 -290
  66. package/dist/src/layouts/forceatlas2.js.map +1 -1
  67. package/dist/src/layouts/fruchterman-reingold.d.ts +241 -0
  68. package/dist/src/layouts/fruchterman-reingold.d.ts.map +1 -0
  69. package/dist/src/layouts/fruchterman-reingold.js +739 -0
  70. package/dist/src/layouts/fruchterman-reingold.js.map +1 -0
  71. package/dist/src/layouts/model-common.d.ts +140 -0
  72. package/dist/src/layouts/model-common.d.ts.map +1 -0
  73. package/dist/src/layouts/model-common.js +269 -0
  74. package/dist/src/layouts/model-common.js.map +1 -0
  75. package/dist/src/layouts/repulsion-grid.d.ts +152 -0
  76. package/dist/src/layouts/repulsion-grid.d.ts.map +1 -0
  77. package/dist/src/layouts/repulsion-grid.js +318 -0
  78. package/dist/src/layouts/repulsion-grid.js.map +1 -0
  79. package/dist/src/layouts/spring-electrical.d.ts +224 -0
  80. package/dist/src/layouts/spring-electrical.d.ts.map +1 -0
  81. package/dist/src/layouts/spring-electrical.js +665 -0
  82. package/dist/src/layouts/spring-electrical.js.map +1 -0
  83. package/dist/src/memory/residency.d.ts +6 -2
  84. package/dist/src/memory/residency.d.ts.map +1 -1
  85. package/dist/src/memory/residency.js +84 -14
  86. package/dist/src/memory/residency.js.map +1 -1
  87. package/dist/src/primitives/core-shape.d.ts +38 -2
  88. package/dist/src/primitives/core-shape.d.ts.map +1 -1
  89. package/dist/src/primitives/core-shape.js +71 -3
  90. package/dist/src/primitives/core-shape.js.map +1 -1
  91. package/dist/src/primitives/grid-pyramid.d.ts +71 -0
  92. package/dist/src/primitives/grid-pyramid.d.ts.map +1 -0
  93. package/dist/src/primitives/grid-pyramid.js +143 -0
  94. package/dist/src/primitives/grid-pyramid.js.map +1 -0
  95. package/dist/src/primitives/grid.d.ts +118 -0
  96. package/dist/src/primitives/grid.d.ts.map +1 -0
  97. package/dist/src/primitives/grid.js +225 -0
  98. package/dist/src/primitives/grid.js.map +1 -0
  99. package/dist/src/primitives/histogram.d.ts +67 -0
  100. package/dist/src/primitives/histogram.d.ts.map +1 -0
  101. package/dist/src/primitives/histogram.js +190 -0
  102. package/dist/src/primitives/histogram.js.map +1 -0
  103. package/dist/src/primitives/radix-sort.d.ts +75 -0
  104. package/dist/src/primitives/radix-sort.d.ts.map +1 -0
  105. package/dist/src/primitives/radix-sort.js +168 -0
  106. package/dist/src/primitives/radix-sort.js.map +1 -0
  107. package/dist/src/primitives/scan.d.ts +44 -0
  108. package/dist/src/primitives/scan.d.ts.map +1 -0
  109. package/dist/src/primitives/scan.js +151 -0
  110. package/dist/src/primitives/scan.js.map +1 -0
  111. package/dist/src/primitives/segmented-reduce.d.ts +25 -17
  112. package/dist/src/primitives/segmented-reduce.d.ts.map +1 -1
  113. package/dist/src/primitives/segmented-reduce.js +166 -47
  114. package/dist/src/primitives/segmented-reduce.js.map +1 -1
  115. package/dist/src/primitives/spmv.d.ts +18 -14
  116. package/dist/src/primitives/spmv.d.ts.map +1 -1
  117. package/dist/src/primitives/spmv.js +94 -58
  118. package/dist/src/primitives/spmv.js.map +1 -1
  119. package/dist/src/primitives/verify.d.ts +49 -0
  120. package/dist/src/primitives/verify.d.ts.map +1 -0
  121. package/dist/src/primitives/verify.js +229 -0
  122. package/dist/src/primitives/verify.js.map +1 -0
  123. package/dist/src/types/accelerator.d.ts +7 -3
  124. package/dist/src/types/accelerator.d.ts.map +1 -1
  125. package/dist/src/types/context.d.ts +53 -0
  126. package/dist/src/types/context.d.ts.map +1 -1
  127. package/dist/src/types/layout.d.ts +52 -0
  128. package/dist/src/types/layout.d.ts.map +1 -1
  129. package/dist/src/types/options.d.ts +43 -1
  130. package/dist/src/types/options.d.ts.map +1 -1
  131. package/dist/src/wgsl/counting-scatter.wgsl.d.ts +8 -0
  132. package/dist/src/wgsl/counting-scatter.wgsl.d.ts.map +1 -0
  133. package/dist/src/wgsl/counting-scatter.wgsl.js +17 -0
  134. package/dist/src/wgsl/counting-scatter.wgsl.js.map +1 -0
  135. package/dist/src/wgsl/fa2-attraction.wgsl.d.ts +23 -8
  136. package/dist/src/wgsl/fa2-attraction.wgsl.d.ts.map +1 -1
  137. package/dist/src/wgsl/fa2-attraction.wgsl.js +100 -17
  138. package/dist/src/wgsl/fa2-attraction.wgsl.js.map +1 -1
  139. package/dist/src/wgsl/fa2-integrate.wgsl.d.ts +7 -2
  140. package/dist/src/wgsl/fa2-integrate.wgsl.d.ts.map +1 -1
  141. package/dist/src/wgsl/fa2-integrate.wgsl.js +28 -2
  142. package/dist/src/wgsl/fa2-integrate.wgsl.js.map +1 -1
  143. package/dist/src/wgsl/fa2-repulsion-exact.wgsl.d.ts +4 -2
  144. package/dist/src/wgsl/fa2-repulsion-exact.wgsl.d.ts.map +1 -1
  145. package/dist/src/wgsl/fa2-repulsion-exact.wgsl.js +14 -5
  146. package/dist/src/wgsl/fa2-repulsion-exact.wgsl.js.map +1 -1
  147. package/dist/src/wgsl/fa2-stats-finalize.wgsl.d.ts +12 -1
  148. package/dist/src/wgsl/fa2-stats-finalize.wgsl.d.ts.map +1 -1
  149. package/dist/src/wgsl/fa2-stats-finalize.wgsl.js +54 -0
  150. package/dist/src/wgsl/fa2-stats-finalize.wgsl.js.map +1 -1
  151. package/dist/src/wgsl/grid-cell-key.wgsl.d.ts +8 -0
  152. package/dist/src/wgsl/grid-cell-key.wgsl.d.ts.map +1 -0
  153. package/dist/src/wgsl/grid-cell-key.wgsl.js +30 -0
  154. package/dist/src/wgsl/grid-cell-key.wgsl.js.map +1 -0
  155. package/dist/src/wgsl/grid-centroid-hub.wgsl.d.ts +8 -0
  156. package/dist/src/wgsl/grid-centroid-hub.wgsl.d.ts.map +1 -0
  157. package/dist/src/wgsl/grid-centroid-hub.wgsl.js +29 -0
  158. package/dist/src/wgsl/grid-centroid-hub.wgsl.js.map +1 -0
  159. package/dist/src/wgsl/grid-centroid.wgsl.d.ts +8 -0
  160. package/dist/src/wgsl/grid-centroid.wgsl.d.ts.map +1 -0
  161. package/dist/src/wgsl/grid-centroid.wgsl.js +29 -0
  162. package/dist/src/wgsl/grid-centroid.wgsl.js.map +1 -0
  163. package/dist/src/wgsl/grid-downsample.wgsl.d.ts +7 -0
  164. package/dist/src/wgsl/grid-downsample.wgsl.d.ts.map +1 -0
  165. package/dist/src/wgsl/grid-downsample.wgsl.js +28 -0
  166. package/dist/src/wgsl/grid-downsample.wgsl.js.map +1 -0
  167. package/dist/src/wgsl/grid-far-field.wgsl.d.ts +13 -0
  168. package/dist/src/wgsl/grid-far-field.wgsl.d.ts.map +1 -0
  169. package/dist/src/wgsl/grid-far-field.wgsl.js +98 -0
  170. package/dist/src/wgsl/grid-far-field.wgsl.js.map +1 -0
  171. package/dist/src/wgsl/grid-near-field.wgsl.d.ts +19 -0
  172. package/dist/src/wgsl/grid-near-field.wgsl.d.ts.map +1 -0
  173. package/dist/src/wgsl/grid-near-field.wgsl.js +129 -0
  174. package/dist/src/wgsl/grid-near-field.wgsl.js.map +1 -0
  175. package/dist/src/wgsl/histogram.wgsl.d.ts +7 -0
  176. package/dist/src/wgsl/histogram.wgsl.d.ts.map +1 -0
  177. package/dist/src/wgsl/histogram.wgsl.js +15 -0
  178. package/dist/src/wgsl/histogram.wgsl.js.map +1 -0
  179. package/dist/src/wgsl/indirect-finalize.wgsl.d.ts +8 -0
  180. package/dist/src/wgsl/indirect-finalize.wgsl.d.ts.map +1 -0
  181. package/dist/src/wgsl/indirect-finalize.wgsl.js +26 -0
  182. package/dist/src/wgsl/indirect-finalize.wgsl.js.map +1 -0
  183. package/dist/src/wgsl/radix-hist.wgsl.d.ts +9 -0
  184. package/dist/src/wgsl/radix-hist.wgsl.d.ts.map +1 -0
  185. package/dist/src/wgsl/radix-hist.wgsl.js +31 -0
  186. package/dist/src/wgsl/radix-hist.wgsl.js.map +1 -0
  187. package/dist/src/wgsl/radix-scatter.wgsl.d.ts +9 -0
  188. package/dist/src/wgsl/radix-scatter.wgsl.d.ts.map +1 -0
  189. package/dist/src/wgsl/radix-scatter.wgsl.js +40 -0
  190. package/dist/src/wgsl/radix-scatter.wgsl.js.map +1 -0
  191. package/dist/src/wgsl/scan-add.wgsl.d.ts +6 -0
  192. package/dist/src/wgsl/scan-add.wgsl.d.ts.map +1 -0
  193. package/dist/src/wgsl/scan-add.wgsl.js +14 -0
  194. package/dist/src/wgsl/scan-add.wgsl.js.map +1 -0
  195. package/dist/src/wgsl/scan-block.wgsl.d.ts +8 -0
  196. package/dist/src/wgsl/scan-block.wgsl.d.ts.map +1 -0
  197. package/dist/src/wgsl/scan-block.wgsl.js +30 -0
  198. package/dist/src/wgsl/scan-block.wgsl.js.map +1 -0
  199. package/dist/src/wgsl/segmented-reduce.wgsl.d.ts +22 -8
  200. package/dist/src/wgsl/segmented-reduce.wgsl.d.ts.map +1 -1
  201. package/dist/src/wgsl/segmented-reduce.wgsl.js +84 -15
  202. package/dist/src/wgsl/segmented-reduce.wgsl.js.map +1 -1
  203. package/dist/src/wgsl/spmv-pull.wgsl.d.ts +22 -11
  204. package/dist/src/wgsl/spmv-pull.wgsl.d.ts.map +1 -1
  205. package/dist/src/wgsl/spmv-pull.wgsl.js +110 -36
  206. package/dist/src/wgsl/spmv-pull.wgsl.js.map +1 -1
  207. package/dist/tsconfig.build.tsbuildinfo +1 -1
  208. package/dist/webgpu-graph-algorithms.js +5016 -1130
  209. package/dist/webgpu-graph-algorithms.js.map +1 -1
  210. package/package.json +10 -7
  211. package/src/accelerator.ts +46 -12
  212. package/src/algorithms/components.ts +12 -16
  213. package/src/algorithms/degree.ts +58 -43
  214. package/src/algorithms/pagerank.ts +20 -18
  215. package/src/algorithms/power-iteration.ts +19 -18
  216. package/src/constants.ts +108 -8
  217. package/src/errors.ts +3 -1
  218. package/src/index.ts +25 -5
  219. package/src/kernel/dispatch.ts +18 -7
  220. package/src/kernel/kernel.ts +59 -5
  221. package/src/kernel/prelude.ts +15 -0
  222. package/src/kernel/profiler.ts +28 -4
  223. package/src/kernels.ts +378 -24
  224. package/src/layouts/calibrate.ts +187 -0
  225. package/src/layouts/force-simulation.ts +111 -26
  226. package/src/layouts/forceatlas2.ts +346 -324
  227. package/src/layouts/fruchterman-reingold.ts +918 -0
  228. package/src/layouts/model-common.ts +323 -0
  229. package/src/layouts/repulsion-grid.ts +451 -0
  230. package/src/layouts/spring-electrical.ts +845 -0
  231. package/src/memory/residency.ts +126 -20
  232. package/src/primitives/core-shape.ts +91 -4
  233. package/src/primitives/grid-pyramid.ts +221 -0
  234. package/src/primitives/grid.ts +349 -0
  235. package/src/primitives/histogram.ts +273 -0
  236. package/src/primitives/radix-sort.ts +246 -0
  237. package/src/primitives/scan.ts +197 -0
  238. package/src/primitives/segmented-reduce.ts +214 -56
  239. package/src/primitives/spmv.ts +125 -65
  240. package/src/primitives/verify.ts +249 -0
  241. package/src/types/accelerator.ts +15 -3
  242. package/src/types/context.ts +56 -0
  243. package/src/types/layout.ts +58 -0
  244. package/src/types/options.ts +45 -1
  245. package/src/wgsl/counting-scatter.wgsl.ts +16 -0
  246. package/src/wgsl/fa2-attraction.wgsl.ts +100 -17
  247. package/src/wgsl/fa2-integrate.wgsl.ts +28 -2
  248. package/src/wgsl/fa2-repulsion-exact.wgsl.ts +14 -5
  249. package/src/wgsl/fa2-stats-finalize.wgsl.ts +54 -0
  250. package/src/wgsl/grid-cell-key.wgsl.ts +29 -0
  251. package/src/wgsl/grid-centroid-hub.wgsl.ts +28 -0
  252. package/src/wgsl/grid-centroid.wgsl.ts +28 -0
  253. package/src/wgsl/grid-downsample.wgsl.ts +27 -0
  254. package/src/wgsl/grid-far-field.wgsl.ts +97 -0
  255. package/src/wgsl/grid-near-field.wgsl.ts +128 -0
  256. package/src/wgsl/histogram.wgsl.ts +14 -0
  257. package/src/wgsl/indirect-finalize.wgsl.ts +25 -0
  258. package/src/wgsl/radix-hist.wgsl.ts +30 -0
  259. package/src/wgsl/radix-scatter.wgsl.ts +39 -0
  260. package/src/wgsl/scan-add.wgsl.ts +13 -0
  261. package/src/wgsl/scan-block.wgsl.ts +29 -0
  262. package/src/wgsl/segmented-reduce.wgsl.ts +84 -15
  263. package/src/wgsl/spmv-pull.wgsl.ts +110 -36
  264. package/dist/chunks/context-CRbw2Wyo.js.map +0 -1
@@ -4,20 +4,26 @@
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
19
 
17
20
  import { type GraphSnapshot } from "@graphty/graph-format";
18
21
 
19
22
  import {
23
+ EXACT_MAX_NODES,
20
24
  FA2_DEFAULTS,
25
+ GRID_BBOX_MARGIN,
26
+ GRID_EXTENT_FLOOR,
21
27
  LAYOUT_TUNING_DEFAULTS,
22
28
  MAX_ITERATIONS_PER_STEP,
23
29
  TRACE_RECORD_BYTES,
@@ -31,7 +37,9 @@ import { type DispatchPlan, plan1d } from "../kernel/dispatch.js";
31
37
  import { type BoundKernel, type Kernel } from "../kernel/kernel.js";
32
38
  import { type UniformBlock, type UniformValues } from "../kernel/struct-block.js";
33
39
  import { type WgslModuleSpec } from "../kernel/wgsl.js";
34
- import { FA2_PARAMS, FA2_STATE, FA2_TRACE, FILL_PARAMS, graphBindings, kernelSpec } from "../kernels.js";
40
+ import { FA2_PARAMS, FA2_STATE, FA2_TRACE, FILL_PARAMS, kernelSpec } from "../kernels.js";
41
+ import { arcCountOf } from "../primitives/core-shape.js";
42
+ import { type GridSpec, gridSpecFor } from "../primitives/grid.js";
35
43
  import {
36
44
  type ForceAtlas2Stats,
37
45
  type ForceAtlas2TraceRecord,
@@ -48,23 +56,45 @@ import {
48
56
  type ModelInputs,
49
57
  type ModelResources,
50
58
  type StateWriter,
59
+ tierFor,
51
60
  } from "./force-simulation.js";
52
61
  import { resolveNodeMass, resolveWeights } from "./inputs.js";
62
+ import {
63
+ type AttractionBound,
64
+ bindAttraction,
65
+ describeValue,
66
+ FILL_PARAMS_BUFFER,
67
+ FORCE_BYTES_PER_NODE,
68
+ invalid,
69
+ isPositiveInteger,
70
+ type Overrides,
71
+ pickBoolean,
72
+ pickCenter,
73
+ pickDim,
74
+ pickNumber,
75
+ pickSeed,
76
+ recordAttraction,
77
+ scalar,
78
+ seedWord,
79
+ subset,
80
+ vector,
81
+ } from "./model-common.js";
53
82
  import { RepulsionExact, type RepulsionExactOverrides } from "./repulsion-exact.js";
83
+ import { type GridStage, RepulsionGrid, type RepulsionGridOverrides } from "./repulsion-grid.js";
54
84
 
55
85
  // ============================================================ constants and small helpers
56
86
 
57
- /** An override record as the kernel layer takes it. */
58
- type Overrides = Readonly<Record<string, number | boolean>>;
59
-
60
- /** The stage names of one iteration in dispatch order plus the per-batch toScene (spec 7.4; contract 3.13). */
61
- const FA2_STAGES = ["K1", "K2", "K3", "K4", "K5", "toScene"] as const;
87
+ /** 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. */
88
+ const FA2_STAGES = ["K1", "K2", "K3", "G1", "G2", "G3", "G4", "G5", "G6", "G7", "K4", "K5", "toScene"] as const;
62
89
 
63
- /** Bytes of the stride-3 f32 force arrays per node. */
64
- const FORCE_BYTES_PER_NODE = 12;
90
+ /** The FA2_STAGES index of the first grid stage, of K4, of K5 and of toScene. */
91
+ const STAGE_G1 = 3;
92
+ const STAGE_K4 = 10;
93
+ const STAGE_K5 = 11;
94
+ const STAGE_TO_SCENE = 12;
65
95
 
66
- /** The name of the model-owned FillParams buffer (a BufferSpec, reached through ModelResources.buffer). */
67
- const FILL_PARAMS_BUFFER = "fillParams";
96
+ /** The name of the model-owned hub-counter buffer K1 binds on every tier (P4 PD-14). */
97
+ const HUB_COUNTERS_BUFFER = "hubCounters";
68
98
 
69
99
  /** The one-workgroup dispatch of K1 (spec 7.4). */
70
100
  const ONE_WORKGROUP: DispatchPlan = { x: 1, y: 1, z: 1, items: 1, stride: null };
@@ -75,9 +105,6 @@ const K2_DEFAULTS: Overrides = { LINLOG: false, DISTRIBUTED: false, TIER: 0, USE
75
105
  /** Every override K5 accepts, with its default. */
76
106
  const K5_DEFAULTS: Overrides = { SWING_MODE: 0 };
77
107
 
78
- /** 2^32, the modulus of the u32 seed word (computed with `%`, never a bitwise operator). */
79
- const U32_MODULUS = 4294967296;
80
-
81
108
  /** The resolved record with no option given: FA2_DEFAULTS plus the null / origin defaults of spec 7.14. */
82
109
  const DEFAULT_RESOLVED: ResolvedForceAtlas2Options = Object.freeze<ResolvedForceAtlas2Options>({
83
110
  ...FA2_DEFAULTS,
@@ -88,214 +115,6 @@ const DEFAULT_RESOLVED: ResolvedForceAtlas2Options = Object.freeze<ResolvedForce
88
115
  seed: null,
89
116
  });
90
117
 
91
- /**
92
- * A short, safe rendering of an argument value for error messages (never String() on an object).
93
- * @param value - the value
94
- * @returns the rendering
95
- */
96
- function describeValue(value: unknown): string {
97
- if (value === null) {
98
- return "null";
99
- }
100
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") {
101
- return String(value);
102
- }
103
- if (typeof value === "undefined") {
104
- return "undefined";
105
- }
106
- if (typeof value === "object" && "length" in value && typeof value.length === "number") {
107
- return `[${value.length} values]`;
108
- }
109
- return typeof value;
110
- }
111
-
112
- /**
113
- * The E_INVALID_ARGUMENT error of an option check (contract 3.1: { argument, value, expected }).
114
- * @param argument - the option name
115
- * @param value - the value given
116
- * @param expected - what was expected
117
- * @returns the error (not thrown here)
118
- */
119
- function invalid(argument: string, value: unknown, expected: string): WebGpuGraphError {
120
- return new WebGpuGraphError("E_INVALID_ARGUMENT", `${argument} must be ${expected}; got ${describeValue(value)}`, {
121
- argument,
122
- value,
123
- expected,
124
- });
125
- }
126
-
127
- /**
128
- * A numeric option: the given value when defined, else the fallback; validated by `check` (the value is checked as
129
- * `unknown` so a JS caller's string or object is E_INVALID_ARGUMENT too).
130
- * @param name - the option name
131
- * @param given - the value given (undefined = absent)
132
- * @param fallback - the previous record's value or the default
133
- * @param check - the range predicate over a finite number
134
- * @param expected - the range in words (the error message)
135
- * @returns the value
136
- */
137
- function pickNumber(
138
- name: string,
139
- given: number | undefined,
140
- fallback: number,
141
- check: (value: number) => boolean,
142
- expected: string,
143
- ): number {
144
- const value: unknown = given === undefined ? fallback : given;
145
- if (typeof value !== "number" || !Number.isFinite(value) || !check(value)) {
146
- throw invalid(name, value, expected);
147
- }
148
- return value;
149
- }
150
-
151
- /**
152
- * A boolean option: the given value when defined, else the fallback; a non-boolean is E_INVALID_ARGUMENT.
153
- * @param name - the option name
154
- * @param given - the value given (undefined = absent)
155
- * @param fallback - the previous record's value or the default
156
- * @returns the value
157
- */
158
- function pickBoolean(name: string, given: boolean | undefined, fallback: boolean): boolean {
159
- const value: unknown = given === undefined ? fallback : given;
160
- if (typeof value !== "boolean") {
161
- throw invalid(name, value, "a boolean");
162
- }
163
- return value;
164
- }
165
-
166
- /**
167
- * The layout dimension: 2 or 3.
168
- * @param given - the value given (undefined = absent)
169
- * @param fallback - the previous record's value or the default
170
- * @returns 2 or 3
171
- */
172
- function pickDim(given: 2 | 3 | undefined, fallback: 2 | 3): 2 | 3 {
173
- const value: unknown = given === undefined ? fallback : given;
174
- if (value !== 2 && value !== 3) {
175
- throw invalid("dim", value, "2 or 3");
176
- }
177
- return value;
178
- }
179
-
180
- /**
181
- * The scene-unit center: an array-like of 2 (z = 0) or 3 finite numbers.
182
- * @param given - the value given (undefined = absent)
183
- * @param fallback - the previous record's value or the default
184
- * @returns the three components
185
- */
186
- function pickCenter(
187
- given: ArrayLike<number> | undefined,
188
- fallback: readonly [number, number, number],
189
- ): readonly [number, number, number] {
190
- if (given === undefined) {
191
- return fallback;
192
- }
193
- const expected = "an array of 2 or 3 finite numbers";
194
- const value: unknown = given;
195
- if (typeof value !== "object" || value === null || !("length" in value)) {
196
- throw invalid("center", given, expected);
197
- }
198
- const { length } = value;
199
- if (length !== 2 && length !== 3) {
200
- throw invalid("center", given, expected);
201
- }
202
- const x: unknown = given[0];
203
- const y: unknown = given[1];
204
- const z: unknown = length === 3 ? given[2] : 0;
205
- if (
206
- typeof x !== "number" ||
207
- typeof y !== "number" ||
208
- typeof z !== "number" ||
209
- !Number.isFinite(x) ||
210
- !Number.isFinite(y) ||
211
- !Number.isFinite(z)
212
- ) {
213
- throw invalid("center", given, expected);
214
- }
215
- return [x, y, z];
216
- }
217
-
218
- /**
219
- * The seed: a finite number, or null (unseeded; 0 keeps the port's "0 = unseeded" quirk through the Lcg).
220
- * @param given - the value given (undefined = absent)
221
- * @param fallback - the previous record's value or the default
222
- * @returns the seed or null
223
- */
224
- function pickSeed(given: number | null | undefined, fallback: number | null): number | null {
225
- if (given === undefined) {
226
- return fallback;
227
- }
228
- const value: unknown = given;
229
- if (value !== null && (typeof value !== "number" || !Number.isFinite(value))) {
230
- throw invalid("seed", given, "a finite number or null");
231
- }
232
- return value;
233
- }
234
-
235
- /**
236
- * Integer >= 1.
237
- * @param value - a finite number
238
- * @returns whether it is a positive integer
239
- */
240
- function isPositiveInteger(value: number): boolean {
241
- return Number.isInteger(value) && value >= 1;
242
- }
243
-
244
- /**
245
- * The u32 word written into Fa2Params.seed: 0 for null, else floor(|seed|) mod 2^32.
246
- * @param seed - the resolved seed
247
- * @returns the u32 value
248
- */
249
- function seedWord(seed: number | null): number {
250
- if (seed === null) {
251
- return 0;
252
- }
253
- return Math.floor(Math.abs(seed)) % U32_MODULUS;
254
- }
255
-
256
- /**
257
- * A scalar field of a block's read() result.
258
- * @param values - the values read
259
- * @param name - the field name
260
- * @returns the number
261
- */
262
- function scalar(values: UniformValues, name: string): number {
263
- const value = values[name];
264
- if (typeof value !== "number") {
265
- throw invalid(name, value, "a scalar field");
266
- }
267
- return value;
268
- }
269
-
270
- /**
271
- * A vector field of a block's read() result.
272
- * @param values - the values read
273
- * @param name - the field name
274
- * @returns the components
275
- */
276
- function vector(values: UniformValues, name: string): readonly number[] {
277
- const value = values[name];
278
- if (typeof value === "number") {
279
- throw invalid(name, value, "a vector field");
280
- }
281
- return value;
282
- }
283
-
284
- /**
285
- * The override record a kernel gets: its defaults overlaid with the values present in the merged set (contract 3.9:
286
- * a name a spec does not declare is rejected at compose time, so nothing else is passed through).
287
- * @param merged - the merged override set of the model (plus USE_PERM / HAS_WEIGHTS from the simulation)
288
- * @param defaults - the kernel's accepted names with their defaults
289
- * @returns the kernel's override record, every accepted name explicit
290
- */
291
- function subset(merged: Overrides, defaults: Overrides): Overrides {
292
- const out: Record<string, number | boolean> = {};
293
- for (const name of Object.keys(defaults)) {
294
- out[name] = name in merged ? merged[name] : defaults[name];
295
- }
296
- return out;
297
- }
298
-
299
118
  /**
300
119
  * The K3 / K4 override values of a merged set (typed for RepulsionExact).
301
120
  * @param merged - the merged override set
@@ -309,6 +128,15 @@ function repulsionOverrides(merged: Overrides): RepulsionExactOverrides {
309
128
  };
310
129
  }
311
130
 
131
+ /**
132
+ * The G6 / G7 / K4 override values of a merged set (typed for RepulsionGrid): K3's three and the FA2 law (P4-T13).
133
+ * @param merged - the merged override set
134
+ * @returns the grid stage's overrides
135
+ */
136
+ function gridOverrides(merged: Overrides): RepulsionGridOverrides {
137
+ return { ...repulsionOverrides(merged), LAW: 0 };
138
+ }
139
+
312
140
  // ============================================================ the resolvers
313
141
 
314
142
  /**
@@ -378,8 +206,8 @@ export function resolveForceAtlas2Options(
378
206
  }
379
207
 
380
208
  /**
381
- * Applies LAYOUT_TUNING_DEFAULTS (spec 7.14); the grid knobs are validated and stored but only `repulsion`,
382
- * `exactMaxNodes`, `deterministic` and `compat` have an effect in P3 (contract 3.3).
209
+ * Applies LAYOUT_TUNING_DEFAULTS (spec 7.14; contract 3.3). `nearMax` is an integer >= 2 (P4 DEP-P4-M: the
210
+ * near-field estimator needs at least one sampled entry besides the node itself).
383
211
  * @param tuning - the GPU-only knobs given (any object carrying them, e.g. the createForceAtlas2 options)
384
212
  * @returns the frozen resolved tuning
385
213
  */
@@ -402,7 +230,13 @@ export function resolveLayoutTuning(tuning: GpuLayoutTuning | undefined): Resolv
402
230
  isPositiveInteger,
403
231
  "an integer >= 1",
404
232
  ),
405
- nearMax: pickNumber("nearMax", t.nearMax, LAYOUT_TUNING_DEFAULTS.nearMax, isPositiveInteger, "an integer >= 1"),
233
+ nearMax: pickNumber(
234
+ "nearMax",
235
+ t.nearMax,
236
+ LAYOUT_TUNING_DEFAULTS.nearMax,
237
+ (v) => isPositiveInteger(v) && v >= 2,
238
+ "an integer >= 2",
239
+ ),
406
240
  deterministic: pickBoolean("deterministic", t.deterministic, LAYOUT_TUNING_DEFAULTS.deterministic),
407
241
  gridMax2D: pickNumber(
408
242
  "gridMax2D",
@@ -441,10 +275,12 @@ interface BoundModel {
441
275
  readonly fillPlan: DispatchPlan;
442
276
  readonly k1: Kernel;
443
277
  readonly k1Bound: BoundKernel;
444
- readonly k2: Kernel;
445
- /** null when arcCount === 0 (K2 is not recorded; the fill below zeroes force instead, spec 7.5). */
446
- readonly k2Bound: BoundKernel | null;
447
- readonly repulsion: RepulsionExact;
278
+ /** The K2 tier dispatches (P4 PD-7); null when arcCount === 0 (K2 is not recorded; the fill below zeroes force instead, spec 7.5). */
279
+ readonly attraction: AttractionBound | null;
280
+ /** The exact-tier stage (K3 and K4), or null on the grid tier (P4 PD-18): one tier's kernels compile per load. */
281
+ readonly repulsion: RepulsionExact | null;
282
+ /** The grid-tier stage (G1-G7 and K4), or null on the exact tier (P4 PD-18). */
283
+ readonly grid: RepulsionGrid | null;
448
284
  readonly k5: Kernel;
449
285
  readonly k5Bound: BoundKernel;
450
286
  readonly toScene: Kernel;
@@ -456,12 +292,12 @@ interface BoundModel {
456
292
  readonly fillOldBound: BoundKernel | null;
457
293
  }
458
294
 
459
- /** The ForceAtlas2 model (spec 7.4: K1 K2 K3 K4 K5 per iteration; toScene once per batch). Stages: ["K1", "K2", "K3", "K4", "K5", "toScene"]. */
295
+ /** 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. */
460
296
  export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtlas2Stats> {
461
297
  /** The model kind of spec 7.19. */
462
298
  readonly kind = "forceatlas2";
463
299
  /** The stage names in dispatch order (the `upTo` vocabulary of recordIteration and debugRunStages). */
464
- readonly stages: readonly ["K1", "K2", "K3", "K4", "K5", "toScene"] = FA2_STAGES;
300
+ readonly stages: typeof FA2_STAGES = FA2_STAGES;
465
301
  /** Fa2Params: the per-iteration uniform block (the simulation writes the shared fields into it). */
466
302
  readonly params: UniformBlock = FA2_PARAMS;
467
303
  /** Fa2State: the state header block (the simulation allocates and initialises it through this layout). */
@@ -484,6 +320,8 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
484
320
  private bound: BoundModel | null = null;
485
321
  /** Armed by onLoad(): the next recordIteration zeroes oldForce first (paper mode). */
486
322
  private resetOldForce = false;
323
+ /** The grid of the load inputs() last resolved (null on the exact tier): onLoad() writes its frame, specs() lists its kernels. */
324
+ private nextGrid: GridSpec | null = null;
487
325
  /**
488
326
  * The K1-K5 compute pass of the batch being recorded, keyed by CommandBatch.id (unique per batch): every
489
327
  * recordIteration of one batch dispatches into it (ONE pass per batch, contract 4.4); null between batches and
@@ -512,15 +350,19 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
512
350
 
513
351
  /**
514
352
  * force 12n and oldForce 12n (zeroed) in BOTH swing modes (3.10.1: a writable slot is never aliased; mode 1 leaves
515
- * oldForce unread and unwritten), plus the 256-byte FillParams uniform buffer the fill dispatches read. n = 0
516
- * reports one node's worth of bytes so no zero-length buffer is ever created (spec 3.6).
353
+ * oldForce unread and unwritten), the 256-byte FillParams uniform buffer the fill dispatches read, the 16-byte
354
+ * `hubCounters` K1 binds on every tier (P4 PD-14), and the grid buffers of `RepulsionGrid.buffers` exactly when
355
+ * `tierFor(tuning, n)` is the grid tier (PD-18). n = 0 reports one node's worth of bytes so no zero-length buffer
356
+ * is ever created (spec 3.6).
517
357
  * @param n - the node count
518
- * @param _dim - the layout dimension (the force arrays are stride 3 in both)
519
- * @returns the three model-owned buffer specs
358
+ * @param dim - the layout dimension (the force arrays are stride 3 in both; the grid's geometry differs)
359
+ * @returns the model-owned buffer specs
520
360
  */
521
- buffers(n: number, _dim: 2 | 3): readonly BufferSpec[] {
361
+ buffers(n: number, dim: 2 | 3): readonly BufferSpec[] {
522
362
  const bytes = Math.max(1, n) * FORCE_BYTES_PER_NODE;
523
363
  const usage = BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST;
364
+ const grid =
365
+ tierFor(this.tuning, n) === "grid" ? RepulsionGrid.buffers(n, gridSpecFor(n, dim, this.tuning)) : [];
524
366
  return [
525
367
  { name: "force", byteLength: bytes, usage, zero: true },
526
368
  { name: "oldForce", byteLength: bytes, usage, zero: true },
@@ -530,34 +372,26 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
530
372
  usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST,
531
373
  zero: false,
532
374
  },
375
+ { name: HUB_COUNTERS_BUFFER, byteLength: 16, usage, zero: true },
376
+ ...grid,
533
377
  ];
534
378
  }
535
379
 
536
380
  /**
537
- * { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) } (3.13 inputs.ts),
538
- * after the tier rule of spec 7.8 / lead f: `repulsion: "grid"` is E_UNSUPPORTED { feature: "repulsion.grid" }
539
- * for any n and `"auto"` when n > exactMaxNodes (the grid tier lands in P4); `"exact"` always runs.
381
+ * { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) } (3.13 inputs.ts).
382
+ * Also remembers the grid of this load (`tierFor(tuning, n)`, spec 7.8) for onLoad() and specs(): the simulation
383
+ * calls inputs() first, then onLoad() before bind().
540
384
  * @param s - the snapshot being loaded
541
385
  * @param options - the simulation's current option record
542
386
  * @returns the per-load inputs
543
387
  */
544
388
  inputs(s: GraphSnapshot, options: ForceAtlas2Options): ModelInputs {
545
389
  const resolved = resolveForceAtlas2Options(options, this.current);
546
- const { repulsion, exactMaxNodes } = this.tuning;
547
390
  const n = s.nodeCount;
548
- if (repulsion === "grid" || (repulsion === "auto" && n > exactMaxNodes)) {
549
- throw new WebGpuGraphError(
550
- "E_UNSUPPORTED",
551
- repulsion === "grid"
552
- ? 'repulsion: "grid" is not available yet (the grid tier lands in P4)'
553
- : `the graph has ${n} nodes, above exactMaxNodes ${exactMaxNodes}, and the grid tier lands in P4`,
554
- {
555
- feature: "repulsion.grid",
556
- hint: 'pass repulsion: "exact" (or raise exactMaxNodes) to run the exact tier at this size',
557
- },
558
- );
559
- }
560
- return { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) };
391
+ // resolve first: a throwing mass / weight resolution leaves the remembered grid of the previous load intact
392
+ const inputs = { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) };
393
+ this.nextGrid = tierFor(this.tuning, n) === "grid" ? gridSpecFor(n, resolved.dim, this.tuning) : null;
394
+ return inputs;
561
395
  }
562
396
 
563
397
  /**
@@ -583,7 +417,9 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
583
417
 
584
418
  /**
585
419
  * The seven module specs of an override set in dispatch order -- K1, K2, K3, K4, K5, toScene, fill -- each with
586
- * only the override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix.
420
+ * only the override names its entry declares (K2 also USE_PERM / HAS_WEIGHTS), for warm() and the compile matrix,
421
+ * followed by the grid tier's specs (`RepulsionGrid.specs`) when the load inputs() last resolved is a grid load
422
+ * (the pipeline key carries no geometry, so the spec's size is immaterial).
587
423
  * @param overrides - the merged override set (the model's plus USE_PERM / HAS_WEIGHTS)
588
424
  * @param _subgroups - accepted for the ForceModel interface and unused: every reducing FA2 body carries
589
425
  * needs: ["subgroups"] in its registry entry and the composer picks the twin from caps.features (contract 4.3)
@@ -591,6 +427,10 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
591
427
  */
592
428
  specs(overrides: Overrides, _subgroups: boolean): readonly WgslModuleSpec[] {
593
429
  const [repulsionSpec, speedSpec] = RepulsionExact.specs(repulsionOverrides(overrides));
430
+ const grid =
431
+ this.nextGrid === null
432
+ ? []
433
+ : RepulsionGrid.specs(gridOverrides(overrides), gridSpecFor(EXACT_MAX_NODES + 1, 2, this.tuning));
594
434
  return [
595
435
  kernelSpec("fa2-stats-finalize"),
596
436
  kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS)),
@@ -599,45 +439,64 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
599
439
  kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS)),
600
440
  kernelSpec("fa2-to-scene"),
601
441
  kernelSpec("fill"),
442
+ ...grid,
602
443
  ];
603
444
  }
604
445
 
605
446
  /**
606
- * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 (or the fill of
607
- * force when arcCount === 0), K3 + K4 through RepulsionExact, K5, toScene, and the fill of oldForce; writes the
608
- * FillParams { count: 3n, value: 0, mode: 0 } into the model's uniform buffer. With n === 0 nothing is bound.
447
+ * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 over the degree
448
+ * tiers through bindAttraction (or the fill of force when arcCount === 0), K3 + K4 through RepulsionExact, K5,
449
+ * toScene, and the fill of oldForce; writes the FillParams { count: 3n, value: 0, mode: 0 } into the model's
450
+ * uniform buffer. With n === 0 nothing is bound. The K2 TIER 1 / 2 pipelines compile on the first load whose
451
+ * degrees need them (P4 PD-7), a one-time cost at that load.
609
452
  * @param resources - the graph, the shared and model buffers, the ring and the cache
610
453
  * @param overrides - the merged override set
611
454
  */
612
455
  async bind(resources: ModelResources, overrides: Overrides): Promise<void> {
613
456
  this.dropBound();
614
457
  this.resources = resources;
615
- const { n, pipelines, caps, core, perm, ring, device } = resources;
458
+ const { n, pipelines, caps, core, ring, device } = resources;
616
459
  if (n === 0) {
617
460
  return;
618
461
  }
619
- const [k1, k2, k5, toScene, fill] = await Promise.all([
462
+ const pos = resources.buffer("positions");
463
+ const force = resources.buffer("force");
464
+ const params = ring.binding(FA2_PARAMS);
465
+ const hasArcs = core.colIdx !== null;
466
+ const [k1, k5, toScene, fill] = await Promise.all([
620
467
  pipelines.kernel(kernelSpec("fa2-stats-finalize")),
621
- pipelines.kernel(kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS))),
622
468
  pipelines.kernel(kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS))),
623
469
  pipelines.kernel(kernelSpec("fa2-to-scene")),
624
470
  pipelines.kernel(kernelSpec("fill")),
625
471
  ]);
626
- const repulsion = await RepulsionExact.create(pipelines, caps, repulsionOverrides(overrides));
472
+ const repulsion =
473
+ resources.tier === "grid"
474
+ ? null
475
+ : await RepulsionExact.create(pipelines, caps, repulsionOverrides(overrides));
476
+ const attraction = hasArcs
477
+ ? await bindAttraction(resources, subset(overrides, K2_DEFAULTS), { pos, force, params })
478
+ : null;
479
+ const grid =
480
+ resources.tier === "grid"
481
+ ? await RepulsionGrid.create(
482
+ resources,
483
+ k1.workgroupSize,
484
+ gridOverrides(overrides),
485
+ gridSpecFor(n, resources.dim, this.tuning),
486
+ )
487
+ : null;
627
488
  if (this.resources !== resources) {
628
489
  // a newer bind() superseded this one while the pipelines compiled; its own bind groups stand
490
+ grid?.dispose();
629
491
  return;
630
492
  }
631
- const pos = resources.buffer("positions");
632
493
  const scene = resources.buffer("scenePositions");
633
494
  const fixed = resources.buffer("fixed");
634
495
  const partials = resources.buffer("partials");
635
496
  const state = resources.buffer("state");
636
497
  const trace = resources.buffer("trace");
637
- const force = resources.buffer("force");
638
498
  const oldForce = resources.buffer("oldForce");
639
499
  const fillParamsBuffer = resources.buffer(FILL_PARAMS_BUFFER);
640
- const params = ring.binding(FA2_PARAMS);
641
500
  const fillParams: Binding = {
642
501
  buffer: fillParamsBuffer.buffer,
643
502
  offset: fillParamsBuffer.offset,
@@ -647,20 +506,41 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
647
506
  const fillBytes = new ArrayBuffer(FILL_PARAMS.byteLength);
648
507
  FILL_PARAMS.write(new DataView(fillBytes), { count: 3 * n, value: 0, mode: 0 });
649
508
  device.queue.writeBuffer(fillParamsBuffer.buffer, fillParamsBuffer.offset, fillBytes);
650
- const hasArcs = core.colIdx !== null;
651
- repulsion.bind({ pos, state, trace, force, oldForce, fixedMask: fixed, partials, params });
509
+ const hubCounters = resources.buffer(HUB_COUNTERS_BUFFER);
510
+ const exact = { pos, state, trace, force, oldForce, fixedMask: fixed, partials, params };
511
+ repulsion?.bind(exact);
512
+ grid?.bind({
513
+ ...exact,
514
+ cellKey: resources.buffer("cellKey"),
515
+ cellVal: resources.buffer("cellVal"),
516
+ sortedKey: resources.buffer("sortedKey"),
517
+ sortedIdx: resources.buffer("sortedIdx"),
518
+ cellHist: resources.buffer("cellHist"),
519
+ cellStart: resources.buffer("cellStart"),
520
+ hubList: resources.buffer("hubList"),
521
+ hubCounters,
522
+ hubArgs: resources.buffer("hubArgs"),
523
+ pyramid: resources.buffer("pyramid"),
524
+ });
652
525
  const wg = k1.workgroupSize;
653
526
  this.bound = {
654
527
  n,
655
528
  plan: plan1d(n, wg, caps),
656
529
  fillPlan: plan1d(3 * n, wg, caps),
657
530
  k1,
658
- k1Bound: k1.bind({ partials, S: state, T: trace, P: params }),
659
- k2,
660
- k2Bound: hasArcs
661
- ? k2.bind({ ...graphBindings(core, perm, resources.weights), pos, force, P: params })
662
- : null,
531
+ // PD-14: on the exact tier K1's grid slots take dummies (cellHist := partials, both read-only; hubCounters
532
+ // is the model's 16-byte buffer on every tier) and the block is dead under gridMax 0
533
+ k1Bound: k1.bind({
534
+ partials,
535
+ S: state,
536
+ T: trace,
537
+ cellHist: grid === null ? partials : resources.buffer("cellHist"),
538
+ hubCounters,
539
+ P: params,
540
+ }),
541
+ attraction,
663
542
  repulsion,
543
+ grid,
664
544
  k5,
665
545
  k5Bound: k5.bind({ force, oldForce, fixedMask: fixed, S: state, pos, partials, P: params }),
666
546
  toScene,
@@ -679,14 +559,19 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
679
559
  * @returns the uniform values
680
560
  */
681
561
  paramsFor(iteration: number, options: ForceAtlas2Options): UniformValues {
682
- const { n } = this.requireResources();
562
+ const { n, core, tiers, tier, dim } = this.requireResources();
683
563
  const resolved = resolveForceAtlas2Options(options, this.current);
684
564
  const { nearMax, extentFactor } = this.tuning;
565
+ const grid = tier === "grid" ? gridSpecFor(n, dim, this.tuning) : null;
566
+ // P4 PD-7: TIER 2 reads [0, hiEnd), TIER 1 [hiEnd, midEnd), TIER 0 [tierStart, tierEnd) = [midEnd, n)
567
+ const so = tiers?.segmentOffsets;
568
+ const hiEnd = so?.[1] ?? 0;
569
+ const midEnd = so?.[2] ?? 0;
685
570
  return {
686
571
  n,
687
572
  dim: resolved.dim,
688
573
  flags: 0,
689
- tierStart: 0,
574
+ tierStart: midEnd,
690
575
  tierEnd: n,
691
576
  iterationIndex: iteration,
692
577
  seed: seedWord(resolved.seed),
@@ -698,34 +583,38 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
698
583
  center: [resolved.center[0], resolved.center[1], resolved.center[2], 0],
699
584
  settleThreshold: resolved.settleThreshold,
700
585
  extentFactor,
701
- gridMax: 0,
702
- levels: 0,
703
- pad: [0, 0, 0, 0],
586
+ gridMax: grid?.g ?? 0,
587
+ levels: grid?.levels ?? 0,
588
+ arcBase: 0,
589
+ arcEnd: arcCountOf(core),
590
+ accumulate: 0,
591
+ hiEnd,
592
+ midEnd,
704
593
  };
705
594
  }
706
595
 
707
596
  /**
708
- * Records one iteration into the batch: K1, K2 (or the fill of force when arcCount === 0), K3, K4, K5 in the
709
- * batch's ONE K1-K5 compute pass (opened by the first call of a batch and reused by every later call with the
710
- * same batch.id, PLAN DECISION 2), then toScene in a second pass that ends it, stopping after stage `upTo` when
711
- * given (spec 7.4; debugRunStages / inspect, spec 11.9 item 2). The simulation passes "K5" for iterations
712
- * 0..k-2 and undefined for the last, so toScene runs once per batch. The first call after load() zeroes
713
- * oldForce before K1 (paper mode). With n === 0 nothing is recorded (PLAN DECISION 9); a call before bind()
714
- * completed is E_NOT_LOADED (never a silent no-op).
597
+ * Records one iteration into the batch, stopping after stage `upTo` when given (spec 7.4; debugRunStages /
598
+ * inspect, spec 11.9 item 2; PD-17: `upTo` names a position in the union list and the recording stops after the
599
+ * last stage recorded at or before it, so "K3" on the grid tier stops after K2 and "G5" on the exact tier after
600
+ * K3). The exact tier: K1, K2 (or the fill of force when arcCount === 0), K3, K4, K5 in the batch's ONE compute
601
+ * pass (opened by the first call of a batch and reused by every later call with the same batch.id, PLAN
602
+ * DECISION 2), then toScene in a second pass that ends it. The grid tier (PD-16): the passes `fa2-k1` (K1),
603
+ * `fa2-attraction` (K2's tiers) and `fa2-grid` (G1-G7, K4, K5) per iteration, then `fa2-to-scene`. The profiler
604
+ * budgets PROFILER_QUERY_SLOTS / 2 = 128 passes per batch, so a grid batch above 42 iterations is timed only in
605
+ * part (the exact tier's two passes per batch always fit): the simulation then reports msPerIteration from the
606
+ * wall time, never from the sum of the timed prefix (`ForceSimulation.batchMilliseconds`). The simulation
607
+ * passes "K5" for iterations 0..k-2 and undefined for the last, so toScene runs once per batch. The first call
608
+ * after load() zeroes oldForce before K1 (paper mode). With n === 0 nothing is recorded (PLAN DECISION 9); a
609
+ * call before bind() completed is E_NOT_LOADED (never a silent no-op).
715
610
  * @param batch - the batch being recorded
716
611
  * @param slot - the UniformRing slot holding this iteration's Fa2Params
717
- * @param tier - "exact" (the grid tier is E_UNSUPPORTED until P4; the simulation never passes "grid")
612
+ * @param tier - the tier the simulation resolved at load() (the same rule bind() applied, PD-18)
718
613
  * @param upTo - a stage name to stop after; undefined records every stage including toScene
719
614
  */
720
615
  recordIteration(batch: CommandBatch, slot: number, tier: "exact" | "grid", upTo?: string): void {
721
- if (tier === "grid") {
722
- throw new WebGpuGraphError("E_UNSUPPORTED", "the grid repulsion tier lands in P4", {
723
- feature: "repulsion.grid",
724
- hint: 'pass repulsion: "exact"',
725
- });
726
- }
727
616
  const resources = this.requireResources();
728
- const stop = upTo === undefined ? FA2_STAGES.length - 1 : this.stageIndex(upTo);
617
+ const stop = upTo === undefined ? STAGE_TO_SCENE : this.stageIndex(upTo);
729
618
  const { bound } = this;
730
619
  if (bound === null) {
731
620
  if (resources.n === 0) {
@@ -740,39 +629,118 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
740
629
  );
741
630
  }
742
631
  const offset = resources.ring.offsetOf(slot);
632
+ if (tier === "grid") {
633
+ this.recordGridIteration(batch, bound, offset, stop);
634
+ return;
635
+ }
636
+ const { repulsion } = bound;
637
+ if (repulsion === null) {
638
+ throw new WebGpuGraphError("E_NOT_LOADED", "the ForceAtlas2 model was bound on the grid tier", {
639
+ state: "loaded",
640
+ });
641
+ }
743
642
  const pass = this.openPass !== null && this.openPass.id === batch.id ? this.openPass.pass : batch.pass("fa2");
744
643
  this.openPass = { id: batch.id, pass };
745
- if (this.resetOldForce) {
746
- this.resetOldForce = false;
747
- if (bound.fillOldBound !== null) {
748
- bound.fill.dispatch(pass, bound.fillOldBound, bound.fillPlan, [0]);
749
- }
750
- }
751
- bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
644
+ this.recordK1(pass, bound, offset);
752
645
  if (stop < 1) {
753
646
  return;
754
647
  }
755
- if (bound.k2Bound !== null) {
756
- bound.k2.dispatch(pass, bound.k2Bound, bound.plan, [offset]);
757
- } else if (bound.fillForceBound !== null) {
758
- bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
759
- }
648
+ this.recordK2(pass, bound, offset);
760
649
  if (stop < 2) {
761
650
  return;
762
651
  }
763
- bound.repulsion.recordRepulsion(pass, bound.n, offset);
764
- if (stop < 3) {
652
+ repulsion.recordRepulsion(pass, bound.n, offset);
653
+ if (stop < STAGE_K4) {
654
+ return;
655
+ }
656
+ repulsion.recordSpeedFinalize(pass, offset);
657
+ if (stop < STAGE_K5) {
658
+ return;
659
+ }
660
+ bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
661
+ if (stop < STAGE_TO_SCENE) {
662
+ return;
663
+ }
664
+ this.recordToScene(batch, bound, offset);
665
+ }
666
+
667
+ /**
668
+ * The grid tier's iteration (PD-16): three compute passes before toScene.
669
+ * @param batch - the batch being recorded
670
+ * @param bound - the bound model
671
+ * @param offset - the Fa2Params dynamic offset of the iteration
672
+ * @param stop - the FA2_STAGES index to stop after
673
+ */
674
+ private recordGridIteration(batch: CommandBatch, bound: BoundModel, offset: number, stop: number): void {
675
+ const { grid } = bound;
676
+ if (grid === null) {
677
+ throw new WebGpuGraphError("E_NOT_LOADED", "the ForceAtlas2 model was bound on the exact tier", {
678
+ state: "loaded",
679
+ });
680
+ }
681
+ this.openPass = null;
682
+ this.recordK1(batch.pass("fa2-k1"), bound, offset);
683
+ if (stop < 1) {
684
+ return;
685
+ }
686
+ this.recordK2(batch.pass("fa2-attraction"), bound, offset);
687
+ if (stop < STAGE_G1) {
688
+ return;
689
+ }
690
+ const pass = batch.pass("fa2-grid");
691
+ const gridStop = stop < STAGE_K4 ? (FA2_STAGES[stop] as GridStage) : undefined;
692
+ grid.recordRepulsion(pass, bound.n, offset, gridStop);
693
+ if (stop < STAGE_K4) {
765
694
  return;
766
695
  }
767
- bound.repulsion.recordSpeedFinalize(pass, offset);
768
- if (stop < 4) {
696
+ grid.recordSpeedFinalize(pass, offset);
697
+ if (stop < STAGE_K5) {
769
698
  return;
770
699
  }
771
700
  bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
772
- if (stop < 5) {
701
+ if (stop < STAGE_TO_SCENE) {
773
702
  return;
774
703
  }
775
- // the second pass ends the K1-K5 pass; the batch is complete after toScene, so nothing reuses it
704
+ this.recordToScene(batch, bound, offset);
705
+ }
706
+
707
+ /**
708
+ * The oldForce reset of the first iteration after load() (paper mode), then K1 (one workgroup).
709
+ * @param pass - the open compute pass
710
+ * @param bound - the bound model
711
+ * @param offset - the Fa2Params dynamic offset
712
+ */
713
+ private recordK1(pass: GPUComputePassEncoder, bound: BoundModel, offset: number): void {
714
+ if (this.resetOldForce) {
715
+ this.resetOldForce = false;
716
+ if (bound.fillOldBound !== null) {
717
+ bound.fill.dispatch(pass, bound.fillOldBound, bound.fillPlan, [0]);
718
+ }
719
+ }
720
+ bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
721
+ }
722
+
723
+ /**
724
+ * K2's tier dispatches, or the fill of force when the graph has no arcs (spec 7.5).
725
+ * @param pass - the open compute pass
726
+ * @param bound - the bound model
727
+ * @param offset - the Fa2Params dynamic offset
728
+ */
729
+ private recordK2(pass: GPUComputePassEncoder, bound: BoundModel, offset: number): void {
730
+ if (bound.attraction !== null) {
731
+ recordAttraction(pass, bound.attraction, offset);
732
+ } else if (bound.fillForceBound !== null) {
733
+ bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
734
+ }
735
+ }
736
+
737
+ /**
738
+ * The toScene pass that ends the iteration's pass; the batch is complete after it, so nothing reuses the pass.
739
+ * @param batch - the batch
740
+ * @param bound - the bound model
741
+ * @param offset - the Fa2Params dynamic offset
742
+ */
743
+ private recordToScene(batch: CommandBatch, bound: BoundModel, offset: number): void {
776
744
  this.openPass = null;
777
745
  const scenePass = batch.pass("fa2-to-scene");
778
746
  bound.toScene.dispatch(scenePass, bound.toSceneBound, bound.plan, [offset]);
@@ -780,7 +748,9 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
780
748
 
781
749
  /**
782
750
  * speed = 1, speedEfficiency = 1, swing = 1, traction = 1 (mode 1 accumulates from 1; mode 0 overwrites them each
783
- * iteration, the initial value is irrelevant); arms the oldForce reset of the next recordIteration.
751
+ * iteration, the initial value is irrelevant); arms the oldForce reset of the next recordIteration. On a grid
752
+ * load the frame of the first build (K1 folds nothing on the first iteration): the same six values K1 derives,
753
+ * in f32 with the kernel's order of operations, from the host-written min / max / centroid / rmsRadius.
784
754
  * @param state - the state writer of the simulation
785
755
  */
786
756
  onLoad(state: StateWriter): void {
@@ -789,6 +759,9 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
789
759
  state.set("swing", 1);
790
760
  state.set("traction", 1);
791
761
  this.resetOldForce = true;
762
+ if (this.nextGrid !== null) {
763
+ writeGridFrame(state, this.nextGrid, this.tuning.extentFactor);
764
+ }
792
765
  }
793
766
 
794
767
  /**
@@ -826,7 +799,9 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
826
799
 
827
800
  /**
828
801
  * Decodes the state header and the k trace records of a completed batch (k = trace.byteLength / 32) into
829
- * ForceAtlas2Stats: the exact tier with null grid fields; msPerIteration null (the simulation owns the clock).
802
+ * ForceAtlas2Stats: `repulsionTier` is the bound tier, the grid fields are the header's on the grid tier
803
+ * (`maxCellOccupancy` / `outsideGrid`: the counts of the iteration before the last K1) and null on the exact
804
+ * tier; msPerIteration null (the simulation owns the clock).
830
805
  * @param state - a DataView over the 256-byte state header
831
806
  * @param trace - a DataView over the k Fa2Trace records of the batch
832
807
  * @returns the stats
@@ -847,15 +822,16 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
847
822
  settledCount: scalar(record, "settledCount"),
848
823
  });
849
824
  }
825
+ const grid = this.resources?.tier === "grid";
850
826
  return {
851
827
  iteration: scalar(header, "iteration"),
852
828
  meanDisplacement: scalar(header, "meanDisplacement"),
853
829
  rmsRadius: scalar(header, "rmsRadius"),
854
830
  layoutRadius: scalar(header, "radius"),
855
831
  centroid: [centroid[0], centroid[1], centroid[2]],
856
- repulsionTier: "exact",
857
- maxCellOccupancy: null,
858
- outsideGrid: null,
832
+ repulsionTier: grid ? "grid" : "exact",
833
+ maxCellOccupancy: grid ? scalar(header, "maxCellOccupancy") : null,
834
+ outsideGrid: grid ? scalar(header, "outsideGrid") : null,
859
835
  msPerIteration: null,
860
836
  swing: scalar(header, "swing"),
861
837
  traction: scalar(header, "traction"),
@@ -892,10 +868,18 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
892
868
  throw invalid("upTo", upTo, FA2_STAGES.join(" | "));
893
869
  }
894
870
 
871
+ /**
872
+ * Releases the grid stage's lease and the bind groups (the simulation calls it from dispose() once every
873
+ * in-flight batch has settled).
874
+ */
875
+ dispose(): void {
876
+ this.dropBound();
877
+ }
878
+
895
879
  /**
896
880
  * Drops the bind groups of the previous bind() (the buffers changed) so the cached kernels do not accumulate stale
897
- * groups across reloads; K3 / K4 live inside RepulsionExact and keep the P1-T6 behaviour. Also forgets the pass
898
- * of a batch recorded before the rebind.
881
+ * groups across reloads; K3 / K4 live inside RepulsionExact and keep the P1-T6 behaviour; the grid stage
882
+ * releases its lease. Also forgets the pass of a batch recorded before the rebind.
899
883
  */
900
884
  private dropBound(): void {
901
885
  this.openPass = null;
@@ -903,13 +887,50 @@ export class ForceAtlas2Model implements ForceModel<ForceAtlas2Options, ForceAtl
903
887
  if (bound === null) {
904
888
  return;
905
889
  }
906
- for (const kernel of [bound.k1, bound.k2, bound.k5, bound.toScene, bound.fill]) {
890
+ for (const kernel of [bound.k1, bound.k5, bound.toScene, bound.fill]) {
891
+ kernel.invalidate();
892
+ }
893
+ for (const [kernel] of bound.attraction?.kernels ?? []) {
907
894
  kernel.invalidate();
908
895
  }
896
+ bound.grid?.dispose();
909
897
  this.bound = null;
910
898
  }
911
899
  }
912
900
 
901
+ /**
902
+ * The grid frame of the first build after load() (spec 7.7 geometry table; PD-10): K1's text in f32 with the same
903
+ * order of operations -- `box = (max - min) * GRID_BBOX_MARGIN`, `extent = max(min(max(box), extentFactor *
904
+ * rmsRadius), GRID_EXTENT_FLOOR)`, `cellSize = extent / G`, `gridMin = centroid - extent / 2` (cellSize in `.w`),
905
+ * `invCellSize = 1 / cellSize`, `eps = 0.25 cellSize` -- plus zero counts. Shared with the FR and spring-electrical
906
+ * models' onLoad (P4-T13).
907
+ * @param state - the state writer (min / max / centroid / rmsRadius already written by the simulation)
908
+ * @param spec - the grid of the load
909
+ * @param extentFactor - the tuning's extent factor
910
+ */
911
+ export function writeGridFrame(state: StateWriter, spec: GridSpec, extentFactor: number): void {
912
+ const f = Math.fround;
913
+ const axis = (name: string): readonly number[] => {
914
+ const value = state.get(name);
915
+ return typeof value === "number" ? [value, value, value] : value;
916
+ };
917
+ const min = axis("min");
918
+ const max = axis("max");
919
+ const centroid = axis("centroid");
920
+ const rms = state.get("rmsRadius");
921
+ const box = [0, 1, 2].map((a) => f(f(f(max[a]) - f(min[a])) * f(GRID_BBOX_MARGIN)));
922
+ const bboxExtent = spec.dim === 3 ? Math.max(box[0], box[1], box[2]) : Math.max(box[0], box[1]);
923
+ const rmsTerm = f(f(extentFactor) * f(typeof rms === "number" ? rms : 0));
924
+ const extent = Math.max(Math.min(bboxExtent, rmsTerm), f(GRID_EXTENT_FLOOR));
925
+ const cellSize = f(extent / spec.g);
926
+ const half = f(0.5 * extent);
927
+ state.set("gridMin", [f(f(centroid[0]) - half), f(f(centroid[1]) - half), f(f(centroid[2]) - half), cellSize]);
928
+ state.set("invCellSize", f(1 / cellSize));
929
+ state.set("eps", f(0.25 * cellSize));
930
+ state.set("outsideGrid", 0);
931
+ state.set("maxCellOccupancy", 0);
932
+ }
933
+
913
934
  // ============================================================ the factory
914
935
 
915
936
  /**
@@ -924,8 +945,9 @@ function resolvePatch(patch: Partial<ForceAtlas2Options>, current: ForceAtlas2Op
924
945
  }
925
946
 
926
947
  /**
927
- * Spec 3.3 createForceAtlas2, verbatim: a GpuLayoutSimulation running ForceAtlas2 on the exact repulsion tier with
928
- * the option defaults of spec 7.14 and the GPU-only tuning of GpuLayoutTuning (contract 3.13 "Contracts").
948
+ * Spec 3.3 createForceAtlas2, verbatim: a GpuLayoutSimulation running ForceAtlas2 on the exact or the grid repulsion
949
+ * tier (spec 7.8) with the option defaults of spec 7.14 and the GPU-only tuning of GpuLayoutTuning (contract 3.13
950
+ * "Contracts").
929
951
  * @param ctx - the context (E_DISPOSED / E_DEVICE_LOST through assertReady)
930
952
  * @param options - the ForceAtlas2 options and the GPU-only tuning knobs in one record
931
953
  * @returns the simulation in state "created"; load() next