@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,37 +4,46 @@
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";
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";
23
29
  import { RepulsionExact } from "./repulsion-exact.js";
24
- /** The stage names of one iteration in dispatch order plus the per-batch toScene (spec 7.4; contract 3.13). */
25
- const FA2_STAGES = ["K1", "K2", "K3", "K4", "K5", "toScene"];
26
- /** Bytes of the stride-3 f32 force arrays per node. */
27
- const FORCE_BYTES_PER_NODE = 12;
28
- /** The name of the model-owned FillParams buffer (a BufferSpec, reached through ModelResources.buffer). */
29
- const FILL_PARAMS_BUFFER = "fillParams";
30
+ import { RepulsionGrid } from "./repulsion-grid.js";
31
+ // ============================================================ constants and small helpers
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";
30
41
  /** The one-workgroup dispatch of K1 (spec 7.4). */
31
42
  const ONE_WORKGROUP = { x: 1, y: 1, z: 1, items: 1, stride: null };
32
43
  /** Every override K2 accepts, with its default (contract 3.10.1 plus the two standard graph overrides). */
33
44
  const K2_DEFAULTS = { LINLOG: false, DISTRIBUTED: false, TIER: 0, USE_PERM: false, HAS_WEIGHTS: false };
34
45
  /** Every override K5 accepts, with its default. */
35
46
  const K5_DEFAULTS = { SWING_MODE: 0 };
36
- /** 2^32, the modulus of the u32 seed word (computed with `%`, never a bitwise operator). */
37
- const U32_MODULUS = 4294967296;
38
47
  /** The resolved record with no option given: FA2_DEFAULTS plus the null / origin defaults of spec 7.14. */
