diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index 81ae996203..a772268558 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -15,8 +15,9 @@ represent their relationships. `@luma.gl/experimental/lugraph` answers these questions directly on a browser WebGPU device. It describes caller-owned GPU edge columns, builds reusable compressed adjacency, and publishes vertex degrees, shortest-path neighborhoods, weakly connected groups, PageRank importance, and progressive -two-dimensional graph layouts into caller-owned GPU buffers. Every operation composes with the -existing `GPUCommandGraph`. +two-dimensional graph layouts into caller-owned GPU buffers. Layout can evaluate every repulsive +interaction exactly or explicitly approximate distant groups through a caller-owned uniform grid. +Every operation composes with the existing `GPUCommandGraph`. This is an experimental, headless graph analytics API, not a graph database, visualization framework, file importer, or general-purpose dataframe. Applications decide how data reaches the @@ -113,8 +114,10 @@ combine graph analytics with further GPU work: Choose another tool when the application needs weighted shortest paths, a graph query language, automatic CPU fallback, distributed execution, or compatibility with a CUDA or Python graph API. Exact force-directed layout also becomes expensive on very large graphs because it evaluates every -pair of vertices. luGraph currently operates on one browser WebGPU device and intentionally does -not provide those features or an approximate layout method. +pair of vertices. An optional spatial layout can exchange some far-field accuracy for fewer +individual repulsion calculations, but its flat grid does not guarantee subquadratic complexity or +make arbitrary graph sizes interactive. luGraph operates on one browser WebGPU device and does not +provide the other unsupported features above. ## Choose the right graph operation @@ -127,9 +130,12 @@ not provide those features or an approximate layout method. | `LuGraphConnectedComponents` | Which vertices belong to the same weakly connected group? | One `uint32` component identifier per vertex | At most `O(K × (V + E))` for `K` bounded iterations | | `LuGraphPageRank` | Which vertices receive influence from other important vertices? | One normalized `float32` score per vertex | `O(K × (V + E))` for `K` iterations | | `LuGraphForceLayout` | How can related vertices be positioned as a readable network? | Directly renderable `float32x2` positions and persistent velocities | `O(V² + E)` per exact force iteration | +| `LuGraphSpatialForceLayout` | Can distant graph regions be approximated while nearby relationships remain exact? | The existing renderable layout positions plus explicit uniform-grid diagnostics | `Θ(V × G + P + E)` per spatial force iteration | -`V` is the graph's explicit vertex count and `E` is its source-edge count. Undirected adjacency -contains both directions for ordinary edges; an undirected self-loop appears once. +`V` is the graph's explicit vertex count, `E` is its source-edge count, `G` is the uniform-grid +cell count, and `P` counts individual interactions in near or insufficiently distant cells. +Undirected adjacency contains both directions for ordinary edges; an undirected self-loop appears +once. ## Describe existing relationships with LuGraph @@ -433,11 +439,121 @@ it for very large networks, already meaningful geographic coordinates, or applic weighted springs. This implementation does not approximate pairwise interactions and does not claim to implement ForceAtlas2 or Barnes–Hut. +## Approximate distant forces with LuGraphSpatialForceLayout + +**Question: How can I make a larger relationship map easier to explore when exact repulsion spends +too much time comparing every individual vertex?** + +`LuGraphSpatialForceLayout` adds an explicitly approximate, opt-in execution path around an existing +`LuGraphForceLayout`. Imagine looking across a city: nearby pedestrians need individual attention, +but a distant crowd can often be treated as one group at its average position. The spatial layout +divides the current drawing area into a regular grid, calculates nearby forces exactly, and +represents sufficiently distant occupied cells by their population and center of mass. + +Use it when an interactive dependency map, social network, transaction investigation, or citation +visualization already owns GPU-resident graph data and can trade a bounded amount of visual +accuracy for fewer individual far-field calculations. Keep the exact layout when every pairwise +force must be reproducible, the network is small enough that index construction costs more than +it saves, vertices are too concentrated to benefit from grouping, or meaningful fixed coordinates +should not be replaced by a force-directed arrangement. + +```ts +import { + LuGraphForceLayout, + LuGraphSpatialForceLayout +} from '@luma.gl/experimental/lugraph'; + +const layout = new LuGraphForceLayout({ + topology, + positions: nodePositions, + velocities: nodeVelocities, + pinned: pinnedVertices, + reset: resetRequested, + iterationsPerFrame: 4 +}); + +const spatialLayout = new LuGraphSpatialForceLayout({ + layout, + gridSize: [32, 32], + bounds: [-4, -4, 4, 4], + theta: 0.6, + nearCellRadius: 1, + cellOffsets: spatialCellOffsets, + vertexIds: spatialVertexIds, + cellCenters: spatialCellCenters, + count: indexedVertexCount, + overflow: spatialIndexOverflow +}); + +spatialLayout.addToGraph(workflow); +``` + +Add either `layout` or `spatialLayout` to a workflow, not both: the spatial contributor advances +the same base positions and velocities itself. Existing directed and undirected spring behavior, +deterministic resets, pinned vertices, velocity limits, progressive warm starts, and directly +renderable position buffers remain intact. Directed attraction still requires reverse adjacency; +existing edge-weight columns remain intentionally unused by the unweighted spring model. + +### Accuracy and spatial controls + +The source vertex's own cell and every cell within `nearCellRadius` use exact, individual vertex +interactions. This neighborhood is a square measured in grid cells: the default radius `1` covers +the source cell and up to eight surrounding cells. Other occupied cells are approximated only when +`cellDiagonal / distanceToCellCenter < theta`; cells that fail that test still contribute all of +their individual interactions. No distant vertex is silently dropped. + +The default `theta: 0.6` controls the speed-versus-accuracy tradeoff. Larger values accept more +distant cell approximations and can increase layout error; smaller values require a cell to be +farther away before its population-weighted center of mass can represent its contents. Set +`theta: 0` to disable every approximation and recover exact all-pairs repulsion while retaining +the explicit grid rebuild and cell-scan overhead. Increasing `nearCellRadius` expands the exact +neighborhood and can also prevent approximation across the entire grid. + +This implementation is a **flat uniform-grid monopole approximation**, not hierarchical +Barnes–Hut, ForceAtlas2, an adaptive tree, or a claim of million-vertex throughput. It computes +cell centers from grouped vertex identifiers without floating-point atomics. + +### Bounds, buffers, and failure behavior + +`gridSize: [columns, rows]` creates `G = columns × rows` equally sized cells inside the explicit, +inclusive `bounds: [minimumX, minimumY, maximumX, maximumY]`. The application supplies five +packed, single-chunk GPU vectors with physically distinct buffer allocations: + +- `cellOffsets`: `GPUVector<'uint32'>` with exactly `G + 1` rows. +- `vertexIds`: `GPUVector<'uint32'>` with caller-selected indexing capacity; allow at least `V` + rows to accept every vertex without overflow. +- `cellCenters`: `GPUVector<'float32x2'>` with exactly `G` rows. +- `count`: a one-row `GPUVector<'uint32'>` reporting accepted in-domain vertices. +- `overflow`: a one-row `GPUVector<'uint32'>` signaling insufficient `vertexIds` capacity. + +The GPU rebuilds these caller-owned buffers on every spatial force iteration because vertices can +cross cell boundaries as the layout moves. Choose bounds that include every current coordinate, +deterministic reset positions in `[-1, 1]`, and sufficient room for future movement. Bounds do not +expand automatically; a vertex outside the domain makes `count` smaller than `vertexCount` even +when indexing capacity is sufficient. + +If any vertex is outside the bounds, the index overflows, or required forward/reverse adjacency +overflows, the spatial step fails closed: it preserves every existing position and clears all +velocities. Counts and overflow flags remain explicit GPU-resident outputs until an application +deliberately reads them back. Caller-owned layout and grid allocations are never destroyed or +silently replaced. + +### Cost and when acceleration helps + +Every vertex still scans every grid cell, even empty cells. With `V` vertices, `G` cells, `P` +individual near-field or rejected-far-field interactions, and `E` edges, each iteration performs +`Θ(V × G + P + E)` work plus one grid rebuild and uses `Θ(V + G)` caller-owned grid storage. +A sensible grid can reduce the number of individual interactions when vertices are distributed +across well-separated regions, but an oversized grid wastes scans and a crowded grid cell +restores pairwise work. The worst case can return to `Θ(V² + E)`; a grid with more cells than +vertices can be even more expensive. Measure the application's actual graph distribution, +index-rebuild cost, accuracy, and frame budget before choosing this path. + ## Compose one GPU-resident workflow All graph contributors add work to the same caller-owned `GPUCommandGraph`. The following example -assumes that the source columns, packed result vectors, and one-row status vectors already exist -on the same WebGPU device: +assumes that the source columns, packed result vectors, spatial index buffers, and one-row status +vectors already exist on the same WebGPU device: ```ts import {GPUCommandGraph} from '@luma.gl/experimental'; @@ -448,6 +564,7 @@ import { LuGraphDegree, LuGraphForceLayout, LuGraphPageRank, + LuGraphSpatialForceLayout, LuGraphTopology } from '@luma.gl/experimental/lugraph'; @@ -503,13 +620,25 @@ new LuGraphPageRank({ iterations: 40, residual: finalRankChange }).addToGraph(workflow); -new LuGraphForceLayout({ +const layout = new LuGraphForceLayout({ topology, positions: nodePositions, velocities: nodeVelocities, pinned: pinnedVertices, reset: resetRequested, iterationsPerFrame: 4 +}); +new LuGraphSpatialForceLayout({ + layout, + gridSize: [32, 32], + bounds: [-4, -4, 4, 4], + theta: 0.6, + nearCellRadius: 1, + cellOffsets: spatialCellOffsets, + vertexIds: spatialVertexIds, + cellCenters: spatialCellCenters, + count: indexedVertexCount, + overflow: spatialIndexOverflow }).addToGraph(workflow); const compiled = workflow.compile(); @@ -522,7 +651,8 @@ Constructors validate existing metadata; they do not upload graph data, submit c results. `addToGraph()` declares GPU work, `compile()` resolves the workflow, and the application explicitly encodes and submits it. Re-encoding rebuilds topology and recomputes the declared results from the current source and control buffers while progressively advancing the existing -layout positions and velocities. +layout positions and velocities. Replace the spatial contributor with `layout.addToGraph(workflow)` +when exact all-pairs repulsion is the better fit. ## Ownership, capacity, and failure boundaries @@ -534,6 +664,8 @@ layout positions and velocities. unreachable distances, weak components publish `0xffffffff`, and PageRank publishes zero scores when a required neighbor list overflowed. Force layout preserves its existing positions and clears velocities on required adjacency overflow. +- Spatial layout also preserves positions and clears velocities when its accepted count excludes + any out-of-domain vertex or its explicit vertex-ID capacity overflows. - Degree remains exact under neighbor overflow because its input is the complete CSR offset range. - Renderable layout positions require both `Buffer.STORAGE` and `Buffer.VERTEX` usage on their original caller-owned allocation; position readback or repacking is never implicit. diff --git a/modules/experimental/src/lugraph/README.md b/modules/experimental/src/lugraph/README.md index a75c1deb88..4bfee4966d 100644 --- a/modules/experimental/src/lugraph/README.md +++ b/modules/experimental/src/lugraph/README.md @@ -1,14 +1,28 @@ # @luma.gl/experimental/lugraph +## Overview + `@luma.gl/experimental/lugraph` analyzes connected data directly on a browser WebGPU device. Its optional, headless graph model preserves existing source and target vertex columns, stable edge identifiers, optional properties, and original GPU vector chunks without uploading or copying them. Reusable compressed adjacency supports vertex-degree queries, bounded breadth-first shortest paths, weakly connected components, normalized PageRank with dangling-vertex redistribution, and -progressive exact force-directed layout with directly renderable GPU positions. Those operations -contribute work to a caller-owned `GPUCommandGraph`; applications retain ownership of their buffers, -rendering, command submission, and any explicitly requested result readback. +progressive exact force-directed layout with directly renderable GPU positions. An optional +`LuGraphSpatialForceLayout` can approximate distant uniform-grid cells while keeping nearby forces +exact; it preserves the same positions, explicit bounds, caller-owned indexing buffers, and +observable overflow status. These operations contribute work to a caller-owned `GPUCommandGraph`; +applications retain ownership of their buffers, rendering, command submission, and any explicitly +requested result readback. + +## When to use luGraph + +Use luGraph to explore relationships that already live on the GPU: inspect social connections, +trace service dependencies, investigate transaction networks, or rank linked documents without +copying every intermediate result back to JavaScript. Exact layout suits smaller graphs and +accuracy-sensitive workflows; the optional flat-grid approximation suits applications that can +trade some far-field accuracy for fewer individual force calculations. It is not Barnes–Hut, +ForceAtlas2, or a guaranteed subquadratic layout. See the [luGraph graph analytics guide](/docs/api-reference/experimental/lugraph) for when to use each operation, complete GPU-resident composition examples, and ownership and capacity contracts. diff --git a/modules/experimental/src/lugraph/index.ts b/modules/experimental/src/lugraph/index.ts index dc0f95b60c..f15acff580 100644 --- a/modules/experimental/src/lugraph/index.ts +++ b/modules/experimental/src/lugraph/index.ts @@ -20,3 +20,5 @@ export {LuGraphPageRank} from './lu-graph-page-rank'; export type {LuGraphPageRankProps} from './lu-graph-page-rank'; export {LuGraphForceLayout} from './lu-graph-force-layout'; export type {LuGraphForceLayoutProps} from './lu-graph-force-layout'; +export {LuGraphSpatialForceLayout} from './lu-graph-spatial-force-layout'; +export type {LuGraphSpatialForceLayoutProps} from './lu-graph-spatial-force-layout'; diff --git a/modules/experimental/src/lugraph/lu-graph-spatial-force-layout-internals.ts b/modules/experimental/src/lugraph/lu-graph-spatial-force-layout-internals.ts new file mode 100644 index 0000000000..a23c558fb7 --- /dev/null +++ b/modules/experimental/src/lugraph/lu-graph-spatial-force-layout-internals.ts @@ -0,0 +1,738 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors +// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph. + +import {type Binding} from '@luma.gl/core'; +import {Computation} from '@luma.gl/engine'; +import type { + GPUCommandGraph, + GraphBufferUse, + GraphDataView +} from '../gpu-primitives/gpu-command-graph'; +import { + type GPUBoundedDispatchLayout, + getBoundedDispatchLayout, + getBoundedInvocationIndexSource +} from '../gpu-primitives/gpu-dispatch-utils'; +import {GPUGridIndex} from '../gpu-primitives/gpu-grid-index'; +import {addGPUGridIndexToGraphWithDispatchLimit} from '../gpu-primitives/gpu-grid-index-internals'; +import { + createTransientView, + getViewBinding, + getViewElementOffset +} from '../gpu-primitives/graph-data-view-utils'; +import type {LuGraphSpatialForceLayout} from './lu-graph-spatial-force-layout'; + +const SPATIAL_FORCE_WORKGROUP_SIZE = 256; +const MINIMUM_REPULSION_DISTANCE_SQUARED = 0.0001; + +type SpatialDataView = GraphDataView<'uint32'> | GraphDataView<'float32x2'>; + +type ImportedSpatialLayout = { + id: string; + vertexCount: number; + cellCount: number; + gridSize: readonly [number, number]; + bounds: readonly [number, number, number, number]; + theta: number; + nearCellRadius: number; + seed: number; + repulsion: number; + attraction: number; + gravity: number; + damping: number; + maxVelocity: number; + timeStep: number; + positions: GraphDataView<'float32x2'>; + velocities: GraphDataView<'float32x2'>; + pinned?: GraphDataView<'uint32'>; + reset?: GraphDataView<'uint32'>; + forwardOffsets: GraphDataView<'uint32'>; + forwardNeighbors: GraphDataView<'uint32'>; + forwardOverflow: GraphDataView<'uint32'>; + reverseOffsets?: GraphDataView<'uint32'>; + reverseNeighbors?: GraphDataView<'uint32'>; + reverseOverflow?: GraphDataView<'uint32'>; + cellOffsets: GraphDataView<'uint32'>; + vertexIds: GraphDataView<'uint32'>; + cellCenters: GraphDataView<'float32x2'>; + count: GraphDataView<'uint32'>; + overflow: GraphDataView<'uint32'>; + validity: GraphDataView<'uint32'>; + maxComputeWorkgroupsPerDimension: number; +}; + +type SpatialBinding = { + view: SpatialDataView; + usage: GraphBufferUse['usage']; +}; + +type SpatialPassProps = { + id: string; + source: string; + bindings: Record; + dispatchLayout: GPUBoundedDispatchLayout; +}; + +/** Adds honest near-exact/far-monopole force integration with bounded dispatch. @internal */ +export function addLuGraphSpatialForceLayoutToGraphWithDispatchLimit( + spatial: LuGraphSpatialForceLayout, + commandGraph: GPUCommandGraph, + maxComputeWorkgroupsPerDimension: number +): void { + const layout = spatial.layout; + const reverse = layout.topology.reverse; + const state: ImportedSpatialLayout = { + id: spatial.id, + vertexCount: layout.topology.graph.vertexCount, + cellCount: spatial.cellCount, + gridSize: spatial.gridSize, + bounds: spatial.bounds, + theta: spatial.theta, + nearCellRadius: spatial.nearCellRadius, + seed: layout.seed, + repulsion: layout.repulsion, + attraction: layout.attraction, + gravity: layout.gravity, + damping: layout.damping, + maxVelocity: layout.maxVelocity, + timeStep: layout.timeStep, + positions: commandGraph.importGPUVector(`${spatial.id}-positions`, layout.positions).data[0], + velocities: commandGraph.importGPUVector(`${spatial.id}-velocities`, layout.velocities).data[0], + ...(layout.pinned + ? {pinned: commandGraph.importGPUVector(`${spatial.id}-pinned`, layout.pinned).data[0]} + : {}), + ...(layout.reset + ? {reset: commandGraph.importGPUVector(`${spatial.id}-reset`, layout.reset).data[0]} + : {}), + forwardOffsets: commandGraph.importGPUVector( + `${spatial.id}-forward-offsets`, + layout.topology.forward.offsets + ).data[0], + forwardNeighbors: commandGraph.importGPUVector( + `${spatial.id}-forward-neighbors`, + layout.topology.forward.neighbors + ).data[0], + forwardOverflow: commandGraph.importGPUVector( + `${spatial.id}-forward-overflow`, + layout.topology.forward.overflow + ).data[0], + ...(layout.topology.graph.directed && reverse + ? { + reverseOffsets: commandGraph.importGPUVector( + `${spatial.id}-reverse-offsets`, + reverse.offsets + ).data[0], + reverseNeighbors: commandGraph.importGPUVector( + `${spatial.id}-reverse-neighbors`, + reverse.neighbors + ).data[0], + reverseOverflow: commandGraph.importGPUVector( + `${spatial.id}-reverse-overflow`, + reverse.overflow + ).data[0] + } + : {}), + cellOffsets: commandGraph.importGPUVector(`${spatial.id}-cell-offsets`, spatial.cellOffsets) + .data[0], + vertexIds: commandGraph.importGPUVector(`${spatial.id}-vertex-ids`, spatial.vertexIds).data[0], + cellCenters: commandGraph.importGPUVector(`${spatial.id}-cell-centers`, spatial.cellCenters) + .data[0], + count: commandGraph.importGPUVector(`${spatial.id}-index-count`, spatial.count).data[0], + overflow: commandGraph.importGPUVector(`${spatial.id}-index-overflow`, spatial.overflow) + .data[0], + validity: createTransientView(commandGraph, `${spatial.id}-validity`, 'uint32', 1), + maxComputeWorkgroupsPerDimension + }; + + if (state.reset && state.vertexCount > 0) { + addInitializationPass(commandGraph, state); + } + if (state.reset) { + addResetClearPass(commandGraph, state); + } + + const iterationCount = state.vertexCount === 0 ? 1 : layout.iterationsPerFrame; + for (let iteration = 0; iteration < iterationCount; iteration++) { + const index = new GPUGridIndex({ + id: `${state.id}-iteration-${iteration}-index`, + positions: state.positions, + gridSize: state.gridSize, + bounds: state.bounds, + cellOffsets: state.cellOffsets, + objectIds: state.vertexIds, + count: state.count, + overflow: state.overflow + }); + addGPUGridIndexToGraphWithDispatchLimit( + index, + commandGraph, + state.maxComputeWorkgroupsPerDimension + ); + addValidityPass(commandGraph, {state, iteration}); + addCellCenterPass(commandGraph, {state, iteration}); + if (state.vertexCount > 0) { + addRepulsionPass(commandGraph, {state, iteration}); + addAttractionPass(commandGraph, {state, iteration}); + addIntegrationPass(commandGraph, {state, iteration}); + } + } +} + +/** Preserves the exact-layout reset hash, pinned coordinates, and topology overflow contract. */ +function addInitializationPass( + commandGraph: GPUCommandGraph, + state: ImportedSpatialLayout +): void { + const reset = state.reset!; + const bindings: Record = { + positions: {view: state.positions, usage: 'storage-read-write'}, + velocities: {view: state.velocities, usage: 'storage-write'}, + reset: {view: reset, usage: 'storage-read'}, + forwardOverflow: {view: state.forwardOverflow, usage: 'storage-read'}, + ...(state.reverseOverflow + ? {reverseOverflow: {view: state.reverseOverflow, usage: 'storage-read'}} + : {}), + ...(state.pinned ? {pinned: {view: state.pinned, usage: 'storage-read'}} : {}) + }; + const reverseOffset = state.reverseOverflow + ? `const REVERSE_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.reverseOverflow)}u;` + : ''; + const pinnedOffset = state.pinned + ? `const PINNED_OFFSET: u32 = ${getViewElementOffset(state.pinned)}u;` + : ''; + const reverseOverflow = state.reverseOverflow + ? ' || reverseOverflow[REVERSE_OVERFLOW_OFFSET] != 0u' + : ''; + const pinned = state.pinned ? ' || pinned[PINNED_OFFSET + index] != 0u' : ''; + const dispatchLayout = getSpatialDispatchLayout(state, state.vertexCount); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const SEED: u32 = ${state.seed}u; +const POSITIONS_OFFSET: u32 = ${getViewElementOffset(state.positions)}u; +const VELOCITIES_OFFSET: u32 = ${getViewElementOffset(state.velocities)}u; +const RESET_OFFSET: u32 = ${getViewElementOffset(reset)}u; +const FORWARD_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.forwardOverflow)}u; +${reverseOffset} +${pinnedOffset} +${getBindingDeclarations(bindings)} + +fn hash(value: u32) -> u32 { + var result = value; + result ^= result >> 16u; + result *= 0x7feb352du; + result ^= result >> 15u; + result *= 0x846ca68bu; + result ^= result >> 16u; + return result; +} + +@compute @workgroup_size(${SPATIAL_FORCE_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, SPATIAL_FORCE_WORKGROUP_SIZE)} + if (index >= VERTEX_COUNT || reset[RESET_OFFSET] == 0u) { return; } + let velocityOffset = VELOCITIES_OFFSET + index * 2u; + velocities[velocityOffset] = 0.0; + velocities[velocityOffset + 1u] = 0.0; + if (forwardOverflow[FORWARD_OVERFLOW_OFFSET] != 0u${reverseOverflow}${pinned}) { return; } + let first = hash(SEED ^ (index * 2u)); + let second = hash(SEED ^ (index * 2u + 1u)); + let positionOffset = POSITIONS_OFFSET + index * 2u; + positions[positionOffset] = f32(first & 0x00ffffffu) / 16777216.0 * 2.0 - 1.0; + positions[positionOffset + 1u] = f32(second & 0x00ffffffu) / 16777216.0 * 2.0 - 1.0; +}`; + addSpatialPass(commandGraph, {id: `${state.id}-initialize`, source, bindings, dispatchLayout}); +} + +/** Consumes a one-shot reset request only after every initialization invocation completes. */ +function addResetClearPass( + commandGraph: GPUCommandGraph, + state: ImportedSpatialLayout +): void { + const reset = state.reset!; + const bindings: Record = { + reset: {view: reset, usage: 'storage-write'} + }; + const dispatchLayout = getSpatialDispatchLayout(state, 1); + const source = /* wgsl */ ` +const RESET_OFFSET: u32 = ${getViewElementOffset(reset)}u; +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${SPATIAL_FORCE_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, SPATIAL_FORCE_WORKGROUP_SIZE)} + if (index == 0u) { reset[RESET_OFFSET] = 0u; } +}`; + addSpatialPass(commandGraph, {id: `${state.id}-clear-reset`, source, bindings, dispatchLayout}); +} + +/** Combines exact topology and complete in-domain index status into one portable binding. */ +function addValidityPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedSpatialLayout; iteration: number} +): void { + const {state} = props; + const bindings: Record = { + count: {view: state.count, usage: 'storage-read'}, + indexOverflow: {view: state.overflow, usage: 'storage-read'}, + forwardOverflow: {view: state.forwardOverflow, usage: 'storage-read'}, + validity: {view: state.validity, usage: 'storage-write'}, + ...(state.reverseOverflow + ? {reverseOverflow: {view: state.reverseOverflow, usage: 'storage-read'}} + : {}) + }; + const reverseOffset = state.reverseOverflow + ? `const REVERSE_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.reverseOverflow)}u;` + : ''; + const reverseGuard = state.reverseOverflow + ? ' && reverseOverflow[REVERSE_OVERFLOW_OFFSET] == 0u' + : ''; + const dispatchLayout = getSpatialDispatchLayout(state, 1); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const COUNT_OFFSET: u32 = ${getViewElementOffset(state.count)}u; +const INDEX_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.overflow)}u; +const FORWARD_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.forwardOverflow)}u; +const VALIDITY_OFFSET: u32 = ${getViewElementOffset(state.validity)}u; +${reverseOffset} +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${SPATIAL_FORCE_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, SPATIAL_FORCE_WORKGROUP_SIZE)} + if (index != 0u) { return; } + let isValid = + count[COUNT_OFFSET] == VERTEX_COUNT && + indexOverflow[INDEX_OVERFLOW_OFFSET] == 0u && + forwardOverflow[FORWARD_OVERFLOW_OFFSET] == 0u${reverseGuard}; + validity[VALIDITY_OFFSET] = select(0u, 1u, isValid); +}`; + addSpatialPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-validate`, + source, + bindings, + dispatchLayout + }); +} + +/** Computes each exact cell center sequentially without floating-point atomic operations. */ +function addCellCenterPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedSpatialLayout; iteration: number} +): void { + const {state} = props; + const bindings: Record = { + positions: {view: state.positions, usage: 'storage-read'}, + cellOffsets: {view: state.cellOffsets, usage: 'storage-read'}, + vertexIds: {view: state.vertexIds, usage: 'storage-read'}, + cellCenters: {view: state.cellCenters, usage: 'storage-write'}, + validity: {view: state.validity, usage: 'storage-read'} + }; + const dispatchLayout = getSpatialDispatchLayout(state, state.cellCount); + const source = /* wgsl */ ` +const CELL_COUNT: u32 = ${state.cellCount}u; +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const CAPACITY: u32 = ${state.vertexIds.length}u; +const POSITIONS_OFFSET: u32 = ${getViewElementOffset(state.positions)}u; +const CELL_OFFSETS_OFFSET: u32 = ${getViewElementOffset(state.cellOffsets)}u; +const VERTEX_IDS_OFFSET: u32 = ${getViewElementOffset(state.vertexIds)}u; +const CELL_CENTERS_OFFSET: u32 = ${getViewElementOffset(state.cellCenters)}u; +const VALIDITY_OFFSET: u32 = ${getViewElementOffset(state.validity)}u; +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${SPATIAL_FORCE_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, SPATIAL_FORCE_WORKGROUP_SIZE)} + if (index >= CELL_COUNT) { return; } + var center = vec2(0.0); + var mass = 0u; + if (validity[VALIDITY_OFFSET] != 0u) { + let first = min(cellOffsets[CELL_OFFSETS_OFFSET + index], CAPACITY); + let last = min(cellOffsets[CELL_OFFSETS_OFFSET + index + 1u], CAPACITY); + for (var slot = first; slot < last; slot++) { + let vertex = vertexIds[VERTEX_IDS_OFFSET + slot]; + if (vertex < VERTEX_COUNT) { + let positionOffset = POSITIONS_OFFSET + vertex * 2u; + center += vec2(positions[positionOffset], positions[positionOffset + 1u]); + mass++; + } + } + } + if (mass > 0u) { center /= f32(mass); } + let centerOffset = CELL_CENTERS_OFFSET + index * 2u; + cellCenters[centerOffset] = center.x; + cellCenters[centerOffset + 1u] = center.y; +}`; + addSpatialPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-cell-centers`, + source, + bindings, + dispatchLayout + }); +} + +/** Visits every occupied cell, retaining exact near-field and accepted far-field monopoles. */ +function addRepulsionPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedSpatialLayout; iteration: number} +): void { + const {state} = props; + const bindings: Record = { + positions: {view: state.positions, usage: 'storage-read'}, + velocities: {view: state.velocities, usage: 'storage-read-write'}, + cellOffsets: {view: state.cellOffsets, usage: 'storage-read'}, + vertexIds: {view: state.vertexIds, usage: 'storage-read'}, + cellCenters: {view: state.cellCenters, usage: 'storage-read'}, + validity: {view: state.validity, usage: 'storage-read'} + }; + const dispatchLayout = getSpatialDispatchLayout(state, state.vertexCount); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const CELL_COUNT: u32 = ${state.cellCount}u; +const WIDTH: u32 = ${state.gridSize[0]}u; +const HEIGHT: u32 = ${state.gridSize[1]}u; +const CAPACITY: u32 = ${state.vertexIds.length}u; +const NEAR_CELL_RADIUS: u32 = ${state.nearCellRadius}u; +const MINIMUM_X: f32 = ${getFloatLiteral(state.bounds[0])}; +const MINIMUM_Y: f32 = ${getFloatLiteral(state.bounds[1])}; +const MAXIMUM_X: f32 = ${getFloatLiteral(state.bounds[2])}; +const MAXIMUM_Y: f32 = ${getFloatLiteral(state.bounds[3])}; +const THETA: f32 = ${getFloatLiteral(state.theta)}; +const REPULSION: f32 = ${getFloatLiteral(state.repulsion)}; +const GRAVITY: f32 = ${getFloatLiteral(state.gravity)}; +const TIME_STEP: f32 = ${getFloatLiteral(state.timeStep)}; +const MINIMUM_DISTANCE_SQUARED: f32 = ${MINIMUM_REPULSION_DISTANCE_SQUARED}; +const POSITIONS_OFFSET: u32 = ${getViewElementOffset(state.positions)}u; +const VELOCITIES_OFFSET: u32 = ${getViewElementOffset(state.velocities)}u; +const CELL_OFFSETS_OFFSET: u32 = ${getViewElementOffset(state.cellOffsets)}u; +const VERTEX_IDS_OFFSET: u32 = ${getViewElementOffset(state.vertexIds)}u; +const CELL_CENTERS_OFFSET: u32 = ${getViewElementOffset(state.cellCenters)}u; +const VALIDITY_OFFSET: u32 = ${getViewElementOffset(state.validity)}u; +${getBindingDeclarations(bindings)} + +fn getCoordinate(value: f32, minimum: f32, maximum: f32, size: u32) -> u32 { + if (maximum == minimum || value == minimum) { return 0u; } + if (value == maximum) { return size - 1u; } + if (minimum < 0.0 && maximum > 0.0) { + let scale = max(abs(minimum), abs(maximum)); + let scaledValue = value / scale; + let scaledMinimum = minimum / scale; + let scaledMaximum = maximum / scale; + return min( + u32((scaledValue - scaledMinimum) / (scaledMaximum - scaledMinimum) * f32(size)), + size - 1u + ); + } + return min(u32((value - minimum) / (maximum - minimum) * f32(size)), size - 1u); +} + +fn readPosition(vertex: u32) -> vec2 { + let positionOffset = POSITIONS_OFFSET + vertex * 2u; + return vec2(positions[positionOffset], positions[positionOffset + 1u]); +} + +@compute @workgroup_size(${SPATIAL_FORCE_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, SPATIAL_FORCE_WORKGROUP_SIZE)} + if (index >= VERTEX_COUNT) { return; } + let velocityOffset = VELOCITIES_OFFSET + index * 2u; + if (validity[VALIDITY_OFFSET] == 0u) { + velocities[velocityOffset] = 0.0; + velocities[velocityOffset + 1u] = 0.0; + return; + } + + let position = readPosition(index); + let sourceColumn = getCoordinate(position.x, MINIMUM_X, MAXIMUM_X, WIDTH); + let sourceRow = getCoordinate(position.y, MINIMUM_Y, MAXIMUM_Y, HEIGHT); + let cellWidth = (MAXIMUM_X - MINIMUM_X) / f32(WIDTH); + let cellHeight = (MAXIMUM_Y - MINIMUM_Y) / f32(HEIGHT); + let cellDiameterSquared = cellWidth * cellWidth + cellHeight * cellHeight; + var force = -GRAVITY * position; + + for (var cell = 0u; cell < CELL_COUNT; cell++) { + let first = min(cellOffsets[CELL_OFFSETS_OFFSET + cell], CAPACITY); + let last = min(cellOffsets[CELL_OFFSETS_OFFSET + cell + 1u], CAPACITY); + let mass = last - first; + if (mass == 0u) { continue; } + let column = cell % WIDTH; + let row = cell / WIDTH; + let columnDistance = max(column, sourceColumn) - min(column, sourceColumn); + let rowDistance = max(row, sourceRow) - min(row, sourceRow); + let isNear = columnDistance <= NEAR_CELL_RADIUS && rowDistance <= NEAR_CELL_RADIUS; + let centerOffset = CELL_CENTERS_OFFSET + cell * 2u; + let center = vec2(cellCenters[centerOffset], cellCenters[centerOffset + 1u]); + let centerDifference = position - center; + let distanceSquared = dot(centerDifference, centerDifference); + let useMonopole = + !isNear && THETA > 0.0 && cellDiameterSquared < THETA * THETA * distanceSquared; + if (useMonopole) { + force += REPULSION * f32(mass) * centerDifference / + max(distanceSquared, MINIMUM_DISTANCE_SQUARED); + continue; + } + for (var slot = first; slot < last; slot++) { + let otherVertex = vertexIds[VERTEX_IDS_OFFSET + slot]; + if (otherVertex >= VERTEX_COUNT || otherVertex == index) { continue; } + let difference = position - readPosition(otherVertex); + let separationSquared = max(dot(difference, difference), MINIMUM_DISTANCE_SQUARED); + force += REPULSION * difference / separationSquared; + } + } + + let previous = vec2(velocities[velocityOffset], velocities[velocityOffset + 1u]); + let velocity = previous + force * TIME_STEP; + velocities[velocityOffset] = velocity.x; + velocities[velocityOffset + 1u] = velocity.y; +}`; + addSpatialPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-repulsion`, + source, + bindings, + dispatchLayout + }); +} + +/** Adds symmetric incident-edge attraction and applies the exact layout's damping and cap. */ +function addAttractionPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedSpatialLayout; iteration: number} +): void { + const {state} = props; + const bindings: Record = { + positions: {view: state.positions, usage: 'storage-read'}, + velocities: {view: state.velocities, usage: 'storage-read-write'}, + forwardOffsets: {view: state.forwardOffsets, usage: 'storage-read'}, + forwardNeighbors: {view: state.forwardNeighbors, usage: 'storage-read'}, + validity: {view: state.validity, usage: 'storage-read'}, + ...(state.reverseOffsets && state.reverseNeighbors + ? { + reverseOffsets: {view: state.reverseOffsets, usage: 'storage-read'}, + reverseNeighbors: {view: state.reverseNeighbors, usage: 'storage-read'} + } + : {}) + }; + const reverseConstants = + state.reverseOffsets && state.reverseNeighbors + ? `const REVERSE_CAPACITY: u32 = ${state.reverseNeighbors.length}u; +const REVERSE_OFFSETS_OFFSET: u32 = ${getViewElementOffset(state.reverseOffsets)}u; +const REVERSE_NEIGHBORS_OFFSET: u32 = ${getViewElementOffset(state.reverseNeighbors)}u;` + : ''; + const reverseAttraction = + state.reverseOffsets && state.reverseNeighbors + ? `let reverseFirst = min(reverseOffsets[REVERSE_OFFSETS_OFFSET + index], REVERSE_CAPACITY); + let reverseLast = min(reverseOffsets[REVERSE_OFFSETS_OFFSET + index + 1u], REVERSE_CAPACITY); + for (var slot = reverseFirst; slot < reverseLast; slot++) { + let neighbor = reverseNeighbors[REVERSE_NEIGHBORS_OFFSET + slot]; + if (neighbor < VERTEX_COUNT) { + force += ATTRACTION * (readPosition(neighbor) - position); + } + }` + : ''; + const dispatchLayout = getSpatialDispatchLayout(state, state.vertexCount); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const FORWARD_CAPACITY: u32 = ${state.forwardNeighbors.length}u; +const POSITIONS_OFFSET: u32 = ${getViewElementOffset(state.positions)}u; +const VELOCITIES_OFFSET: u32 = ${getViewElementOffset(state.velocities)}u; +const FORWARD_OFFSETS_OFFSET: u32 = ${getViewElementOffset(state.forwardOffsets)}u; +const FORWARD_NEIGHBORS_OFFSET: u32 = ${getViewElementOffset(state.forwardNeighbors)}u; +const VALIDITY_OFFSET: u32 = ${getViewElementOffset(state.validity)}u; +const ATTRACTION: f32 = ${getFloatLiteral(state.attraction)}; +const DAMPING: f32 = ${getFloatLiteral(state.damping)}; +const MAX_VELOCITY: f32 = ${getFloatLiteral(state.maxVelocity)}; +const TIME_STEP: f32 = ${getFloatLiteral(state.timeStep)}; +${reverseConstants} +${getBindingDeclarations(bindings)} + +fn readPosition(vertex: u32) -> vec2 { + let positionOffset = POSITIONS_OFFSET + vertex * 2u; + return vec2(positions[positionOffset], positions[positionOffset + 1u]); +} + +@compute @workgroup_size(${SPATIAL_FORCE_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, SPATIAL_FORCE_WORKGROUP_SIZE)} + if (index >= VERTEX_COUNT) { return; } + let velocityOffset = VELOCITIES_OFFSET + index * 2u; + if (validity[VALIDITY_OFFSET] == 0u) { + velocities[velocityOffset] = 0.0; + velocities[velocityOffset + 1u] = 0.0; + return; + } + let position = readPosition(index); + var force = vec2(0.0); + let first = min(forwardOffsets[FORWARD_OFFSETS_OFFSET + index], FORWARD_CAPACITY); + let last = min(forwardOffsets[FORWARD_OFFSETS_OFFSET + index + 1u], FORWARD_CAPACITY); + for (var slot = first; slot < last; slot++) { + let neighbor = forwardNeighbors[FORWARD_NEIGHBORS_OFFSET + slot]; + if (neighbor < VERTEX_COUNT) { + force += ATTRACTION * (readPosition(neighbor) - position); + } + } + ${reverseAttraction} + let previous = vec2(velocities[velocityOffset], velocities[velocityOffset + 1u]); + var velocity = (previous + force * TIME_STEP) * DAMPING; + let speed = length(velocity); + if (speed > MAX_VELOCITY) { velocity *= MAX_VELOCITY / speed; } + velocities[velocityOffset] = velocity.x; + velocities[velocityOffset + 1u] = velocity.y; +}`; + addSpatialPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-attraction`, + source, + bindings, + dispatchLayout + }); +} + +/** Preserves invalid-index and pinned coordinates while publishing bounded velocities. */ +function addIntegrationPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedSpatialLayout; iteration: number} +): void { + const {state} = props; + const bindings: Record = { + positions: {view: state.positions, usage: 'storage-read-write'}, + velocities: {view: state.velocities, usage: 'storage-read-write'}, + validity: {view: state.validity, usage: 'storage-read'}, + ...(state.pinned ? {pinned: {view: state.pinned, usage: 'storage-read'}} : {}) + }; + const pinnedOffset = state.pinned + ? `const PINNED_OFFSET: u32 = ${getViewElementOffset(state.pinned)}u;` + : ''; + const pinned = state.pinned ? ' || pinned[PINNED_OFFSET + index] != 0u' : ''; + const dispatchLayout = getSpatialDispatchLayout(state, state.vertexCount); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const POSITIONS_OFFSET: u32 = ${getViewElementOffset(state.positions)}u; +const VELOCITIES_OFFSET: u32 = ${getViewElementOffset(state.velocities)}u; +const VALIDITY_OFFSET: u32 = ${getViewElementOffset(state.validity)}u; +const TIME_STEP: f32 = ${getFloatLiteral(state.timeStep)}; +${pinnedOffset} +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${SPATIAL_FORCE_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, SPATIAL_FORCE_WORKGROUP_SIZE)} + if (index >= VERTEX_COUNT) { return; } + let velocityOffset = VELOCITIES_OFFSET + index * 2u; + if (validity[VALIDITY_OFFSET] == 0u${pinned}) { + velocities[velocityOffset] = 0.0; + velocities[velocityOffset + 1u] = 0.0; + return; + } + let positionOffset = POSITIONS_OFFSET + index * 2u; + positions[positionOffset] += velocities[velocityOffset] * TIME_STEP; + positions[positionOffset + 1u] += velocities[velocityOffset + 1u] * TIME_STEP; +}`; + addSpatialPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-integrate`, + source, + bindings, + dispatchLayout + }); +} + +/** Declares packed uint32 views and float32x2 component arrays in binding order. */ +function getBindingDeclarations(bindings: Record): string { + return Object.entries(bindings) + .map(([name, binding], location) => { + const access = binding.usage === 'storage-read' ? 'read' : 'read_write'; + const element = binding.view.format === 'uint32' ? 'u32' : 'f32'; + return `@group(0) @binding(${location}) var ${name}: array<${element}>;`; + }) + .join('\n'); +} + +/** Compiles one bounded pass without floating-point atomics, submission, or readback. */ +function addSpatialPass( + commandGraph: GPUCommandGraph, + props: SpatialPassProps +): void { + commandGraph.addComputePass({ + id: props.id, + resources: Object.values(props.bindings).map(({view, usage}) => ({buffer: view, usage})), + compile: ({device}) => { + const computation = new Computation(device, { + id: props.id, + source: props.source, + shaderLayout: { + bindings: Object.keys(props.bindings).map((name, location) => ({ + name, + type: 'storage' as const, + group: 0, + location + })) + } + }); + return { + encode: ({computePass, getBuffer}) => { + const bindings: Record = {}; + for (const [name, binding] of Object.entries(props.bindings)) { + bindings[name] = getViewBinding(binding.view, getBuffer); + } + computation.setBindings(bindings); + computation.dispatch( + computePass, + props.dispatchLayout.x, + props.dispatchLayout.y, + props.dispatchLayout.z + ); + }, + destroy: () => computation.destroy() + }; + } + }); +} + +/** Emits the exact float32 domain literal used by the existing uniform-grid index. */ +function getFloatLiteral(value: number): string { + const literal = `${Math.fround(value)}`; + return literal.includes('.') || literal.includes('e') ? literal : `${literal}.0`; +} + +function getSpatialDispatchLayout( + state: ImportedSpatialLayout, + elementCount: number +): GPUBoundedDispatchLayout { + return getLuGraphSpatialForceLayoutDispatchLayout( + elementCount, + state.maxComputeWorkgroupsPerDimension + ); +} + +/** Plans bounded three-dimensional spatial index, cell, and vertex dispatch. @internal */ +export function getLuGraphSpatialForceLayoutDispatchLayout( + elementCount: number, + maxComputeWorkgroupsPerDimension: number +): GPUBoundedDispatchLayout { + return getBoundedDispatchLayout( + 'LuGraphSpatialForceLayout', + elementCount, + SPATIAL_FORCE_WORKGROUP_SIZE, + maxComputeWorkgroupsPerDimension + ); +} diff --git a/modules/experimental/src/lugraph/lu-graph-spatial-force-layout.ts b/modules/experimental/src/lugraph/lu-graph-spatial-force-layout.ts new file mode 100644 index 0000000000..e1ea3e044b --- /dev/null +++ b/modules/experimental/src/lugraph/lu-graph-spatial-force-layout.ts @@ -0,0 +1,246 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors +// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph. + +import type {Buffer} from '@luma.gl/core'; +import {DynamicBuffer} from '@luma.gl/engine'; +import type {GPUData, GPUVector} from '@luma.gl/tables'; +import type {GPUCommandGraph} from '../gpu-primitives/gpu-command-graph'; +import type {LuGraphForceLayout} from './lu-graph-force-layout'; +import {addLuGraphSpatialForceLayoutToGraphWithDispatchLimit} from './lu-graph-spatial-force-layout-internals'; +import type {LuGraphAdjacency} from './lu-graph-topology'; + +const MAXIMUM_UINT32 = 0xffffffff; +const SCALAR_BYTE_LENGTH = 4; + +/** Exact layout state, spatial approximation controls, and explicit caller-owned grid storage. */ +export type LuGraphSpatialForceLayoutProps = { + /** Prefix for generated command-graph nodes and graph-owned synchronization state. */ + id?: string; + /** Existing caller-owned progressive force layout, including its render-ready positions. */ + layout: LuGraphForceLayout; + /** Positive horizontal and vertical uniform-grid cell counts. */ + gridSize: readonly [number, number]; + /** Strictly increasing minimum x/y and maximum x/y spatial domain coordinates. */ + bounds: readonly [number, number, number, number]; + /** Nonnegative far-cell opening criterion; zero disables approximation. Defaults to 0.6. */ + theta?: number; + /** Nonnegative exact Chebyshev-neighborhood radius in grid cells. Defaults to one. */ + nearCellRadius?: number; + /** Caller-owned exclusive cell offsets with one trailing total row. */ + cellOffsets: GPUVector<'uint32'>; + /** Caller-owned, explicitly capacity-bounded vertex identifiers grouped by grid cell. */ + vertexIds: GPUVector<'uint32'>; + /** Caller-owned floating-point center of mass for every uniform-grid cell. */ + cellCenters: GPUVector<'float32x2'>; + /** Caller-owned scalar receiving the number of positions accepted by the spatial grid. */ + count: GPUVector<'uint32'>; + /** Caller-owned scalar receiving whether accepted positions exceed vertex-ID capacity. */ + overflow: GPUVector<'uint32'>; +}; + +/** + * Applies explicit uniform-grid near/far approximation to an existing progressive force layout. + * + * Every iteration rebuilds the caller-owned spatial index from current positions. The source + * vertex's own and configured neighboring cells remain exact, while sufficiently distant cells + * contribute population-weighted center-of-mass repulsion. Setting `theta` to zero makes every + * cell exact. This is a flat-grid monopole approximation, not Barnes–Hut or ForceAtlas2. + * + * Out-of-domain vertices, index-capacity overflow, or graph-topology overflow leave positions + * unchanged and clear velocities. Original caller-owned index buffers expose actual build capacity, + * acceptance, overflow, and storage overhead without implicit readback or floating-point atomics. + */ +export class LuGraphSpatialForceLayout { + /** Prefix for generated command-graph nodes and graph-owned synchronization state. */ + readonly id: string; + /** Existing caller-owned exact-layout configuration and progressive vertex state. */ + readonly layout: LuGraphForceLayout; + /** Horizontal and vertical uniform-grid dimensions. */ + readonly gridSize: readonly [number, number]; + /** Explicit two-dimensional minimum and maximum spatial domain. */ + readonly bounds: readonly [number, number, number, number]; + /** Far-cell opening criterion; zero preserves exact all-pairs repulsion. */ + readonly theta: number; + /** Exact grid-cell neighborhood radius around each source vertex. */ + readonly nearCellRadius: number; + /** Number of caller-owned row-major uniform-grid cells. */ + readonly cellCount: number; + /** Caller-owned exclusive offsets for every uniform-grid cell. */ + readonly cellOffsets: GPUVector<'uint32'>; + /** Caller-owned, capacity-bounded stable vertex identifiers grouped by cell. */ + readonly vertexIds: GPUVector<'uint32'>; + /** Caller-owned two-component floating-point centers of mass. */ + readonly cellCenters: GPUVector<'float32x2'>; + /** Caller-owned accepted-position count. */ + readonly count: GPUVector<'uint32'>; + /** Caller-owned spatial-index capacity overflow flag. */ + readonly overflow: GPUVector<'uint32'>; + + /** Validates existing metadata without allocating GPU storage, submitting, or reading back. */ + constructor(props: LuGraphSpatialForceLayoutProps) { + this.id = props.id ?? 'lu-graph-spatial-force-layout'; + this.layout = props.layout; + this.gridSize = props.gridSize; + this.bounds = props.bounds; + this.theta = props.theta ?? 0.6; + this.nearCellRadius = props.nearCellRadius ?? 1; + this.cellOffsets = props.cellOffsets; + this.vertexIds = props.vertexIds; + this.cellCenters = props.cellCenters; + this.count = props.count; + this.overflow = props.overflow; + + if ( + this.gridSize.length !== 2 || + this.gridSize.some(dimension => !Number.isSafeInteger(dimension) || dimension < 1) + ) { + throw new Error(`${this.id} gridSize requires two positive integer dimensions`); + } + const cellCount = this.gridSize[0] * this.gridSize[1]; + if (!Number.isSafeInteger(cellCount) || cellCount >= MAXIMUM_UINT32) { + throw new Error(`${this.id} grid cell count and trailing offset must fit in uint32`); + } + this.cellCount = cellCount; + + if ( + this.bounds.length !== 4 || + !this.bounds.every(Number.isFinite) || + this.bounds[0] >= this.bounds[2] || + this.bounds[1] >= this.bounds[3] + ) { + throw new Error(`${this.id} bounds require finite, strictly increasing x and y extents`); + } + if (!Number.isFinite(this.theta) || this.theta < 0) { + throw new Error(`${this.id} theta must be a finite non-negative number`); + } + if ( + !Number.isSafeInteger(this.nearCellRadius) || + this.nearCellRadius < 0 || + this.nearCellRadius > MAXIMUM_UINT32 + ) { + throw new Error(`${this.id} nearCellRadius must be a non-negative uint32`); + } + + validateSpatialVector(this.cellOffsets, 'uint32', `${this.id} cellOffsets`, cellCount + 1); + validateSpatialVector(this.vertexIds, 'uint32', `${this.id} vertexIds`); + validateSpatialVector(this.cellCenters, 'float32x2', `${this.id} cellCenters`, cellCount); + validateSpatialVector(this.count, 'uint32', `${this.id} count`, 1); + validateSpatialVector(this.overflow, 'uint32', `${this.id} overflow`, 1); + validateDistinctSpatialOutputs(this); + } + + /** Declares bounded index construction and approximate force work without implicit submission. */ + addToGraph(commandGraph: GPUCommandGraph): void { + addLuGraphSpatialForceLayoutToGraphWithDispatchLimit( + this, + commandGraph, + commandGraph.device.limits.maxComputeWorkgroupsPerDimension + ); + } +} + +/** Validates one packed, four-byte-aligned index destination and optional exact row count. */ +function validateSpatialVector( + vector: GPUVector, + format: Format, + name: string, + expectedLength?: number +): void { + const componentCount = format === 'float32x2' ? 2 : 1; + const rowByteLength = componentCount * SCALAR_BYTE_LENGTH; + if ( + vector.data.length !== 1 || + vector.format !== format || + vector.stride !== componentCount || + vector.byteStride !== rowByteLength || + vector.rowByteLength !== rowByteLength || + vector.valueLength !== vector.length || + vector.bufferLayout || + !Number.isSafeInteger(vector.length) || + vector.length < 0 || + vector.length > MAXIMUM_UINT32 + ) { + throw new Error(`${name} must contain exactly one packed ${format} chunk`); + } + if (expectedLength !== undefined && vector.length !== expectedLength) { + throw new Error(`${name} must contain exactly ${expectedLength} ${format} rows`); + } + + const chunk = vector.data[0]; + if ( + chunk.format !== format || + chunk.length !== vector.length || + chunk.stride !== componentCount || + chunk.byteStride !== rowByteLength || + chunk.rowByteLength !== rowByteLength || + chunk.valueLength !== chunk.length || + !Number.isSafeInteger(chunk.byteOffset) || + chunk.byteOffset < 0 || + chunk.byteOffset % SCALAR_BYTE_LENGTH !== 0 + ) { + throw new Error(`${name} must contain one packed, uint32-aligned ${format} chunk`); + } +} + +/** Requires every explicit index destination to avoid all topology and mutable layout allocations. */ +function validateDistinctSpatialOutputs(spatial: LuGraphSpatialForceLayout): void { + const layout = spatial.layout; + const topology = layout.topology; + const existingVectors = [ + topology.graph.sourceVertices, + topology.graph.targetVertices, + ...(topology.graph.edgeWeights ? [topology.graph.edgeWeights] : []), + ...(topology.graph.edgeIds ? [topology.graph.edgeIds] : []), + ...getAdjacencyVectors(topology.forward), + ...(topology.reverse ? getAdjacencyVectors(topology.reverse) : []), + topology.invalidEdgeCount, + layout.positions, + layout.velocities, + ...(layout.pinned ? [layout.pinned] : []), + ...(layout.reset ? [layout.reset] : []) + ]; + const allocations = new Set(); + for (const vector of existingVectors) { + for (const chunk of vector.data) { + allocations.add(getPhysicalBuffer(chunk)); + } + } + + const destinations = [ + {name: 'cellOffsets', vector: spatial.cellOffsets}, + {name: 'vertexIds', vector: spatial.vertexIds}, + {name: 'cellCenters', vector: spatial.cellCenters}, + {name: 'count', vector: spatial.count}, + {name: 'overflow', vector: spatial.overflow} + ]; + for (const {name, vector} of destinations) { + const buffer = getPhysicalBuffer(vector.data[0]); + if (allocations.has(buffer)) { + throw new Error(`${spatial.id} ${name} must use a distinct physical buffer allocation`); + } + allocations.add(buffer); + } +} + +/** Enumerates existing topology columns without changing any caller-owned chunks or metadata. */ +function getAdjacencyVectors( + adjacency: LuGraphAdjacency +): (GPUVector<'uint32'> | GPUVector<'float32'>)[] { + return [ + adjacency.offsets, + adjacency.neighbors, + adjacency.edgeIds, + ...(adjacency.edgeWeights ? [adjacency.edgeWeights] : []), + adjacency.count, + adjacency.overflow + ]; +} + +/** Resolves stable engine wrappers before comparing underlying physical index allocations. */ +function getPhysicalBuffer( + chunk: GPUData<'uint32'> | GPUData<'float32'> | GPUData<'float32x2'> +): Buffer { + return chunk.buffer instanceof DynamicBuffer ? chunk.buffer.buffer : chunk.buffer; +} diff --git a/modules/experimental/test/lugraph/lu-graph-spatial-force-layout.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-spatial-force-layout.node.spec.ts new file mode 100644 index 0000000000..889814024a --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-spatial-force-layout.node.spec.ts @@ -0,0 +1,579 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import {Buffer} from '@luma.gl/core'; +import {DynamicBuffer} from '@luma.gl/engine'; +import * as experimentalModule from '@luma.gl/experimental'; +import { + LuGraph, + LuGraphForceLayout, + LuGraphSpatialForceLayout, + LuGraphTopology, + type LuGraphAdjacency, + type LuGraphSpatialForceLayoutProps +} from '@luma.gl/experimental/lugraph'; +import {GPUData, GPUVector} from '@luma.gl/tables'; +import {NullDevice} from '@luma.gl/test-utils'; +import {afterEach, describe, expect, test, vi} from 'vitest'; + +type VectorFormat = 'uint32' | 'float32' | 'float32x2'; +type VectorValues = Uint32Array | Float32Array; + +type SpatialFixture = { + device: NullDevice; + buffers: Buffer[]; + dynamicBuffers: DynamicBuffer[]; + vectors: GPUVector[]; +}; + +type VectorOptions = { + buffer?: Buffer | DynamicBuffer; + byteOffset?: number; + byteStride?: number; + rowByteLength?: number; + stride?: number; +}; + +const spatialFixtures: SpatialFixture[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const fixture of spatialFixtures.splice(0)) { + for (const vector of fixture.vectors) vector.destroy(); + for (const dynamicBuffer of fixture.dynamicBuffers) dynamicBuffer.destroy(); + for (const buffer of fixture.buffers) buffer.destroy(); + fixture.device.destroy(); + } +}); + +describe('LuGraphSpatialForceLayout composition and caller-owned grid resources', () => { + test('exposes spatial acceleration only through the optional luGraph package entry', () => { + expect(typeof LuGraphSpatialForceLayout).toBe('function'); + expect('LuGraphSpatialForceLayout' in experimentalModule).toBe(false); + }); + + test('retains the exact base layout and every grid buffer without hidden GPU operations', () => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture, {weighted: true, pinned: true, reset: true}); + const createBufferSpy = vi.spyOn(fixture.device, 'createBuffer'); + const createCommandEncoderSpy = vi.spyOn(fixture.device, 'createCommandEncoder'); + const submitSpy = vi.spyOn(fixture.device, 'submit'); + const readbackSpies = fixture.buffers.map(buffer => vi.spyOn(buffer, 'readAsync')); + + const spatial = new LuGraphSpatialForceLayout({...props, id: 'borrowed-spatial-layout'}); + + expect(spatial.id).toBe('borrowed-spatial-layout'); + expect(spatial.layout).toBe(props.layout); + expect(spatial.gridSize).toBe(props.gridSize); + expect(spatial.bounds).toBe(props.bounds); + expect(spatial.cellCount).toBe(6); + expect(spatial.theta).toBe(0.6); + expect(spatial.nearCellRadius).toBe(1); + expect(spatial.cellOffsets).toBe(props.cellOffsets); + expect(spatial.vertexIds).toBe(props.vertexIds); + expect(spatial.cellCenters).toBe(props.cellCenters); + expect(spatial.count).toBe(props.count); + expect(spatial.overflow).toBe(props.overflow); + expect(spatial.layout.positions).toBe(props.layout.positions); + expect(spatial.layout.topology.graph.sourceVertices.data.map(chunk => chunk.length)).toEqual([ + 2, 0, 3 + ]); + expect(createBufferSpy).not.toHaveBeenCalled(); + expect(createCommandEncoderSpy).not.toHaveBeenCalled(); + expect(submitSpy).not.toHaveBeenCalled(); + for (const readbackSpy of readbackSpies) expect(readbackSpy).not.toHaveBeenCalled(); + expect(Reflect.has(spatial, 'destroy')).toBe(false); + + for (const vector of fixture.vectors) vector.destroy(); + expect(fixture.buffers.every(buffer => !buffer.destroyed)).toBe(true); + }); + + test('accepts empty graphs and zero-capacity caller-owned vertex-ID indexes', () => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture, {vertexCount: 0, vertexCapacity: 0, reset: true}); + const spatial = new LuGraphSpatialForceLayout(props); + + expect(spatial.layout.positions.length).toBe(0); + expect(spatial.vertexIds.length).toBe(0); + expect(spatial.cellOffsets.length).toBe(spatial.cellCount + 1); + expect(spatial.cellCenters.length).toBe(spatial.cellCount); + expect(spatial.layout.reset?.length).toBe(1); + }); +}); + +describe('LuGraphSpatialForceLayout finite grid and honest approximation contracts', () => { + test.each([ + 0, 0.001, 0.6, 1, 100 + ])('accepts a nonnegative finite opening criterion: %s', theta => { + const fixture = createSpatialFixture(); + expect(new LuGraphSpatialForceLayout({...createSpatialProps(fixture), theta}).theta).toBe( + theta + ); + }); + + test.each([ + -0.001, + Number.NaN, + Number.NEGATIVE_INFINITY, + Number.POSITIVE_INFINITY + ])('rejects an invalid far-cell opening criterion: %s', theta => { + const fixture = createSpatialFixture(); + expect(() => new LuGraphSpatialForceLayout({...createSpatialProps(fixture), theta})).toThrow( + /theta|finite|negative/ + ); + }); + + test.each([ + 0, 1, 5, 0xffffffff + ])('accepts an unsigned exact near-cell radius: %s', nearCellRadius => { + const fixture = createSpatialFixture(); + expect( + new LuGraphSpatialForceLayout({...createSpatialProps(fixture), nearCellRadius}).nearCellRadius + ).toBe(nearCellRadius); + }); + + test.each([ + -1, + 1.5, + 0x100000000, + Number.NaN, + Number.POSITIVE_INFINITY + ])('rejects an invalid exact near-cell radius: %s', nearCellRadius => { + const fixture = createSpatialFixture(); + expect( + () => new LuGraphSpatialForceLayout({...createSpatialProps(fixture), nearCellRadius}) + ).toThrow(/nearCellRadius|uint32|negative/); + }); + + test.each([ + ['empty x dimension', [0, 2]], + ['negative y dimension', [2, -1]], + ['fractional dimension', [1.5, 2]], + ['non-finite dimension', [2, Number.POSITIVE_INFINITY]], + ['missing dimension', [2]], + ['unexpected z dimension', [2, 2, 2]], + ['overflowing cell product', [65536, 65536]], + ['overflowing trailing offset', [0xffffffff, 1]] + ] as [ + string, + number[] + ][])('rejects invalid two-dimensional grid shape: %s', (_name, gridSize) => { + const fixture = createSpatialFixture(); + expect( + () => + new LuGraphSpatialForceLayout({ + ...createSpatialProps(fixture), + gridSize: gridSize as unknown as readonly [number, number] + }) + ).toThrow(/gridSize|grid|dimension|cell|uint32/); + }); + + test.each([ + ['equal x endpoints', [0, -1, 0, 1]], + ['equal y endpoints', [-1, 0, 1, 0]], + ['reversed x endpoints', [1, -1, -1, 1]], + ['reversed y endpoints', [-1, 1, 1, -1]], + ['non-finite minimum', [Number.NaN, -1, 1, 1]], + ['non-finite maximum', [-1, -1, Number.POSITIVE_INFINITY, 1]], + ['missing axis bound', [-1, -1, 1]] + ] as [string, number[]][])('rejects invalid strict index bounds: %s', (_name, bounds) => { + const fixture = createSpatialFixture(); + expect( + () => + new LuGraphSpatialForceLayout({ + ...createSpatialProps(fixture), + bounds: bounds as unknown as readonly [number, number, number, number] + }) + ).toThrow(/bounds|finite|increasing|extent/); + }); +}); + +describe('LuGraphSpatialForceLayout packed index outputs and physical allocation safety', () => { + test.each([6, 8])('requires one exclusive offset per cell plus a trailing total: %i', length => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture); + const cellOffsets = createVector(fixture, 'incorrect-offsets', 'uint32', [ + new Uint32Array(length) + ]); + expect(() => new LuGraphSpatialForceLayout({...props, cellOffsets})).toThrow( + /cellOffsets|row|cell/ + ); + }); + + test.each([5, 7])('requires one float32x2 center per row-major grid cell: %i', length => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture); + const cellCenters = createVector(fixture, 'incorrect-centers', 'float32x2', [ + new Float32Array(length * 2) + ]); + expect(() => new LuGraphSpatialForceLayout({...props, cellCenters})).toThrow( + /cellCenters|row|cell/ + ); + }); + + test.each([ + 'cellOffsets', + 'vertexIds', + 'count', + 'overflow' + ] as const)('requires packed unsigned scalar index data in %s', name => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture); + const expectedLength = name === 'cellOffsets' ? 7 : name === 'vertexIds' ? 6 : 1; + const wrongFormat = createVector(fixture, `float-${name}`, 'float32', [ + new Float32Array(expectedLength) + ]); + expect(() => new LuGraphSpatialForceLayout({...props, [name]: wrongFormat})).toThrow( + new RegExp(`${name}|uint32|packed`) + ); + }); + + test('requires float32x2 centers rather than uint32 or float32 scalars', () => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture); + const wrongFormat = createVector(fixture, 'scalar-centers', 'float32', [new Float32Array(6)]); + expect( + () => + new LuGraphSpatialForceLayout({ + ...props, + cellCenters: wrongFormat as GPUVector<'float32x2'> + }) + ).toThrow(/cellCenters|float32x2|packed/); + }); + + test.each([ + 'cellOffsets', + 'vertexIds', + 'cellCenters', + 'count', + 'overflow' + ] as const)('rejects partitioned caller-owned %s output', name => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture); + const vector = + name === 'cellCenters' + ? createVector(fixture, `partitioned-${name}`, 'float32x2', [ + new Float32Array(12), + new Float32Array(0) + ]) + : createVector(fixture, `partitioned-${name}`, 'uint32', [ + new Uint32Array(name === 'cellOffsets' ? 7 : name === 'vertexIds' ? 6 : 1), + new Uint32Array(0) + ]); + expect(() => new LuGraphSpatialForceLayout({...props, [name]: vector})).toThrow( + new RegExp(`${name}|one|single|chunk`) + ); + }); + + test.each([ + ['misaligned center offset', {byteOffset: 2}], + ['padded center stride', {byteStride: 12}], + ['oversized center payload', {rowByteLength: 12}], + ['incorrect center component count', {stride: 1}] + ] as [string, VectorOptions][])('rejects unpacked caller-owned centers: %s', (_name, options) => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture); + const cellCenters = createVector( + fixture, + 'unpacked-centers', + 'float32x2', + [new Float32Array(12)], + options + ); + expect(() => new LuGraphSpatialForceLayout({...props, cellCenters})).toThrow( + /cellCenters|packed|aligned|float32x2/ + ); + }); + + test.each(['count', 'overflow'] as const)('requires exactly one uint32 %s status row', name => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture); + for (const length of [0, 2]) { + const status = createVector(fixture, `incorrect-${name}-${length}`, 'uint32', [ + new Uint32Array(length) + ]); + expect(() => new LuGraphSpatialForceLayout({...props, [name]: status})).toThrow( + new RegExp(`${name}|one|row|scalar`) + ); + } + }); + + test('accepts four-byte offsets for packed scalar and float32x2 index vectors', () => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture); + const cellOffsets = createVector( + fixture, + 'offset-cell-offsets', + 'uint32', + [new Uint32Array(7)], + {byteOffset: 4} + ); + const cellCenters = createVector( + fixture, + 'offset-cell-centers', + 'float32x2', + [new Float32Array(12)], + {byteOffset: 4} + ); + const count = createVector(fixture, 'offset-count', 'uint32', [new Uint32Array(1)], { + byteOffset: 4 + }); + const spatial = new LuGraphSpatialForceLayout({...props, cellOffsets, cellCenters, count}); + expect(spatial.cellOffsets.data[0].byteOffset).toBe(4); + expect(spatial.cellCenters.data[0].byteOffset).toBe(4); + expect(spatial.count.data[0].byteOffset).toBe(4); + }); + + test.each([ + 'sourceVertices', + 'targetVertices', + 'edgeWeights', + 'edgeIds', + 'forward.offsets', + 'forward.neighbors', + 'forward.edgeIds', + 'forward.edgeWeights', + 'forward.count', + 'forward.overflow', + 'reverse.offsets', + 'reverse.neighbors', + 'reverse.edgeIds', + 'reverse.edgeWeights', + 'reverse.count', + 'reverse.overflow', + 'invalidEdgeCount', + 'positions', + 'velocities', + 'pinned', + 'reset' + ])('rejects vertex IDs backed by an existing graph or layout allocation: %s', vectorName => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture, { + vertexCount: 1, + weighted: true, + pinned: true, + reset: true, + gridSize: [1, 1] + }); + const existing = getExistingVector(props.layout, vectorName); + const vertexIds = createVector(fixture, 'aliased-spatial-ids', 'uint32', [new Uint32Array(1)], { + buffer: existing.data[0].buffer + }); + expect(() => new LuGraphSpatialForceLayout({...props, vertexIds})).toThrow( + /distinct|physical|allocation/ + ); + }); + + test.each([ + 'vertexIds', + 'cellCenters', + 'count', + 'overflow' + ] as const)('requires every writable grid destination to have its own physical allocation: %s', name => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture, {vertexCount: 1, gridSize: [1, 1]}); + const format = name === 'cellCenters' ? 'float32x2' : 'uint32'; + const values = name === 'cellCenters' ? new Float32Array(2) : new Uint32Array(1); + const alias = createVector(fixture, `aliased-${name}`, format, [values], { + buffer: props.cellOffsets.data[0].buffer + }); + expect(() => new LuGraphSpatialForceLayout({...props, [name]: alias})).toThrow( + /distinct|physical|allocation/ + ); + }); + + test('unwraps borrowed DynamicBuffer aliases before accepting caller-owned grid outputs', () => { + const fixture = createSpatialFixture(); + const props = createSpatialProps(fixture, {vertexCount: 1, gridSize: [1, 1]}); + const concreteBuffer = props.layout.positions.data[0].buffer as Buffer; + const dynamicBuffer = new DynamicBuffer(fixture.device, { + id: 'borrowed-spatial-wrapper', + buffer: concreteBuffer, + ownsBuffer: false + }); + fixture.dynamicBuffers.push(dynamicBuffer); + const vertexIds = createVector(fixture, 'dynamic-aliased-ids', 'uint32', [new Uint32Array(1)], { + buffer: dynamicBuffer + }); + expect(() => new LuGraphSpatialForceLayout({...props, vertexIds})).toThrow( + /distinct|physical|allocation/ + ); + expect(concreteBuffer.destroyed).toBe(false); + }); +}); + +function createSpatialFixture(): SpatialFixture { + const fixture = {device: new NullDevice({}), buffers: [], dynamicBuffers: [], vectors: []}; + spatialFixtures.push(fixture); + return fixture; +} + +function createSpatialProps( + fixture: SpatialFixture, + options: { + vertexCount?: number; + directed?: boolean; + weighted?: boolean; + pinned?: boolean; + reset?: boolean; + gridSize?: readonly [number, number]; + vertexCapacity?: number; + } = {} +): LuGraphSpatialForceLayoutProps { + const vertexCount = options.vertexCount ?? 6; + const sourceVertices = createVector(fixture, 'sourceVertices', 'uint32', [ + Uint32Array.from([0, 2]), + new Uint32Array(0), + Uint32Array.from([2, 3, 4]) + ]); + const targetVertices = createVector(fixture, 'targetVertices', 'uint32', [ + Uint32Array.from([1, 4]), + new Uint32Array(0), + Uint32Array.from([3, 5, 1]) + ]); + const edgeWeights = options.weighted + ? createVector(fixture, 'edgeWeights', 'float32', [ + Float32Array.from([0.5, 2]), + new Float32Array(0), + Float32Array.from([1, 4, 8]) + ]) + : undefined; + const edgeIds = options.weighted + ? createVector(fixture, 'edgeIds', 'uint32', [ + Uint32Array.from([10, 20]), + new Uint32Array(0), + Uint32Array.from([30, 40, 50]) + ]) + : undefined; + const directed = options.directed ?? true; + const graph = new LuGraph({ + vertexCount, + sourceVertices, + targetVertices, + edgeWeights, + edgeIds, + directed + }); + const forward = createAdjacency(fixture, 'forward', vertexCount, 5, options.weighted); + const reverse = directed + ? createAdjacency(fixture, 'reverse', vertexCount, 5, options.weighted) + : undefined; + const topology = new LuGraphTopology({ + graph, + forward, + reverse, + invalidEdgeCount: createVector(fixture, 'invalidEdgeCount', 'uint32', [new Uint32Array(1)]) + }); + const positions = createVector(fixture, 'positions', 'float32x2', [ + new Float32Array(vertexCount * 2) + ]); + const velocities = createVector(fixture, 'velocities', 'float32x2', [ + new Float32Array(vertexCount * 2) + ]); + const pinned = options.pinned + ? createVector(fixture, 'pinned', 'uint32', [new Uint32Array(vertexCount)]) + : undefined; + const reset = options.reset + ? createVector(fixture, 'reset', 'uint32', [new Uint32Array(1)]) + : undefined; + const layout = new LuGraphForceLayout({topology, positions, velocities, pinned, reset}); + const gridSize = options.gridSize ?? [3, 2]; + const cellCount = gridSize[0] * gridSize[1]; + return { + layout, + gridSize, + bounds: [-2, -2, 2, 2], + cellOffsets: createVector(fixture, 'cellOffsets', 'uint32', [new Uint32Array(cellCount + 1)]), + vertexIds: createVector(fixture, 'vertexIds', 'uint32', [ + new Uint32Array(options.vertexCapacity ?? vertexCount) + ]), + cellCenters: createVector(fixture, 'cellCenters', 'float32x2', [ + new Float32Array(cellCount * 2) + ]), + count: createVector(fixture, 'count', 'uint32', [new Uint32Array(1)]), + overflow: createVector(fixture, 'overflow', 'uint32', [new Uint32Array(1)]) + }; +} + +function createAdjacency( + fixture: SpatialFixture, + name: string, + vertexCount: number, + capacity: number, + weighted = false +): LuGraphAdjacency { + return { + offsets: createVector(fixture, `${name}-offsets`, 'uint32', [new Uint32Array(vertexCount + 1)]), + neighbors: createVector(fixture, `${name}-neighbors`, 'uint32', [new Uint32Array(capacity)]), + edgeIds: createVector(fixture, `${name}-edgeIds`, 'uint32', [new Uint32Array(capacity)]), + edgeWeights: weighted + ? createVector(fixture, `${name}-weights`, 'float32', [new Float32Array(capacity)]) + : undefined, + count: createVector(fixture, `${name}-count`, 'uint32', [new Uint32Array(1)]), + overflow: createVector(fixture, `${name}-overflow`, 'uint32', [new Uint32Array(1)]) + }; +} + +function getExistingVector(layout: LuGraphForceLayout, name: string): GPUVector { + if (name === 'positions') return layout.positions; + if (name === 'velocities') return layout.velocities; + if (name === 'pinned') return layout.pinned!; + if (name === 'reset') return layout.reset!; + const topology = layout.topology; + if (name === 'invalidEdgeCount') return topology.invalidEdgeCount; + if (name === 'sourceVertices') return topology.graph.sourceVertices; + if (name === 'targetVertices') return topology.graph.targetVertices; + if (name === 'edgeWeights') return topology.graph.edgeWeights!; + if (name === 'edgeIds') return topology.graph.edgeIds!; + const [direction, vectorName] = name.split('.'); + const adjacency = direction === 'forward' ? topology.forward : topology.reverse!; + return adjacency[vectorName as keyof LuGraphAdjacency]!; +} + +function createVector( + fixture: SpatialFixture, + name: string, + format: Format, + chunks: readonly VectorValues[], + options: VectorOptions = {} +): GPUVector { + const components = format === 'float32x2' ? 2 : 1; + const byteOffset = options.byteOffset ?? 0; + const byteStride = options.byteStride ?? components * Uint32Array.BYTES_PER_ELEMENT; + const rowByteLength = options.rowByteLength ?? components * Uint32Array.BYTES_PER_ELEMENT; + const stride = options.stride ?? components; + const data = chunks.map((values, chunkIndex) => { + const length = values.length / components; + const buffer = + options.buffer ?? + fixture.device.createBuffer({ + id: `${name}-chunk-${chunkIndex}-${fixture.buffers.length}`, + byteLength: byteOffset + Math.max(Math.max(length, 1) * byteStride, rowByteLength, 8), + usage: Buffer.STORAGE | Buffer.VERTEX | Buffer.COPY_DST | Buffer.COPY_SRC + }); + if (!options.buffer) fixture.buffers.push(buffer as Buffer); + return new GPUData({ + buffer, + format, + length, + byteOffset, + byteStride, + rowByteLength, + stride, + ownsBuffer: false + }); + }); + const vector = new GPUVector({ + type: 'data', + name, + format, + data, + byteStride, + rowByteLength, + stride, + ownsData: false + }); + fixture.vectors.push(vector); + return vector; +} diff --git a/modules/experimental/test/lugraph/lu-graph-spatial-force-layout.spec.ts b/modules/experimental/test/lugraph/lu-graph-spatial-force-layout.spec.ts new file mode 100644 index 0000000000..f5c71583d4 --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-spatial-force-layout.spec.ts @@ -0,0 +1,1247 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import {Buffer, type Device} from '@luma.gl/core'; +import {GPUCommandGraph} from '@luma.gl/experimental'; +import { + LuGraph, + LuGraphForceLayout, + LuGraphSpatialForceLayout, + LuGraphTopology, + type LuGraphAdjacency +} from '@luma.gl/experimental/lugraph'; +import {GPUData, GPUVector} from '@luma.gl/tables'; +import {getWebGPUTestDevice} from '@luma.gl/test-utils'; +import test, {type Test} from 'test/utils/vitest-tape'; +import {vi} from 'vitest'; +import { + addLuGraphSpatialForceLayoutToGraphWithDispatchLimit, + getLuGraphSpatialForceLayoutDispatchLayout +} from '../../src/lugraph/lu-graph-spatial-force-layout-internals'; + +const SPATIAL_TOLERANCE = 2e-4; +const MINIMUM_SQUARED_DISTANCE = 0.0001; + +type ScalarFormat = 'uint32' | 'float32'; + +type SpatialScenario = { + name: string; + vertexCount: number; + sourceChunks: number[][]; + targetChunks: number[][]; + positions: number[]; + velocities?: number[]; + pinned?: number[]; + reset?: number; + seed?: number; + gridSize?: readonly [number, number]; + bounds?: readonly [number, number, number, number]; + theta?: number; + nearCellRadius?: number; + vertexCapacity?: number; + directed?: boolean; + weightChunks?: number[][]; + iterationsPerFrame?: number; + repulsion?: number; + attraction?: number; + gravity?: number; + damping?: number; + maxVelocity?: number; + timeStep?: number; + capacity?: number; + reverseCapacity?: number; + maximumWorkgroups?: number; + byteOffset?: number; + differsFromExact?: boolean; +}; + +type GridReference = { + cells: number[][]; + offsets: number[]; + centers: number[]; + acceptedCount: number; + overflow: boolean; +}; + +type ExpectedSpatialLayout = { + positions: number[]; + velocities: number[]; + grid: GridReference; + reset?: number; + invalidEdgeCount: number; + forwardCount: number; + reverseCount: number; + forwardOverflow: boolean; + reverseOverflow: boolean; + failed: boolean; +}; + +type SpatialExecutionFixture = { + device: Device; + buffers: Buffer[]; + vectors: GPUVector[]; + graph: LuGraph; + topology: LuGraphTopology; + layout: LuGraphForceLayout; + spatial: LuGraphSpatialForceLayout; + commandGraph: GPUCommandGraph; + compiled?: ReturnType; +}; + +const spatialScenarios: SpatialScenario[] = [ + { + name: 'empty graphs initialize all caller-owned cells and consume deterministic reset', + vertexCount: 0, + sourceChunks: [], + targetChunks: [], + positions: [], + directed: false, + reset: 1, + gridSize: [3, 2], + vertexCapacity: 0, + iterationsPerFrame: 1 + }, + { + name: 'an isolated indexed vertex retains exact gravity and progressive velocity semantics', + vertexCount: 1, + sourceChunks: [[]], + targetChunks: [[]], + positions: [1, -1], + velocities: [0.5, 0.25], + gravity: 0.2, + damping: 0.8, + timeStep: 0.5, + maxVelocity: 10, + iterationsPerFrame: 2 + }, + { + name: 'theta zero exactly matches all-pairs repulsion across distant cells', + vertexCount: 5, + sourceChunks: [[0, 1], [], [3]], + targetChunks: [[1, 2], [], [4]], + positions: [-1.8, -0.4, -1.1, 0.6, 0.1, -0.8, 1.1, 0.5, 1.3, 0.4], + gridSize: [8, 3], + theta: 0, + repulsion: 0.4, + gravity: 0, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1 + }, + { + name: 'own and adjacent cells remain exact even with an aggressive far-field opening angle', + vertexCount: 3, + sourceChunks: [[]], + targetChunks: [[]], + positions: [-1.8, 0, -1.55, 0.15, -1.3, -0.1], + gridSize: [8, 2], + nearCellRadius: 1, + theta: 100, + gravity: 0, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1 + }, + { + name: 'distant multi-vertex cells use population-weighted centroid repulsion', + vertexCount: 4, + sourceChunks: [[]], + targetChunks: [[]], + positions: [-1.8, 0, 1.1, 0.12, 1.3, 0.2, -1.65, 0.3], + gridSize: [8, 2], + nearCellRadius: 0, + theta: 1, + repulsion: 0.25, + gravity: 0, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1, + differsFromExact: true + }, + { + name: 'small opening angles fall back to exact far-cell vertex iteration', + vertexCount: 3, + sourceChunks: [[]], + targetChunks: [[]], + positions: [-1.8, 0, 1.1, 0.12, 1.3, 0.2], + gridSize: [8, 2], + nearCellRadius: 0, + theta: 0.01, + gravity: 0, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1 + }, + { + name: 'expanded Chebyshev near radius explicitly disables approximation across the grid', + vertexCount: 4, + sourceChunks: [[]], + targetChunks: [[]], + positions: [-1.7, -0.8, -0.3, 0.5, 1.1, -0.2, 1.3, 0.1], + gridSize: [8, 3], + nearCellRadius: 32, + theta: 100, + iterationsPerFrame: 1 + }, + { + name: 'empty cells retain zero centroids and preserve exact exclusive offsets', + vertexCount: 3, + sourceChunks: [[]], + targetChunks: [[]], + positions: [-1.8, -1.8, 0, 0, 1.8, 1.8], + gridSize: [4, 4], + theta: 0, + repulsion: 0, + gravity: 0, + iterationsPerFrame: 1 + }, + { + name: 'inclusive maximum bounds place coordinates in the final row-major cell', + vertexCount: 3, + sourceChunks: [[]], + targetChunks: [[]], + positions: [-2, -2, 0, 0, 2, 2], + gridSize: [3, 3], + theta: 0, + repulsion: 0, + gravity: 0, + iterationsPerFrame: 1 + }, + { + name: 'directed forward and reverse edges retain symmetric exact spring attraction', + vertexCount: 2, + sourceChunks: [[0]], + targetChunks: [[1]], + positions: [-1, 0, 1, 0], + repulsion: 0, + gravity: 0, + attraction: 0.25, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1 + }, + { + name: 'undirected forward adjacency remains symmetric without reverse grid attraction', + vertexCount: 3, + sourceChunks: [[0, 1]], + targetChunks: [[1, 2]], + positions: [-1, 0, 0, 0.5, 1, 0], + directed: false, + repulsion: 0, + attraction: 0.2, + gravity: 0, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 2 + }, + { + name: 'duplicate edges strengthen springs while self loops contribute zero displacement', + vertexCount: 3, + sourceChunks: [[0, 0], [], [1, 2]], + targetChunks: [[1, 1], [], [1, 0]], + positions: [-1, 0, 1, 0, 0, 1], + repulsion: 0, + gravity: 0, + attraction: 0.2, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1 + }, + { + name: 'existing edge weights remain intentionally unweighted under spatial acceleration', + vertexCount: 4, + sourceChunks: [[0, 1], [], [2]], + targetChunks: [[1, 2], [], [3]], + weightChunks: [[0.001, 100], [], [42]], + positions: [-1, 0, 0, 0.5, 0.8, -0.25, 1.2, 0.5], + theta: 0, + repulsion: 0.03, + maxVelocity: 2, + iterationsPerFrame: 2 + }, + { + name: 'invalid original edge identifiers remain excluded without dropping valid grid vertices', + vertexCount: 5, + sourceChunks: [[0, 8], [], [2, 3, 4]], + targetChunks: [[1, 2], [], [9, 4, 4]], + positions: [-1.5, 0, -0.7, 0.5, 0, 0, 0.7, -0.5, 1.5, 0], + theta: 0, + maxVelocity: 0.1, + iterationsPerFrame: 2 + }, + { + name: 'pinned vertices preserve positions and publish zero progressive velocities', + vertexCount: 4, + sourceChunks: [[0, 1, 2]], + targetChunks: [[1, 2, 3]], + positions: [-1.5, 0.5, -0.4, 0, 0.4, 0, 1.5, -0.5], + velocities: [1, 2, 0.5, -1, 0, 0, -3, 4], + pinned: [1, 0, 0, 9], + maxVelocity: 0.1, + iterationsPerFrame: 2 + }, + { + name: 'deterministic reset initializes before rebuilding the spatial grid and preserves pins', + vertexCount: 4, + sourceChunks: [[0, 1], [], [2]], + targetChunks: [[1, 2], [], [3]], + positions: [1.5, -1.5, 1.3, 1.2, -1.1, 0.5, 0.9, -1.3], + velocities: [1, 1, 2, 2, 3, 3, 4, 4], + pinned: [1, 0, 0, 0], + reset: 1, + seed: 123456789, + theta: 0, + maxVelocity: 0.05, + iterationsPerFrame: 2 + }, + { + name: 'successive iterations rebuild the index after vertices cross cell boundaries', + vertexCount: 2, + sourceChunks: [[]], + targetChunks: [[]], + positions: [-0.9, 0, 0.9, 0], + gridSize: [4, 1], + bounds: [-1, -1, 1, 1], + repulsion: 0, + gravity: 1, + damping: 1, + timeStep: 0.6, + maxVelocity: 10, + iterationsPerFrame: 2 + }, + { + name: 'coincident indexed vertices remain finite through exact softened near-field forces', + vertexCount: 3, + sourceChunks: [[]], + targetChunks: [[]], + positions: [0, 0, 0, 0, 1, 0], + theta: 0, + gravity: 0, + maxVelocity: 0.5, + iterationsPerFrame: 1 + }, + { + name: 'zero-capacity vertex IDs publish explicit overflow and leave render positions unchanged', + vertexCount: 3, + sourceChunks: [[0]], + targetChunks: [[1]], + positions: [-1, 0, 0, 0, 1, 0], + velocities: [1, 2, 3, 4, 5, 6], + vertexCapacity: 0, + iterationsPerFrame: 1 + }, + { + name: 'partial spatial capacity fails closed while reporting the full indexed vertex count', + vertexCount: 4, + sourceChunks: [[0, 1]], + targetChunks: [[1, 2]], + positions: [-1, 0, -0.5, 0, 0.5, 0, 1, 0], + velocities: [1, 1, 2, 2, 3, 3, 4, 4], + vertexCapacity: 2, + iterationsPerFrame: 2 + }, + { + name: 'out-of-domain vertices fail closed even when capacity does not overflow', + vertexCount: 3, + sourceChunks: [[0]], + targetChunks: [[1]], + positions: [0, 0, 3, 0, -1, 1], + velocities: [1, 2, 3, 4, 5, 6], + iterationsPerFrame: 1 + }, + { + name: 'forward topology overflow preserves coordinates regardless of a complete grid index', + vertexCount: 4, + sourceChunks: [[0, 1, 2]], + targetChunks: [[1, 2, 3]], + positions: [-1.5, 0, -0.5, 0, 0.5, 0, 1.5, 0], + velocities: [1, 1, 2, 2, 3, 3, 4, 4], + capacity: 1, + iterationsPerFrame: 1 + }, + { + name: 'reverse topology overflow suppresses deterministic reset and clears its request', + vertexCount: 4, + sourceChunks: [[0, 1, 2]], + targetChunks: [[1, 2, 3]], + positions: [-1.5, 0, -0.5, 0, 0.5, 0, 1.5, 0], + velocities: [1, 1, 2, 2, 3, 3, 4, 4], + reset: 1, + reverseCapacity: 1, + iterationsPerFrame: 1 + }, + { + name: 'packed index centers, offsets, controls, and positions support four-byte view offsets', + vertexCount: 4, + sourceChunks: [[0, 2]], + targetChunks: [[1, 3]], + positions: [-1.5, 0, -0.5, 0.5, 0.5, -0.5, 1.5, 0], + pinned: [0, 1, 0, 0], + reset: 0, + theta: 0, + maxVelocity: 0.1, + byteOffset: 4, + iterationsPerFrame: 1 + }, + { + name: 'bounded 3D indexing and centroid passes process 1025 vertices and 1025 cells', + vertexCount: 1025, + sourceChunks: [ + Array.from({length: 600}, (_, vertexIndex) => vertexIndex), + [], + Array.from({length: 424}, (_, vertexIndex) => vertexIndex + 600) + ], + targetChunks: [ + Array.from({length: 600}, (_, vertexIndex) => vertexIndex + 1), + [], + Array.from({length: 424}, (_, vertexIndex) => vertexIndex + 601) + ], + positions: Array.from({length: 2050}, (_, coordinateIndex) => + coordinateIndex % 2 === 0 ? (Math.floor(coordinateIndex / 2) + 0.5) / 1025 : 0 + ), + gridSize: [1025, 1], + bounds: [0, -1, 1, 1], + theta: 0.6, + repulsion: 0.00001, + gravity: 0, + maxVelocity: 0.05, + iterationsPerFrame: 1, + maximumWorkgroups: 2 + } +]; + +test('LuGraphSpatialForceLayout plans bounded 3D vertex, grid, and centroid dispatch', tapeTest => { + tapeTest.deepEqual(getLuGraphSpatialForceLayoutDispatchLayout(0, 2), {x: 1, y: 1, z: 1}); + tapeTest.deepEqual(getLuGraphSpatialForceLayoutDispatchLayout(512, 2), {x: 2, y: 1, z: 1}); + tapeTest.deepEqual(getLuGraphSpatialForceLayoutDispatchLayout(513, 2), {x: 2, y: 2, z: 1}); + tapeTest.deepEqual(getLuGraphSpatialForceLayoutDispatchLayout(1025, 2), {x: 2, y: 2, z: 2}); + tapeTest.throws(() => getLuGraphSpatialForceLayoutDispatchLayout(2049, 2), /3D dispatch limit/); + tapeTest.end(); +}); + +for (const scenario of spatialScenarios) { + test(`LuGraphSpatialForceLayout GPU spatial physics: ${scenario.name}`, async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const expected = calculateExpectedSpatialLayout(scenario); + const fixture = createExecutionFixture(device, scenario, expected); + try { + compileSpatialLayout(fixture, scenario.maximumWorkgroups); + executeSpatialLayout(fixture); + await assertSpatialLayout(tapeTest, fixture, scenario, expected); + tapeTest.deepEqual( + fixture.graph.sourceVertices.data.map(chunk => chunk.length), + scenario.sourceChunks.map(chunk => chunk.length), + 'spatial acceleration preserves caller-owned graph chunks and empty edge batches' + ); + if (scenario.differsFromExact) { + const exact = calculateExpectedSpatialLayout({...scenario, theta: 0}); + const actual = await readCoordinateVector(fixture.layout.positions); + tapeTest.ok( + actual.some((coordinate, index) => Math.abs(coordinate - exact.positions[index]) > 1e-5), + 'far-cell monopoles are explicitly approximate rather than silently presented as exact' + ); + } + } finally { + destroyExecutionFixture(tapeTest, fixture); + } + + tapeTest.end(); + }); +} + +test('LuGraphSpatialForceLayout repeatedly rebuilds spatial cells across warm starts and deterministic reset', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const initial: SpatialScenario = { + name: 'progressive spatial reindexing', + vertexCount: 4, + sourceChunks: [[0, 1], [], [2]], + targetChunks: [[1, 2], [], [3]], + positions: [-1.2, 0.8, -0.2, -0.5, 0.4, 0.2, 1.1, -0.8], + velocities: [0.1, 0, 0, 0.1, -0.1, 0, 0, -0.1], + gridSize: [5, 3], + reset: 1, + seed: 456, + theta: 0, + maxVelocity: 0.05, + iterationsPerFrame: 2 + }; + const expectedInitial = calculateExpectedSpatialLayout(initial); + const fixture = createExecutionFixture(device, initial, expectedInitial); + const submitSpy = vi.spyOn(device, 'submit'); + const sourceReadbackSpies = [ + ...fixture.graph.sourceVertices.data, + ...fixture.graph.targetVertices.data + ].map(chunk => vi.spyOn(chunk.buffer, 'readAsync')); + + try { + compileSpatialLayout(fixture); + tapeTest.equal( + submitSpy.mock.calls.length, + 0, + 'spatial layout construction never submits work' + ); + tapeTest.ok( + sourceReadbackSpies.every(spy => spy.mock.calls.length === 0), + 'grid rebuilds and approximation never read source edges back to the CPU' + ); + submitSpy.mockRestore(); + for (const sourceReadbackSpy of sourceReadbackSpies) sourceReadbackSpy.mockRestore(); + + executeSpatialLayout(fixture); + await assertSpatialLayout(tapeTest, fixture, initial, expectedInitial); + + const warmStart = { + ...initial, + positions: expectedInitial.positions, + velocities: expectedInitial.velocities, + reset: 0 + }; + executeSpatialLayout(fixture); + await assertSpatialLayout( + tapeTest, + fixture, + warmStart, + calculateExpectedSpatialLayout(warmStart) + ); + + (fixture.layout.reset!.data[0].buffer as Buffer).write(Uint32Array.from([1])); + executeSpatialLayout(fixture); + await assertSpatialLayout(tapeTest, fixture, initial, expectedInitial); + } finally { + submitSpy.mockRestore(); + for (const sourceReadbackSpy of sourceReadbackSpies) sourceReadbackSpy.mockRestore(); + destroyExecutionFixture(tapeTest, fixture); + } + + tapeTest.end(); +}); + +/** Computes the exact documented flat-grid monopole approximation without GPU-side atomics. */ +function calculateExpectedSpatialLayout(scenario: SpatialScenario): ExpectedSpatialLayout { + const outgoing = Array.from({length: scenario.vertexCount}, () => [] as number[]); + const incoming = Array.from({length: scenario.vertexCount}, () => [] as number[]); + let invalidEdgeCount = 0; + let validEdgeCount = 0; + + for (const [chunkIndex, sources] of scenario.sourceChunks.entries()) { + for (const [rowIndex, source] of sources.entries()) { + const target = scenario.targetChunks[chunkIndex][rowIndex]; + if (source >= scenario.vertexCount || target >= scenario.vertexCount) { + invalidEdgeCount++; + continue; + } + validEdgeCount++; + outgoing[source].push(target); + incoming[target].push(source); + if (scenario.directed === false && source !== target) outgoing[target].push(source); + } + } + + const forwardCount = outgoing.reduce((count, neighbors) => count + neighbors.length, 0); + const reverseCount = scenario.directed === false ? 0 : validEdgeCount; + const forwardOverflow = forwardCount > (scenario.capacity ?? forwardCount); + const reverseOverflow = + scenario.directed !== false && reverseCount > (scenario.reverseCapacity ?? reverseCount); + const topologyFailed = forwardOverflow || reverseOverflow; + const positions = Array.from(scenario.positions); + let velocities = Array.from(scenario.velocities ?? new Array(scenario.vertexCount * 2).fill(0)); + const pinned = scenario.pinned ?? []; + + if (scenario.reset && !topologyFailed) { + for (let vertexIndex = 0; vertexIndex < scenario.vertexCount; vertexIndex++) { + if (!pinned[vertexIndex]) { + positions[vertexIndex * 2] = getSeededCoordinate(scenario.seed ?? 0, vertexIndex * 2); + positions[vertexIndex * 2 + 1] = getSeededCoordinate( + scenario.seed ?? 0, + vertexIndex * 2 + 1 + ); + } + } + } + if (scenario.reset) velocities.fill(0); + + let grid = calculateGridReference(scenario, positions); + let failed = topologyFailed || grid.overflow || grid.acceptedCount !== scenario.vertexCount; + const iterations = scenario.vertexCount === 0 ? 0 : (scenario.iterationsPerFrame ?? 4); + for (let iteration = 0; iteration < iterations; iteration++) { + grid = calculateGridReference(scenario, positions); + failed = topologyFailed || grid.overflow || grid.acceptedCount !== scenario.vertexCount; + if (failed) { + velocities.fill(0); + continue; + } + + const nextVelocities = Array.from(velocities); + for (let vertexIndex = 0; vertexIndex < scenario.vertexCount; vertexIndex++) { + const [forceX, forceY] = calculateSpatialRepulsion(scenario, grid, positions, vertexIndex); + const positionX = positions[vertexIndex * 2]; + const positionY = positions[vertexIndex * 2 + 1]; + const timeStep = scenario.timeStep ?? 1; + let velocityX = + velocities[vertexIndex * 2] + (forceX - (scenario.gravity ?? 0.01) * positionX) * timeStep; + let velocityY = + velocities[vertexIndex * 2 + 1] + + (forceY - (scenario.gravity ?? 0.01) * positionY) * timeStep; + + const neighbors = + scenario.directed === false + ? outgoing[vertexIndex] + : [...outgoing[vertexIndex], ...incoming[vertexIndex]]; + for (const neighborIndex of neighbors) { + velocityX += + (scenario.attraction ?? 0.1) * (positions[neighborIndex * 2] - positionX) * timeStep; + velocityY += + (scenario.attraction ?? 0.1) * (positions[neighborIndex * 2 + 1] - positionY) * timeStep; + } + velocityX *= scenario.damping ?? 0.9; + velocityY *= scenario.damping ?? 0.9; + const speed = Math.hypot(velocityX, velocityY); + const maximumSpeed = scenario.maxVelocity ?? 1; + if (speed > maximumSpeed) { + velocityX *= maximumSpeed / speed; + velocityY *= maximumSpeed / speed; + } + nextVelocities[vertexIndex * 2] = pinned[vertexIndex] ? 0 : velocityX; + nextVelocities[vertexIndex * 2 + 1] = pinned[vertexIndex] ? 0 : velocityY; + } + + velocities = nextVelocities; + for (let vertexIndex = 0; vertexIndex < scenario.vertexCount; vertexIndex++) { + if (!pinned[vertexIndex]) { + positions[vertexIndex * 2] += velocities[vertexIndex * 2] * (scenario.timeStep ?? 1); + positions[vertexIndex * 2 + 1] += + velocities[vertexIndex * 2 + 1] * (scenario.timeStep ?? 1); + } + } + } + + if (failed) grid.centers.fill(0); + + return { + positions, + velocities, + grid, + ...(scenario.reset !== undefined ? {reset: 0} : {}), + invalidEdgeCount, + forwardCount, + reverseCount, + forwardOverflow, + reverseOverflow, + failed + }; +} + +/** Replicates inclusive-max GPUGridIndex row-major coordinates and centroid gathering. */ +function calculateGridReference(scenario: SpatialScenario, positions: number[]): GridReference { + const [columns, rows] = scenario.gridSize ?? [3, 2]; + const bounds = scenario.bounds ?? [-2, -2, 2, 2]; + const cells = Array.from({length: columns * rows}, () => [] as number[]); + for (let vertexIndex = 0; vertexIndex < scenario.vertexCount; vertexIndex++) { + const horizontal = positions[vertexIndex * 2]; + const vertical = positions[vertexIndex * 2 + 1]; + if ( + !Number.isFinite(horizontal) || + !Number.isFinite(vertical) || + horizontal < bounds[0] || + horizontal > bounds[2] || + vertical < bounds[1] || + vertical > bounds[3] + ) { + continue; + } + const column = getGridCoordinate(horizontal, bounds[0], bounds[2], columns); + const row = getGridCoordinate(vertical, bounds[1], bounds[3], rows); + cells[row * columns + column].push(vertexIndex); + } + + const offsets = [0]; + for (const cell of cells) offsets.push(offsets[offsets.length - 1] + cell.length); + const centers = new Array(cells.length * 2).fill(0); + const acceptedCount = offsets[offsets.length - 1]; + const overflow = acceptedCount > (scenario.vertexCapacity ?? scenario.vertexCount); + if (!overflow) { + for (const [cellIndex, vertices] of cells.entries()) { + if (vertices.length === 0) continue; + centers[cellIndex * 2] = + vertices.reduce((sum, vertex) => sum + positions[vertex * 2], 0) / vertices.length; + centers[cellIndex * 2 + 1] = + vertices.reduce((sum, vertex) => sum + positions[vertex * 2 + 1], 0) / vertices.length; + } + } + return {cells, offsets, centers, acceptedCount, overflow}; +} + +function getGridCoordinate(value: number, minimum: number, maximum: number, size: number): number { + if (value === minimum) return 0; + if (value === maximum) return size - 1; + return Math.min(Math.floor(((value - minimum) / (maximum - minimum)) * size), size - 1); +} + +/** Applies exact nearby-cell repulsion and the documented far-cell population monopole. */ +function calculateSpatialRepulsion( + scenario: SpatialScenario, + grid: GridReference, + positions: number[], + vertexIndex: number +): [number, number] { + const [columns, rows] = scenario.gridSize ?? [3, 2]; + const bounds = scenario.bounds ?? [-2, -2, 2, 2]; + const x = positions[vertexIndex * 2]; + const y = positions[vertexIndex * 2 + 1]; + const sourceColumn = getGridCoordinate(x, bounds[0], bounds[2], columns); + const sourceRow = getGridCoordinate(y, bounds[1], bounds[3], rows); + const cellWidth = (bounds[2] - bounds[0]) / columns; + const cellHeight = (bounds[3] - bounds[1]) / rows; + const diameterSquared = cellWidth * cellWidth + cellHeight * cellHeight; + const theta = scenario.theta ?? 0.6; + const radius = scenario.nearCellRadius ?? 1; + let forceX = 0; + let forceY = 0; + + for (const [cellIndex, vertices] of grid.cells.entries()) { + if (vertices.length === 0) continue; + const column = cellIndex % columns; + const row = Math.floor(cellIndex / columns); + const isNear = Math.max(Math.abs(column - sourceColumn), Math.abs(row - sourceRow)) <= radius; + const centerX = grid.centers[cellIndex * 2]; + const centerY = grid.centers[cellIndex * 2 + 1]; + const centerDistanceX = x - centerX; + const centerDistanceY = y - centerY; + const centerDistanceSquared = + centerDistanceX * centerDistanceX + centerDistanceY * centerDistanceY; + const approximate = + !isNear && theta > 0 && diameterSquared < theta * theta * centerDistanceSquared; + + if (approximate) { + const denominator = Math.max(centerDistanceSquared, MINIMUM_SQUARED_DISTANCE); + forceX += ((scenario.repulsion ?? 1) * vertices.length * centerDistanceX) / denominator; + forceY += ((scenario.repulsion ?? 1) * vertices.length * centerDistanceY) / denominator; + continue; + } + + for (const neighbor of vertices) { + if (neighbor === vertexIndex) continue; + const distanceX = x - positions[neighbor * 2]; + const distanceY = y - positions[neighbor * 2 + 1]; + const denominator = Math.max( + distanceX * distanceX + distanceY * distanceY, + MINIMUM_SQUARED_DISTANCE + ); + forceX += ((scenario.repulsion ?? 1) * distanceX) / denominator; + forceY += ((scenario.repulsion ?? 1) * distanceY) / denominator; + } + } + return [forceX, forceY]; +} + +function getSeededCoordinate(seed: number, coordinateIndex: number): number { + let hashed = (seed ^ coordinateIndex) >>> 0; + hashed ^= hashed >>> 16; + hashed = Math.imul(hashed, 0x7feb352d) >>> 0; + hashed ^= hashed >>> 15; + hashed = Math.imul(hashed, 0x846ca68b) >>> 0; + hashed ^= hashed >>> 16; + return (2 * (hashed & 0x00ffffff)) / 16777216 - 1; +} + +function createExecutionFixture( + device: Device, + scenario: SpatialScenario, + expected: ExpectedSpatialLayout +): SpatialExecutionFixture { + const buffers: Buffer[] = []; + const vectors: GPUVector[] = []; + const sourceVertices = createInputVector( + device, + buffers, + vectors, + 'source-vertices', + 'uint32', + scenario.sourceChunks + ); + const targetVertices = createInputVector( + device, + buffers, + vectors, + 'target-vertices', + 'uint32', + scenario.targetChunks + ); + const edgeWeights = scenario.weightChunks + ? createInputVector( + device, + buffers, + vectors, + 'source-weights', + 'float32', + scenario.weightChunks + ) + : undefined; + const directed = scenario.directed ?? true; + const graph = new LuGraph({ + vertexCount: scenario.vertexCount, + sourceVertices, + targetVertices, + edgeWeights, + directed + }); + const forward = createOutputAdjacency( + device, + buffers, + vectors, + 'forward', + scenario.vertexCount, + scenario.capacity ?? expected.forwardCount, + Boolean(edgeWeights), + scenario.byteOffset + ); + const reverse = directed + ? createOutputAdjacency( + device, + buffers, + vectors, + 'reverse', + scenario.vertexCount, + scenario.reverseCapacity ?? expected.reverseCount, + Boolean(edgeWeights), + scenario.byteOffset + ) + : undefined; + const topology = new LuGraphTopology({ + graph, + forward, + reverse, + invalidEdgeCount: createScalarVector(device, buffers, vectors, 'invalid-edges', 'uint32', 1) + }); + const positions = createCoordinateVector( + device, + buffers, + vectors, + 'render-positions', + scenario.positions, + scenario.byteOffset, + true + ); + const velocities = createCoordinateVector( + device, + buffers, + vectors, + 'layout-velocities', + scenario.velocities ?? new Array(scenario.vertexCount * 2).fill(0), + scenario.byteOffset + ); + const pinned = scenario.pinned + ? createScalarVector( + device, + buffers, + vectors, + 'pinned-vertices', + 'uint32', + scenario.vertexCount, + { + values: scenario.pinned, + byteOffset: scenario.byteOffset + } + ) + : undefined; + const reset = + scenario.reset !== undefined + ? createScalarVector(device, buffers, vectors, 'layout-reset', 'uint32', 1, { + values: [scenario.reset], + byteOffset: scenario.byteOffset + }) + : undefined; + const layout = new LuGraphForceLayout({ + topology, + positions, + velocities, + pinned, + reset, + seed: scenario.seed, + iterationsPerFrame: scenario.iterationsPerFrame, + repulsion: scenario.repulsion, + attraction: scenario.attraction, + gravity: scenario.gravity, + damping: scenario.damping, + maxVelocity: scenario.maxVelocity, + timeStep: scenario.timeStep + }); + const gridSize = scenario.gridSize ?? [3, 2]; + const cellCount = gridSize[0] * gridSize[1]; + const spatial = new LuGraphSpatialForceLayout({ + layout, + gridSize, + bounds: scenario.bounds ?? [-2, -2, 2, 2], + theta: scenario.theta, + nearCellRadius: scenario.nearCellRadius, + cellOffsets: createScalarVector( + device, + buffers, + vectors, + 'cell-offsets', + 'uint32', + cellCount + 1, + { + byteOffset: scenario.byteOffset + } + ), + vertexIds: createScalarVector( + device, + buffers, + vectors, + 'vertex-ids', + 'uint32', + scenario.vertexCapacity ?? scenario.vertexCount, + {byteOffset: scenario.byteOffset} + ), + cellCenters: createCoordinateVector( + device, + buffers, + vectors, + 'cell-centers', + new Array(cellCount * 2).fill(0), + scenario.byteOffset + ), + count: createScalarVector(device, buffers, vectors, 'indexed-count', 'uint32', 1, { + byteOffset: scenario.byteOffset + }), + overflow: createScalarVector(device, buffers, vectors, 'index-overflow', 'uint32', 1, { + byteOffset: scenario.byteOffset + }) + }); + + return { + device, + buffers, + vectors, + graph, + topology, + layout, + spatial, + commandGraph: new GPUCommandGraph(device) + }; +} + +function createInputVector( + device: Device, + buffers: Buffer[], + vectors: GPUVector[], + name: string, + format: Format, + chunks: readonly number[][] +): GPUVector { + const data = chunks.map((chunk, chunkIndex) => { + const values = format === 'float32' ? Float32Array.from(chunk) : Uint32Array.from(chunk); + const buffer = device.createBuffer({ + id: `${name}-chunk-${chunkIndex}`, + data: values.length > 0 ? values : new Uint32Array(1), + usage: Buffer.STORAGE | Buffer.COPY_DST + }); + buffers.push(buffer); + return new GPUData({buffer, format, length: values.length, ownsBuffer: false}); + }); + const vector = new GPUVector({type: 'data', name, format, data, ownsData: false}); + vectors.push(vector); + return vector; +} + +function createOutputAdjacency( + device: Device, + buffers: Buffer[], + vectors: GPUVector[], + name: string, + vertexCount: number, + capacity: number, + weighted: boolean, + byteOffset = 0 +): LuGraphAdjacency { + return { + offsets: createScalarVector( + device, + buffers, + vectors, + `${name}-offsets`, + 'uint32', + vertexCount + 1, + { + byteOffset + } + ), + neighbors: createScalarVector( + device, + buffers, + vectors, + `${name}-neighbors`, + 'uint32', + capacity + ), + edgeIds: createScalarVector(device, buffers, vectors, `${name}-edge-ids`, 'uint32', capacity), + edgeWeights: weighted + ? createScalarVector(device, buffers, vectors, `${name}-weights`, 'float32', capacity) + : undefined, + count: createScalarVector(device, buffers, vectors, `${name}-count`, 'uint32', 1), + overflow: createScalarVector(device, buffers, vectors, `${name}-overflow`, 'uint32', 1) + }; +} + +function createScalarVector( + device: Device, + buffers: Buffer[], + vectors: GPUVector[], + name: string, + format: Format, + length: number, + options: {values?: number[]; byteOffset?: number} = {} +): GPUVector { + const byteOffset = options.byteOffset ?? 0; + const buffer = device.createBuffer({ + id: name, + byteLength: byteOffset + Math.max(length, 1) * Uint32Array.BYTES_PER_ELEMENT, + usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST + }); + if (options.values?.length) { + buffer.write( + format === 'float32' ? Float32Array.from(options.values) : Uint32Array.from(options.values), + byteOffset + ); + } + buffers.push(buffer); + const vector = new GPUVector({ + type: 'buffer', + name, + format, + buffer, + length, + byteOffset, + ownsBuffer: false + }); + vectors.push(vector); + return vector; +} + +function createCoordinateVector( + device: Device, + buffers: Buffer[], + vectors: GPUVector[], + name: string, + values: number[], + byteOffset = 0, + renderable = false +): GPUVector<'float32x2'> { + const buffer = device.createBuffer({ + id: name, + byteLength: byteOffset + Math.max(values.length, 2) * Float32Array.BYTES_PER_ELEMENT, + usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST | (renderable ? Buffer.VERTEX : 0) + }); + if (values.length) buffer.write(Float32Array.from(values), byteOffset); + buffers.push(buffer); + const vector = new GPUVector<'float32x2'>({ + type: 'buffer', + name, + format: 'float32x2', + buffer, + length: values.length / 2, + byteOffset, + ownsBuffer: false + }); + vectors.push(vector); + return vector; +} + +function compileSpatialLayout(fixture: SpatialExecutionFixture, maximumWorkgroups?: number): void { + fixture.topology.addToGraph(fixture.commandGraph); + if (maximumWorkgroups === undefined) { + fixture.spatial.addToGraph(fixture.commandGraph); + } else { + addLuGraphSpatialForceLayoutToGraphWithDispatchLimit( + fixture.spatial, + fixture.commandGraph, + maximumWorkgroups + ); + } + fixture.compiled = fixture.commandGraph.compile(); +} + +function executeSpatialLayout(fixture: SpatialExecutionFixture): void { + const commandEncoder = fixture.device.createCommandEncoder({ + id: 'lu-graph-spatial-force-layout-test' + }); + fixture.compiled!.encode(commandEncoder, {parameters: undefined}); + fixture.device.submit(commandEncoder.finish()); +} + +async function assertSpatialLayout( + tapeTest: Test, + fixture: SpatialExecutionFixture, + scenario: SpatialScenario, + expected: ExpectedSpatialLayout +): Promise { + const [ + positions, + velocities, + offsets, + vertexIds, + centers, + count, + overflow, + reset, + invalid, + topologyOverflow, + reverseOverflow + ] = await Promise.all([ + readCoordinateVector(fixture.layout.positions), + readCoordinateVector(fixture.layout.velocities), + readUint32Vector(fixture.spatial.cellOffsets), + readUint32Vector(fixture.spatial.vertexIds), + readCoordinateVector(fixture.spatial.cellCenters), + readUint32Vector(fixture.spatial.count), + readUint32Vector(fixture.spatial.overflow), + fixture.layout.reset ? readUint32Vector(fixture.layout.reset) : Promise.resolve(undefined), + readUint32Vector(fixture.topology.invalidEdgeCount), + readUint32Vector(fixture.topology.forward.overflow), + fixture.topology.reverse + ? readUint32Vector(fixture.topology.reverse.overflow) + : Promise.resolve(undefined) + ]); + + assertClose( + tapeTest, + positions, + expected.positions, + 'accelerated GPU coordinates match CPU near/far physics' + ); + assertClose( + tapeTest, + velocities, + expected.velocities, + 'accelerated GPU velocities preserve exact attraction and damping' + ); + tapeTest.deepEqual( + offsets, + expected.grid.offsets, + 'row-major cell offsets retain all accepted vertices' + ); + assertClose( + tapeTest, + centers, + expected.grid.centers, + 'nonempty caller-owned cell centers equal true floating centroids' + ); + tapeTest.equal( + count[0], + expected.grid.acceptedCount, + 'spatial index reports full in-domain vertex count' + ); + tapeTest.equal( + overflow[0], + Number(expected.grid.overflow), + 'spatial ID capacity overflow stays explicit' + ); + tapeTest.equal(invalid[0], expected.invalidEdgeCount, 'invalid source edges remain excluded'); + tapeTest.equal( + topologyOverflow[0], + Number(expected.forwardOverflow), + 'forward topology capacity is explicit' + ); + if (reverseOverflow) { + tapeTest.equal( + reverseOverflow[0], + Number(expected.reverseOverflow), + 'reverse topology capacity is explicit' + ); + } + if (reset) tapeTest.equal(reset[0], 0, 'one-shot deterministic reset is consumed on the GPU'); + + for (const [cellIndex, expectedVertices] of expected.grid.cells.entries()) { + const first = offsets[cellIndex]; + const last = Math.min(offsets[cellIndex + 1], vertexIds.length); + const actual = vertexIds + .slice(Math.min(first, vertexIds.length), last) + .sort((left, right) => left - right); + const expectedStored = expectedVertices + .slice(0, Math.max(0, last - first)) + .sort((left, right) => left - right); + tapeTest.deepEqual( + actual, + expectedStored, + 'atomic cell placement preserves every stored stable vertex ID' + ); + } + + if (expected.failed) { + tapeTest.deepEqual( + positions, + Array.from(new Float32Array(scenario.positions)), + 'invalid topology or index preserves render coordinates' + ); + tapeTest.ok( + velocities.every(velocity => velocity === 0), + 'invalid topology or index clears progressive velocities' + ); + } + + for (const [vertexIndex, pinned] of (scenario.pinned ?? []).entries()) { + if (pinned) { + tapeTest.equal( + positions[vertexIndex * 2], + scenario.positions[vertexIndex * 2], + 'pinned x never moves' + ); + tapeTest.equal( + positions[vertexIndex * 2 + 1], + scenario.positions[vertexIndex * 2 + 1], + 'pinned y never moves' + ); + tapeTest.equal(velocities[vertexIndex * 2], 0, 'pinned horizontal velocity remains zero'); + tapeTest.equal(velocities[vertexIndex * 2 + 1], 0, 'pinned vertical velocity remains zero'); + } + } +} + +function assertClose(tapeTest: Test, actual: number[], expected: number[], message: string): void { + const largestError = actual.reduce( + (largest, value, index) => Math.max(largest, Math.abs(value - expected[index])), + 0 + ); + tapeTest.ok( + largestError <= SPATIAL_TOLERANCE, + `${message} within ${SPATIAL_TOLERANCE}: ${largestError}` + ); +} + +async function readUint32Vector(vector: GPUVector<'uint32'>): Promise { + if (vector.length === 0) return []; + const chunk = vector.data[0]; + const bytes = await (chunk.buffer as Buffer).readAsync(chunk.byteOffset, vector.length * 4); + return Array.from(new Uint32Array(bytes.buffer, bytes.byteOffset, vector.length)); +} + +async function readCoordinateVector(vector: GPUVector<'float32x2'>): Promise { + if (vector.length === 0) return []; + const chunk = vector.data[0]; + const bytes = await (chunk.buffer as Buffer).readAsync(chunk.byteOffset, vector.length * 8); + return Array.from(new Float32Array(bytes.buffer, bytes.byteOffset, vector.length * 2)); +} + +function destroyExecutionFixture(tapeTest: Test, fixture: SpatialExecutionFixture): void { + fixture.compiled?.destroy(); + for (const vector of fixture.vectors) vector.destroy(); + tapeTest.ok( + fixture.buffers.every(buffer => !buffer.destroyed), + 'destroying graph-owned grid scratch preserves every caller-owned topology, layout, and index allocation' + ); + for (const buffer of fixture.buffers) buffer.destroy(); +} diff --git a/test/examples/lugraph-docs.node.spec.ts b/test/examples/lugraph-docs.node.spec.ts index 578052e515..bc2b36b7c2 100644 --- a/test/examples/lugraph-docs.node.spec.ts +++ b/test/examples/lugraph-docs.node.spec.ts @@ -110,7 +110,8 @@ describe('luGraph GPU-resident graph analytics documentation', () => { 'LuGraphBreadthFirstSearch', 'LuGraphConnectedComponents', 'LuGraphPageRank', - 'LuGraphForceLayout' + 'LuGraphForceLayout', + 'LuGraphSpatialForceLayout' ]) { expect(graphDocumentation, graphOperation).toContain(graphOperation); } @@ -121,6 +122,10 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(packageDocumentation).toContain('weakly connected components'); expect(packageDocumentation).toContain('normalized PageRank'); expect(packageDocumentation).toContain('progressive exact force-directed layout'); + expect(packageDocumentation).toContain('LuGraphSpatialForceLayout'); + expect(packageDocumentation).toContain('## Overview'); + expect(packageDocumentation).toContain('## When to use luGraph'); + expect(packageDocumentation).toContain('flat-grid approximation'); expect(graphDocumentation).toContain("from '@luma.gl/experimental/lugraph';"); expect(graphDocumentation).toContain('topology.addToGraph(workflow);'); expect(graphDocumentation).toContain('const compiled = workflow.compile();'); @@ -177,6 +182,61 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(graphDocumentation).toContain('new LuGraphForceLayout({'); }); + test('explains optional spatial approximation, practical use cases, and honest scaling', () => { + expect(graphDocumentation).toContain( + '## Approximate distant forces with LuGraphSpatialForceLayout' + ); + expect(graphDocumentation).toContain('**Question: How can I make a larger relationship map'); + expect(graphDocumentation).toContain('nearby pedestrians need individual attention'); + expect(graphDocumentation).toContain('interactive dependency map'); + expect(graphDocumentation).toContain('transaction investigation'); + expect(graphDocumentation).toContain('### Accuracy and spatial controls'); + expect(graphDocumentation).toContain('### Bounds, buffers, and failure behavior'); + expect(graphDocumentation).toContain('### Cost and when acceleration helps'); + expect(graphDocumentation).toContain('population-weighted center of mass'); + expect(graphDocumentation).toContain('`cellDiagonal / distanceToCellCenter < theta`'); + expect(graphDocumentation).toContain('default `theta: 0.6`'); + expect(graphDocumentation).toContain('`theta: 0`'); + expect(graphDocumentation).toContain('`nearCellRadius`'); + expect(graphDocumentation).toContain('up to eight surrounding cells'); + expect(graphDocumentation).toContain('No distant vertex is silently dropped'); + expect(graphDocumentation).toContain('flat uniform-grid monopole approximation'); + expect(graphDocumentation).toContain('not hierarchical'); + expect(graphDocumentation).toContain('Barnes–Hut, ForceAtlas2'); + expect(graphDocumentation).toContain('not guarantee subquadratic complexity'); + expect(graphDocumentation).toContain('`Θ(V × G + P + E)`'); + expect(graphDocumentation).toContain('`Θ(V + G)` caller-owned grid storage'); + expect(graphDocumentation).toContain('worst case can return to `Θ(V² + E)`'); + expect(graphDocumentation).toContain('without floating-point atomics'); + }); + + test('documents explicit spatial buffers, complete indexing, and fail-closed ownership', () => { + expect(graphDocumentation).toContain( + 'inclusive `bounds: [minimumX, minimumY, maximumX, maximumY]`' + ); + expect(graphDocumentation).toContain("`cellOffsets`: `GPUVector<'uint32'>`"); + expect(graphDocumentation).toContain('exactly `G + 1` rows'); + expect(graphDocumentation).toContain("`vertexIds`: `GPUVector<'uint32'>`"); + expect(graphDocumentation).toContain("`cellCenters`: `GPUVector<'float32x2'>`"); + expect(graphDocumentation).toContain('exactly `G` rows'); + expect(graphDocumentation).toContain("`count`: a one-row `GPUVector<'uint32'>`"); + expect(graphDocumentation).toContain("`overflow`: a one-row `GPUVector<'uint32'>`"); + expect(graphDocumentation).toContain('physically distinct buffer allocations'); + expect(graphDocumentation).toContain('on every spatial force iteration'); + expect(graphDocumentation).toContain('Bounds do not'); + expect(graphDocumentation).toContain('expand automatically'); + expect(graphDocumentation).toContain('`count` smaller than `vertexCount`'); + expect(graphDocumentation).toContain('it preserves every existing position and clears all'); + expect(graphDocumentation).toContain( + 'existing edge-weight columns remain intentionally unused' + ); + expect(graphDocumentation).toContain('new LuGraphSpatialForceLayout({'); + expect(graphDocumentation).toContain('spatialLayout.addToGraph(workflow);'); + expect(graphDocumentation).toContain( + 'Replace the spatial contributor with `layout.addToGraph(workflow)`' + ); + }); + test('preserves independent MIT ownership and accurate NVIDIA RAPIDS inspiration', () => { for (const documentation of [graphDocumentation, packageDocumentation]) { expect(documentation).toContain('NVIDIA RAPIDS cuGraph');