39
48
  const DEFAULT_RESOLVED = Object.freeze({
40
49
  ...FA2_DEFAULTS,
@@ -44,191 +53,6 @@ const DEFAULT_RESOLVED = Object.freeze({
44
53
  center: [0, 0, 0],
45
54
  seed: null,
46
55
  });
47
- /**
48
- * A short, safe rendering of an argument value for error messages (never String() on an object).
49
- * @param value - the value
50
- * @returns the rendering
51
- */
52
- function describeValue(value) {
53
- if (value === null) {
54
- return "null";
55
- }
56
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") {
57
- return String(value);
58
- }
59
- if (typeof value === "undefined") {
60
- return "undefined";
61
- }
62
- if (typeof value === "object" && "length" in value && typeof value.length === "number") {
63
- return `[${value.length} values]`;
64
- }
65
- return typeof value;
66
- }
67
- /**
68
- * The E_INVALID_ARGUMENT error of an option check (contract 3.1: { argument, value, expected }).
69
- * @param argument - the option name
70
- * @param value - the value given
71
- * @param expected - what was expected
72
- * @returns the error (not thrown here)
73
- */
74
- function invalid(argument, value, expected) {
75
- return new WebGpuGraphError("E_INVALID_ARGUMENT", `${argument} must be ${expected}; got ${describeValue(value)}`, {
76
- argument,
77
- value,
78
- expected,
79
- });
80
- }
81
- /**
82
- * A numeric option: the given value when defined, else the fallback; validated by `check` (the value is checked as
83
- * `unknown` so a JS caller's string or object is E_INVALID_ARGUMENT too).
84
- * @param name - the option name
85
- * @param given - the value given (undefined = absent)
86
- * @param fallback - the previous record's value or the default
87
- * @param check - the range predicate over a finite number
88
- * @param expected - the range in words (the error message)
89
- * @returns the value
90
- */
91
- function pickNumber(name, given, fallback, check, expected) {
92
- const value = given === undefined ? fallback : given;
93
- if (typeof value !== "number" || !Number.isFinite(value) || !check(value)) {
94
- throw invalid(name, value, expected);
95
- }
96
- return value;
97
- }
98
- /**
99
- * A boolean option: the given value when defined, else the fallback; a non-boolean is E_INVALID_ARGUMENT.
100
- * @param name - the option name
101
- * @param given - the value given (undefined = absent)
102
- * @param fallback - the previous record's value or the default
103
- * @returns the value
104
- */
105
- function pickBoolean(name, given, fallback) {
106
- const value = given === undefined ? fallback : given;
107
- if (typeof value !== "boolean") {
108
- throw invalid(name, value, "a boolean");
109
- }
110
- return value;
111
- }
112
- /**
113
- * The layout dimension: 2 or 3.
114
- * @param given - the value given (undefined = absent)
115
- * @param fallback - the previous record's value or the default
116
- * @returns 2 or 3
117
- */
118
- function pickDim(given, fallback) {
119
- const value = given === undefined ? fallback : given;
120
- if (value !== 2 && value !== 3) {
121
- throw invalid("dim", value, "2 or 3");
122
- }
123
- return value;
124
- }
125
- /**
126
- * The scene-unit center: an array-like of 2 (z = 0) or 3 finite numbers.
127
- * @param given - the value given (undefined = absent)
128
- * @param fallback - the previous record's value or the default
129
- * @returns the three components
130
- */
131
- function pickCenter(given, fallback) {
132
- if (given === undefined) {
133
- return fallback;
134
- }
135
- const expected = "an array of 2 or 3 finite numbers";
136
- const value = given;
137
- if (typeof value !== "object" || value === null || !("length" in value)) {
138
- throw invalid("center", given, expected);
139
- }
140
- const { length } = value;
141
- if (length !== 2 && length !== 3) {
142
- throw invalid("center", given, expected);
143
- }
144
- const x = given[0];
145
- const y = given[1];
146
- const z = length === 3 ? given[2] : 0;
147
- if (typeof x !== "number" ||
148
- typeof y !== "number" ||
149
- typeof z !== "number" ||
150
- !Number.isFinite(x) ||
151
- !Number.isFinite(y) ||
152
- !Number.isFinite(z)) {
153
- throw invalid("center", given, expected);
154
- }
155
- return [x, y, z];
156
- }
157
- /**
158
- * The seed: a finite number, or null (unseeded; 0 keeps the port's "0 = unseeded" quirk through the Lcg).
159
- * @param given - the value given (undefined = absent)
160
- * @param fallback - the previous record's value or the default
161
- * @returns the seed or null
162
- */
163
- function pickSeed(given, fallback) {
164
- if (given === undefined) {
165
- return fallback;
166
- }
167
- const value = given;
168
- if (value !== null && (typeof value !== "number" || !Number.isFinite(value))) {
169
- throw invalid("seed", given, "a finite number or null");
170
- }
171
- return value;
172
- }
173
- /**
174
- * Integer >= 1.
175
- * @param value - a finite number
176
- * @returns whether it is a positive integer
177
- */
178
- function isPositiveInteger(value) {
179
- return Number.isInteger(value) && value >= 1;
180
- }
181
- /**
182
- * The u32 word written into Fa2Params.seed: 0 for null, else floor(|seed|) mod 2^32.
183
- * @param seed - the resolved seed
184
- * @returns the u32 value
185
- */
186
- function seedWord(seed) {
187
- if (seed === null) {
188
- return 0;
189
- }
190
- return Math.floor(Math.abs(seed)) % U32_MODULUS;
191
- }
192
- /**
193
- * A scalar field of a block's read() result.
194
- * @param values - the values read
195
- * @param name - the field name
196
- * @returns the number
197
- */
198
- function scalar(values, name) {
199
- const value = values[name];
200
- if (typeof value !== "number") {
201
- throw invalid(name, value, "a scalar field");
202
- }
203
- return value;
204
- }
205
- /**
206
- * A vector field of a block's read() result.
207
- * @param values - the values read
208
- * @param name - the field name
209
- * @returns the components
210
- */
211
- function vector(values, name) {
212
- const value = values[name];
213
- if (typeof value === "number") {
214
- throw invalid(name, value, "a vector field");
215
- }
216
- return value;
217
- }
218
- /**
219
- * The override record a kernel gets: its defaults overlaid with the values present in the merged set (contract 3.9:
220
- * a name a spec does not declare is rejected at compose time, so nothing else is passed through).
221
- * @param merged - the merged override set of the model (plus USE_PERM / HAS_WEIGHTS from the simulation)
222
- * @param defaults - the kernel's accepted names with their defaults
223
- * @returns the kernel's override record, every accepted name explicit
224
- */
225
- function subset(merged, defaults) {
226
- const out = {};
227
- for (const name of Object.keys(defaults)) {
228
- out[name] = name in merged ? merged[name] : defaults[name];
229
- }
230
- return out;
231
- }
232
56
  /**
233
57
  * The K3 / K4 override values of a merged set (typed for RepulsionExact).
234
58
  * @param merged - the merged override set
@@ -241,6 +65,14 @@ function repulsionOverrides(merged) {
241
65
  GRAVITY_CENTER: merged.GRAVITY_CENTER === 1 ? 1 : 0,
242
66
  };
243
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
+ }
244
76
  // ============================================================ the resolvers
245
77
  /**
246
78
  * Applies FA2_DEFAULTS to the option record; validates ranges (spec 7.14; contract 3.13). With `previous` the record
@@ -285,8 +117,8 @@ export function resolveForceAtlas2Options(options, previous) {
285
117
  return Object.freeze(resolved);
286
118
  }
287
119
  /**
288
- * Applies LAYOUT_TUNING_DEFAULTS (spec 7.14); the grid knobs are validated and stored but only `repulsion`,
289
- * `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).
290
122
  * @param tuning - the GPU-only knobs given (any object carrying them, e.g. the createForceAtlas2 options)
291
123
  * @returns the frozen resolved tuning
292
124
  */
@@ -303,7 +135,7 @@ export function resolveLayoutTuning(tuning) {
303
135
  const resolved = {
304
136
  repulsion,
305
137
  exactMaxNodes: pickNumber("exactMaxNodes", t.exactMaxNodes, LAYOUT_TUNING_DEFAULTS.exactMaxNodes, isPositiveInteger, "an integer >= 1"),
306
- 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"),
307
139
  deterministic: pickBoolean("deterministic", t.deterministic, LAYOUT_TUNING_DEFAULTS.deterministic),
308
140
  gridMax2D: pickNumber("gridMax2D", t.gridMax2D, LAYOUT_TUNING_DEFAULTS.gridMax2D, isPositiveInteger, "an integer >= 1"),
309
141
  gridMax3D: pickNumber("gridMax3D", t.gridMax3D, LAYOUT_TUNING_DEFAULTS.gridMax3D, isPositiveInteger, "an integer >= 1"),
@@ -312,7 +144,7 @@ export function resolveLayoutTuning(tuning) {
312
144
  };
313
145
  return Object.freeze(resolved);
314
146
  }
315
- /** 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. */
316
148
  export class ForceAtlas2Model {
317
149
  /**
318
150
  * Creates the model for one simulation.
@@ -337,6 +169,8 @@ export class ForceAtlas2Model {
337
169
  this.bound = null;
338
170
  /** Armed by onLoad(): the next recordIteration zeroes oldForce first (paper mode). */
339
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;
340
174
  /**
341
175
  * The K1-K5 compute pass of the batch being recorded, keyed by CommandBatch.id (unique per batch): every
342
176
  * recordIteration of one batch dispatches into it (ONE pass per batch, contract 4.4); null between batches and
@@ -355,15 +189,18 @@ export class ForceAtlas2Model {
355
189
  }
356
190
  /**
357
191
  * force 12n and oldForce 12n (zeroed) in BOTH swing modes (3.10.1: a writable slot is never aliased; mode 1 leaves
358
- * oldForce unread and unwritten), plus the 256-byte FillParams uniform buffer the fill dispatches read. n = 0
359
- * 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).
360
196
  * @param n - the node count
361
- * @param _dim - the layout dimension (the force arrays are stride 3 in both)
362
- * @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
363
199
  */
364
- buffers(n, _dim) {
200
+ buffers(n, dim) {
365
201
  const bytes = Math.max(1, n) * FORCE_BYTES_PER_NODE;
366
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)) : [];
367
204
  return [
368
205
  { name: "force", byteLength: bytes, usage, zero: true },
369
206
  { name: "oldForce", byteLength: bytes, usage, zero: true },
@@ -373,29 +210,25 @@ export class ForceAtlas2Model {
373
210
  usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST,
374
211
  zero: false,
375
212
  },
213
+ { name: HUB_COUNTERS_BUFFER, byteLength: 16, usage, zero: true },
214
+ ...grid,
376
215
  ];
377
216
  }
378
217
  /**
379
- * { mass: resolveNodeMass(s, resolved.nodeMass), weights: resolveWeights(s, resolved.weight) } (3.13 inputs.ts),
380
- * after the tier rule of spec 7.8 / lead f: `repulsion: "grid"` is E_UNSUPPORTED { feature: "repulsion.grid" }
381
- * 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().
382
221
  * @param s - the snapshot being loaded
383
222
  * @param options - the simulation's current option record
384
223
  * @returns the per-load inputs
385
224
  */
386
225
  inputs(s, options) {
387
226
  const resolved = resolveForceAtlas2Options(options, this.current);
388
- const { repulsion, exactMaxNodes } = this.tuning;
389
227
  const n = s.nodeCount;
390
- if (repulsion === "grid" || (repulsion === "auto" && n > exactMaxNodes)) {
391
- throw new WebGpuGraphError("E_UNSUPPORTED", repulsion === "grid"
392
- ? 'repulsion: "grid" is not available yet (the grid tier lands in P4)'
393
- : `the graph has ${n} nodes, above exactMaxNodes ${exactMaxNodes}, and the grid tier lands in P4`, {
394
- feature: "repulsion.grid",
395
- hint: 'pass repulsion: "exact" (or raise exactMaxNodes) to run the exact tier at this size',
396
- });
397
- }
398
- 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;
399
232
  }
400
233
  /**
401
234
  * { LINLOG, DISTRIBUTED, TIER: 0, SWING_MODE: compat === "networkx" ? 1 : 0, STRONG_GRAVITY, GRAVITY_CENTER:
@@ -419,7 +252,9 @@ export class ForceAtlas2Model {
419
252
  }
420
253
  /**
421
254
  * The seven module specs of an override set in dispatch order -- K1, K2, K3, K4, K5, toScene, fill -- each with
422
- * 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).
423
258
  * @param overrides - the merged override set (the model's plus USE_PERM / HAS_WEIGHTS)
424
259
  * @param _subgroups - accepted for the ForceModel interface and unused: every reducing FA2 body carries
425
260
  * needs: ["subgroups"] in its registry entry and the composer picks the twin from caps.features (contract 4.3)
@@ -427,6 +262,9 @@ export class ForceAtlas2Model {
427
262
  */
428
263
  specs(overrides, _subgroups) {
429
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));
430
268
  return [
431
269
  kernelSpec("fa2-stats-finalize"),
432
270
  kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS)),
@@ -435,44 +273,56 @@ export class ForceAtlas2Model {
435
273
  kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS)),
436
274
  kernelSpec("fa2-to-scene"),
437
275
  kernelSpec("fill"),
276
+ ...grid,
438
277
  ];
439
278
  }
440
279
  /**
441
- * Compiles (through the cache) and binds every kernel against the buffers of this load(): K1, K2 (or the fill of
442
- * force when arcCount === 0), K3 + K4 through RepulsionExact, K5, toScene, and the fill of oldForce; writes the
443
- * 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.
444
285
  * @param resources - the graph, the shared and model buffers, the ring and the cache
445
286
  * @param overrides - the merged override set
446
287
  */
447
288
  async bind(resources, overrides) {
448
289
  this.dropBound();
449
290
  this.resources = resources;
450
- const { n, pipelines, caps, core, perm, ring, device } = resources;
291
+ const { n, pipelines, caps, core, ring, device } = resources;
451
292
  if (n === 0) {
452
293
  return;
453
294
  }
454
- 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([
455
300
  pipelines.kernel(kernelSpec("fa2-stats-finalize")),
456
- pipelines.kernel(kernelSpec("fa2-attraction", subset(overrides, K2_DEFAULTS))),
457
301
  pipelines.kernel(kernelSpec("fa2-integrate", subset(overrides, K5_DEFAULTS))),
458
302
  pipelines.kernel(kernelSpec("fa2-to-scene")),
459
303
  pipelines.kernel(kernelSpec("fill")),
460
304
  ]);
461
- 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;
462
314
  if (this.resources !== resources) {
463
315
  // a newer bind() superseded this one while the pipelines compiled; its own bind groups stand
316
+ grid?.dispose();
464
317
  return;
465
318
  }
466
- const pos = resources.buffer("positions");
467
319
  const scene = resources.buffer("scenePositions");
468
320
  const fixed = resources.buffer("fixed");
469
321
  const partials = resources.buffer("partials");
470
322
  const state = resources.buffer("state");
471
323
  const trace = resources.buffer("trace");
472
- const force = resources.buffer("force");
473
324
  const oldForce = resources.buffer("oldForce");
474
325
  const fillParamsBuffer = resources.buffer(FILL_PARAMS_BUFFER);
475
- const params = ring.binding(FA2_PARAMS);
476
326
  const fillParams = {
477
327
  buffer: fillParamsBuffer.buffer,
478
328
  offset: fillParamsBuffer.offset,
@@ -482,20 +332,41 @@ export class ForceAtlas2Model {
482
332
  const fillBytes = new ArrayBuffer(FILL_PARAMS.byteLength);
483
333
  FILL_PARAMS.write(new DataView(fillBytes), { count: 3 * n, value: 0, mode: 0 });
484
334
  device.queue.writeBuffer(fillParamsBuffer.buffer, fillParamsBuffer.offset, fillBytes);
485
- const hasArcs = core.colIdx !== null;
486
- 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
+ });
487
351
  const wg = k1.workgroupSize;
488
352
  this.bound = {
489
353
  n,
490
354
  plan: plan1d(n, wg, caps),
491
355
  fillPlan: plan1d(3 * n, wg, caps),
492
356
  k1,
493
- k1Bound: k1.bind({ partials, S: state, T: trace, P: params }),
494
- k2,
495
- k2Bound: hasArcs
496
- ? k2.bind({ ...graphBindings(core, perm, resources.weights), pos, force, P: params })
497
- : 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,
498
368
  repulsion,
369
+ grid,
499
370
  k5,
500
371
  k5Bound: k5.bind({ force, oldForce, fixedMask: fixed, S: state, pos, partials, P: params }),
501
372
  toScene,
@@ -513,14 +384,19 @@ export class ForceAtlas2Model {
513
384
  * @returns the uniform values
514
385
  */
515
386
  paramsFor(iteration, options) {
516
- const { n } = this.requireResources();
387
+ const { n, core, tiers, tier, dim } = this.requireResources();
517
388
  const resolved = resolveForceAtlas2Options(options, this.current);
518
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;
519
395
  return {
520
396
  n,
521
397
  dim: resolved.dim,
522
398
  flags: 0,
523
- tierStart: 0,
399
+ tierStart: midEnd,
524
400
  tierEnd: n,
525
401
  iterationIndex: iteration,
526
402
  seed: seedWord(resolved.seed),
@@ -532,33 +408,37 @@ export class ForceAtlas2Model {
532
408
  center: [resolved.center[0], resolved.center[1], resolved.center[2], 0],
533
409
  settleThreshold: resolved.settleThreshold,
534
410
  extentFactor,
535
- gridMax: 0,
536
- levels: 0,
537
- 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,
538
418
  };
539
419
  }
540
420
  /**
541
- * Records one iteration into the batch: K1, K2 (or the fill of force when arcCount === 0), K3, K4, K5 in the
542
- * batch's ONE K1-K5 compute pass (opened by the first call of a batch and reused by every later call with the
543
- * same batch.id, PLAN DECISION 2), then toScene in a second pass that ends it, stopping after stage `upTo` when
544
- * given (spec 7.4; debugRunStages / inspect, spec 11.9 item 2). The simulation passes "K5" for iterations
545
- * 0..k-2 and undefined for the last, so toScene runs once per batch. The first call after load() zeroes
546
- * oldForce before K1 (paper mode). With n === 0 nothing is recorded (PLAN DECISION 9); a call before bind()
547
- * 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).
548
434
  * @param batch - the batch being recorded
549
435
  * @param slot - the UniformRing slot holding this iteration's Fa2Params
550
- * @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)
551
437
  * @param upTo - a stage name to stop after; undefined records every stage including toScene
552
438
  */
553
439
  recordIteration(batch, slot, tier, upTo) {
554
- if (tier === "grid") {
555
- throw new WebGpuGraphError("E_UNSUPPORTED", "the grid repulsion tier lands in P4", {
556
- feature: "repulsion.grid",
557
- hint: 'pass repulsion: "exact"',
558
- });
559
- }
560
440
  const resources = this.requireResources();
561
- const stop = upTo === undefined ? FA2_STAGES.length - 1 : this.stageIndex(upTo);
441
+ const stop = upTo === undefined ? STAGE_TO_SCENE : this.stageIndex(upTo);
562
442
  const { bound } = this;
563
443
  if (bound === null) {
564
444
  if (resources.n === 0) {
@@ -569,47 +449,124 @@ export class ForceAtlas2Model {
569
449
  });
570
450
  }
571
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
+ }
572
462
  const pass = this.openPass !== null && this.openPass.id === batch.id ? this.openPass.pass : batch.pass("fa2");
573
463
  this.openPass = { id: batch.id, pass };
574
- if (this.resetOldForce) {
575
- this.resetOldForce = false;
576
- if (bound.fillOldBound !== null) {
577
- bound.fill.dispatch(pass, bound.fillOldBound, bound.fillPlan, [0]);
578
- }
579
- }
580
- bound.k1.dispatch(pass, bound.k1Bound, ONE_WORKGROUP, [offset]);
464
+ this.recordK1(pass, bound, offset);
581
465
  if (stop < 1) {
582
466
  return;
583
467
  }
584
- if (bound.k2Bound !== null) {
585
- bound.k2.dispatch(pass, bound.k2Bound, bound.plan, [offset]);
468
+ this.recordK2(pass, bound, offset);
469
+ if (stop < 2) {
470
+ return;
471
+ }
472
+ repulsion.recordRepulsion(pass, bound.n, offset);
473
+ if (stop < STAGE_K4) {
474
+ return;
586
475
  }
587
- else if (bound.fillForceBound !== null) {
588
- bound.fill.dispatch(pass, bound.fillForceBound, bound.fillPlan, [0]);
476
+ repulsion.recordSpeedFinalize(pass, offset);
477
+ if (stop < STAGE_K5) {
478
+ return;
589
479
  }
590
- if (stop < 2) {
480
+ bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
481
+ if (stop < STAGE_TO_SCENE) {
482
+ return;
483
+ }
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) {
591
507
  return;
592
508
  }
593
- bound.repulsion.recordRepulsion(pass, bound.n, offset);
594
- if (stop < 3) {
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) {
595
513
  return;
596
514
  }
597
- bound.repulsion.recordSpeedFinalize(pass, offset);
598
- if (stop < 4) {
515
+ grid.recordSpeedFinalize(pass, offset);
516
+ if (stop < STAGE_K5) {
599
517
  return;
600
518
  }
601
519
  bound.k5.dispatch(pass, bound.k5Bound, bound.plan, [offset]);
602
- if (stop < 5) {
520
+ if (stop < STAGE_TO_SCENE) {
603
521
  return;
604
522
  }
605
- // 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) {
606
561
  this.openPass = null;
607
562
  const scenePass = batch.pass("fa2-to-scene");
608
563
  bound.toScene.dispatch(scenePass, bound.toSceneBound, bound.plan, [offset]);
609
564
  }
610
565
  /**
611
566
  * speed = 1, speedEfficiency = 1, swing = 1, traction = 1 (mode 1 accumulates from 1; mode 0 overwrites them each
612
- * 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.
613
570
  * @param state - the state writer of the simulation
614
571
  */
615
572
  onLoad(state) {
@@ -618,6 +575,9 @@ export class ForceAtlas2Model {
618
575
  state.set("swing", 1);
619
576
  state.set("traction", 1);
620
577
  this.resetOldForce = true;
578
+ if (this.nextGrid !== null) {
579
+ writeGridFrame(state, this.nextGrid, this.tuning.extentFactor);
580
+ }
621
581
  }
622
582
  /**
623
583
  * Mode 0: nothing (D8). Mode 1 (networkx): swing = traction = 1 (spec 7.2 "load() and reheat() reset them to 1").
@@ -651,7 +611,9 @@ export class ForceAtlas2Model {
651
611
  }
652
612
  /**
653
613
  * Decodes the state header and the k trace records of a completed batch (k = trace.byteLength / 32) into
654
- * 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).
655
617
  * @param state - a DataView over the 256-byte state header
656
618
  * @param trace - a DataView over the k Fa2Trace records of the batch
657
619
  * @returns the stats
@@ -672,15 +634,16 @@ export class ForceAtlas2Model {
672
634
  settledCount: scalar(record, "settledCount"),
673
635
  });
674
636
  }
637
+ const grid = this.resources?.tier === "grid";
675
638
  return {
676
639
  iteration: scalar(header, "iteration"),
677
640
  meanDisplacement: scalar(header, "meanDisplacement"),
678
641
  rmsRadius: scalar(header, "rmsRadius"),
679
642
  layoutRadius: scalar(header, "radius"),
680
643
  centroid: [centroid[0], centroid[1], centroid[2]],
681
- repulsionTier: "exact",
682
- maxCellOccupancy: null,
683
- outsideGrid: null,
644
+ repulsionTier: grid ? "grid" : "exact",
645
+ maxCellOccupancy: grid ? scalar(header, "maxCellOccupancy") : null,
646
+ outsideGrid: grid ? scalar(header, "outsideGrid") : null,
684
647
  msPerIteration: null,
685
648
  swing: scalar(header, "swing"),
686
649
  traction: scalar(header, "traction"),
@@ -714,10 +677,17 @@ export class ForceAtlas2Model {
714
677
  }
715
678
  throw invalid("upTo", upTo, FA2_STAGES.join(" | "));
716
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
+ }
717
687
  /**
718
688
  * Drops the bind groups of the previous bind() (the buffers changed) so the cached kernels do not accumulate stale
719
- * groups across reloads; K3 / K4 live inside RepulsionExact and keep the P1-T6 behaviour. Also forgets the pass
720
- * 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.
721
691
  */
722
692
  dropBound() {
723
693
  this.openPass = null;
@@ -725,12 +695,48 @@ export class ForceAtlas2Model {
725
695
  if (bound === null) {
726
696
  return;
727
697
  }
728
- 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 ?? []) {
729
702
  kernel.invalidate();
730
703
  }
704
+ bound.grid?.dispose();
731
705
  this.bound = null;
732
706
  }
733
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
+ }
734
740
  // ============================================================ the factory
735
741
  /**
736
742
  * The resolve callback of the simulation's setParams: the patch over the current record, re-validated (the current
@@ -743,8 +749,9 @@ function resolvePatch(patch, current) {
743
749
  return resolveForceAtlas2Options(patch, resolveForceAtlas2Options(current));
744
750
  }
745
751
  /**
746
- * Spec 3.3 createForceAtlas2, verbatim: a GpuLayoutSimulation running ForceAtlas2 on the exact repulsion tier with
747
- * 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").
748
755
  * @param ctx - the context (E_DISPOSED / E_DEVICE_LOST through assertReady)
749
756
  * @param options - the ForceAtlas2 options and the GPU-only tuning knobs in one record
750
757
  * @returns the simulation in state "created"; load() next