diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index 4982a6fb17..3a3e5c00f3 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -13,8 +13,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, and PageRank importance into -caller-owned GPU buffers. Every operation composes with the existing `GPUCommandGraph`. +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`. 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 @@ -31,8 +32,8 @@ luGraph keeps the complete intermediate pipeline on one WebGPU device: ```text Existing GPU edge columns -> compressed adjacency - -> degree / shortest paths / weak components / PageRank - -> caller-owned GPU result columns + -> degree / shortest paths / weak components / PageRank / force layout + -> caller-owned GPU result columns and directly renderable positions ``` The original source and target chunks keep their identities, including empty batches. Adjacency and @@ -51,7 +52,8 @@ Use luGraph for browser applications that already own typed GPU relationship col combine graph analytics with further GPU work: - **Social and communication networks:** count contacts, highlight friends within a bounded number - of introductions, group disconnected networks, and rank influential accounts. + of introductions, group disconnected networks, rank influential accounts, and arrange connected + people into a readable map. - **Software and service dependencies:** follow incoming or outgoing dependency chains, find isolated dependency islands, and identify packages that many important packages depend on. - **Transaction and fraud investigations:** follow transfers around a selected account, identify @@ -63,8 +65,9 @@ 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. -luGraph currently operates on one browser WebGPU device and intentionally does not provide those -features. +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. ## Choose the right graph operation @@ -76,6 +79,7 @@ features. | `LuGraphBreadthFirstSearch` | Which vertices are within a chosen number of unweighted hops? | Distances, deterministic predecessors, and an optional selection mask | At most `O(D × (V + E))` for `D` compiled hops | | `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 | `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. @@ -306,6 +310,82 @@ signal, not an automatic convergence threshold, early-termination mechanism, or fixed budget reached the stationary distribution. Reductions use portable WebGPU workgroups and ordinary `float32` arithmetic, not floating-point atomics or native GPU `float64`. +## Reveal relationships with LuGraphForceLayout + +**Question: How can I position connected entities so the structure of their relationships becomes +visible?** + +`LuGraphForceLayout` progressively assigns two-dimensional positions to graph vertices. Imagine +every vertex pushing away from every other vertex while each edge acts like a spring pulling its +two endpoints together. Over successive frames, tightly connected entities move closer together, +unrelated vertices spread apart, and a gentle pull toward the origin keeps the network in view. + +Use it to arrange a social graph so overlapping circles of friends become easier to inspect, map +service dependencies around their most connected systems, expose connected counterparties in a +transaction investigation, or turn a citation list into a navigable document network. Unlike +degree, connected components, and PageRank, which answer numerical questions about a vertex, force +layout answers where an application can draw that vertex. Your application still owns its +renderer, colors, labels, and interaction design. + +```ts +import {LuGraphForceLayout} from '@luma.gl/experimental/lugraph'; + +const layout = new LuGraphForceLayout({ + topology, + positions: nodePositions, + velocities: nodeVelocities, + pinned: pinnedVertices, + reset: resetRequested, + seed: 42, + iterationsPerFrame: 4, + repulsion: 1, + attraction: 0.1, + gravity: 0.01, + damping: 0.9, + maxVelocity: 1, + timeStep: 1 +}); +``` + +`positions` and `velocities` are distinct, caller-owned, packed `GPUVector<'float32x2'>` values +with one row per vertex. The physical position buffer must have both `Buffer.STORAGE` and +`Buffer.VERTEX` usage: compute updates the exact same allocation that an application can bind as a +render vertex attribute. Velocity storage requires `Buffer.STORAGE`. There is no intermediate +position copy, CPU coordinate readback, or graph-owned layout scratch buffer. + +Every exact iteration evaluates repulsion against all other vertices, equal-strength attraction +over every incident edge, gravity toward the origin, velocity damping, and a configurable maximum +speed. The force pass finishes for every vertex before a separate integration pass writes the next +positions. This globally ordered separation keeps the old position field stable during force +evaluation without requiring floating-point atomics. + +Edges pull both endpoints together even when the source graph is directed, so directed layout +requires both forward and reverse adjacency. Undirected layout reuses symmetric forward adjacency. +Existing edge weights are preserved by topology but intentionally ignored by this unweighted +spring model; a duplicate edge contributes another spring and a self-loop adds no displacement. + +Set a vertex's optional `uint32` `pinned` row to any nonzero value to preserve its current position +and clear its velocity. This supports dragging a node into place, holding a selected account +steady, or anchoring known reference vertices while their neighbors continue to settle. + +Writing a nonzero value to the optional one-row `uint32` `reset` vector requests deterministic +initialization from `seed` and clears existing velocities. Pinned coordinates remain unchanged, +and the GPU consumes the reset request by clearing it. Subsequent encodings warm-start from the +current positions and velocities instead of restarting the simulation on every frame. + +The defaults are `seed: 0`, `iterationsPerFrame: 4`, `repulsion: 1`, `attraction: 0.1`, +`gravity: 0.01`, `damping: 0.9`, `maxVelocity: 1`, and `timeStep: 1`. Increase the per-frame step +count when visual responsiveness matters more than frame cost; lower it when other GPU work needs +the same frame budget. If required forward or reverse adjacency overflows, the layout preserves +every existing position and clears all velocities rather than drawing a misleading partial graph. + +Each force step performs exact `O(V² + E)` work: doubling the vertex count roughly quadruples the +all-pairs repulsion. Choose this layout for graph sizes where exact pairwise interactions fit the +available frame budget, especially when the result is consumed directly by GPU rendering. Avoid +it for very large networks, already meaningful geographic coordinates, or applications requiring +weighted springs. This implementation does not approximate pairwise interactions and does not +claim to implement ForceAtlas2 or Barnes–Hut. + ## Compose one GPU-resident workflow All graph contributors add work to the same caller-owned `GPUCommandGraph`. The following example @@ -319,6 +399,7 @@ import { LuGraphBreadthFirstSearch, LuGraphConnectedComponents, LuGraphDegree, + LuGraphForceLayout, LuGraphPageRank, LuGraphTopology } from '@luma.gl/experimental/lugraph'; @@ -375,6 +456,14 @@ new LuGraphPageRank({ iterations: 40, residual: finalRankChange }).addToGraph(workflow); +new LuGraphForceLayout({ + topology, + positions: nodePositions, + velocities: nodeVelocities, + pinned: pinnedVertices, + reset: resetRequested, + iterationsPerFrame: 4 +}).addToGraph(workflow); const compiled = workflow.compile(); const encoder = device.createCommandEncoder({id: 'analyze-network'}); @@ -385,7 +474,8 @@ device.submit(encoder.finish()); Constructors validate existing metadata; they do not upload graph data, submit commands, or read 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. +results from the current source and control buffers while progressively advancing the existing +layout positions and velocities. ## Ownership, capacity, and failure boundaries @@ -395,8 +485,11 @@ results from the current source and control buffers. `DynamicBuffer` wrapper exposes the same underlying allocation through different views. - Adjacency capacities and overflow statuses are explicit. Breadth-first search fails closed to unreachable distances, weak components publish `0xffffffff`, and PageRank publishes zero scores - when a required neighbor list overflowed. + when a required neighbor list overflowed. Force layout preserves its existing positions and + clears velocities on required adjacency overflow. - 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. - Fixed component and PageRank iteration budgets do not imply convergence. Their optional status and final-change outputs remain GPU-resident until an application explicitly requests readback. - Work uses bounded WebGPU dispatch and portable storage bindings on one device. Original chunk diff --git a/examples/experimental/gpu-trace-viewer/app.ts b/examples/experimental/gpu-trace-viewer/app.ts index 636f6815cf..235272f8e5 100644 --- a/examples/experimental/gpu-trace-viewer/app.ts +++ b/examples/experimental/gpu-trace-viewer/app.ts @@ -1119,22 +1119,22 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe workgroupSize: 1 }); for (const chunk of handles.spanChunks) { - addTraceIndirectComputePass(graph, { + addTraceIndirectComputePass(graph, { id: `trace-candidate-span-visibility-${chunk.chunkIndex}`, source: getCandidateVisibilityShader(chunk), - bindings: [ + bindings: [ storageRead('spans', chunk.spans), - storageRead('spanBatches', handles.spanBatchIndex), - storageRead('candidateBatchIds', handles.candidateBatchIds), - uniformBinding('viewUniforms', handles.uniforms), - storageRead('processStates', handles.processStates), - storageRead('threadOffsets', handles.threadOffsets), - storageRead('threadStates', handles.threadStates), - storageRead('reachedSpans', handles.reachedSpans), - storageWrite('visibilityFlags', handles.spanVisibility) - ], - dispatchBuffer: handles.exactCandidateDispatchCommands - }); + storageRead('spanBatches', handles.spanBatchIndex), + storageRead('candidateBatchIds', handles.candidateBatchIds), + uniformBinding('viewUniforms', handles.uniforms), + storageRead('processStates', handles.processStates), + storageRead('threadOffsets', handles.threadOffsets), + storageRead('threadStates', handles.threadStates), + storageRead('reachedSpans', handles.reachedSpans), + storageWrite('visibilityFlags', handles.spanVisibility) + ], + dispatchBuffer: handles.exactCandidateDispatchCommands + }); } addTraceComputePass(graph, { id: 'trace-clear-density', @@ -1144,37 +1144,37 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe workgroupSize: TRACE_WORKGROUP_SIZE }); for (const chunk of handles.spanChunks) { - addTraceIndirectComputePass(graph, { + addTraceIndirectComputePass(graph, { id: `trace-candidate-density-${chunk.chunkIndex}`, source: getCandidateDensityShader(chunk), - bindings: [ + bindings: [ storageRead('spans', chunk.spans), - storageRead('spanBatches', handles.spanBatchIndex), - storageRead('candidateBatchIds', handles.candidateBatchIds), - uniformBinding('viewUniforms', handles.uniforms), - storageRead('processStates', handles.processStates), - storageRead('threadOffsets', handles.threadOffsets), - storageRead('threadStates', handles.threadStates), - storageRead('reachedSpans', handles.reachedSpans), - storageWrite('densityBins', handles.densityBins) - ], - dispatchBuffer: handles.densityCandidateDispatchCommands - }); - addTraceIndirectComputePass(graph, { + storageRead('spanBatches', handles.spanBatchIndex), + storageRead('candidateBatchIds', handles.candidateBatchIds), + uniformBinding('viewUniforms', handles.uniforms), + storageRead('processStates', handles.processStates), + storageRead('threadOffsets', handles.threadOffsets), + storageRead('threadStates', handles.threadStates), + storageRead('reachedSpans', handles.reachedSpans), + storageWrite('densityBins', handles.densityBins) + ], + dispatchBuffer: handles.densityCandidateDispatchCommands + }); + addTraceIndirectComputePass(graph, { id: `trace-candidate-pick-${chunk.chunkIndex}`, source: getCandidatePickShader(chunk), - bindings: [ + bindings: [ storageRead('spans', chunk.spans), - storageRead('spanBatches', handles.spanBatchIndex), - storageRead('candidateBatchIds', handles.candidateBatchIds), - uniformBinding('viewUniforms', handles.uniforms), - storageRead('processStates', handles.processStates), - storageRead('threadOffsets', handles.threadOffsets), - storageRead('threadStates', handles.threadStates), - storageWrite('pickResult', handles.pickResult) - ], - dispatchBuffer: handles.pickCandidateDispatchCommands - }); + storageRead('spanBatches', handles.spanBatchIndex), + storageRead('candidateBatchIds', handles.candidateBatchIds), + uniformBinding('viewUniforms', handles.uniforms), + storageRead('processStates', handles.processStates), + storageRead('threadOffsets', handles.threadOffsets), + storageRead('threadStates', handles.threadStates), + storageWrite('pickResult', handles.pickResult) + ], + dispatchBuffer: handles.pickCandidateDispatchCommands + }); } const visibleSpanCountBuffer = graph.createTransientBuffer({ id: 'trace-visible-span-count', @@ -1357,18 +1357,18 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe } if (resources.dependencyCount > 0) { - encoder.setPipeline(this.dependencyModel.pipeline); - encoder.setVertexArray(this.dependencyModel.vertexArray); - encoder.setBindings({ - dependencies: resources.dependencies, - visibleDependencyIds: resources.visibleDependencyIds, + encoder.setPipeline(this.dependencyModel.pipeline); + encoder.setVertexArray(this.dependencyModel.vertexArray); + encoder.setBindings({ + dependencies: resources.dependencies, + visibleDependencyIds: resources.visibleDependencyIds, spans: resources.spanChunks[0].buffer, - processStates: resources.processStates, - threadStates: resources.threadStates, - threadOffsets: resources.threadOffsets, - dependencyResults: resources.dependencyResults, - viewUniforms: this.viewUniformBuffer - }); + processStates: resources.processStates, + threadStates: resources.threadStates, + threadOffsets: resources.threadOffsets, + dependencyResults: resources.dependencyResults, + viewUniforms: this.viewUniformBuffer + }); resources.drawCommands.draw(encoder, resources.dependencyDrawCommandIndex); } diff --git a/modules/experimental/src/lugraph/README.md b/modules/experimental/src/lugraph/README.md index e02db6a9d7..a75c1deb88 100644 --- a/modules/experimental/src/lugraph/README.md +++ b/modules/experimental/src/lugraph/README.md @@ -5,9 +5,10 @@ optional, headless graph model preserves existing source and target vertex colum 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, and normalized PageRank with dangling-vertex redistribution. Those -operations contribute work to a caller-owned `GPUCommandGraph`; applications retain ownership of -their buffers, rendering, command submission, and any explicitly requested result readback. +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. 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 de09f77600..dc0f95b60c 100644 --- a/modules/experimental/src/lugraph/index.ts +++ b/modules/experimental/src/lugraph/index.ts @@ -18,3 +18,5 @@ export {LuGraphConnectedComponents} from './lu-graph-connected-components'; export type {LuGraphConnectedComponentsProps} from './lu-graph-connected-components'; 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'; diff --git a/modules/experimental/src/lugraph/lu-graph-force-layout-internals.ts b/modules/experimental/src/lugraph/lu-graph-force-layout-internals.ts new file mode 100644 index 0000000000..7ba5ba0cfc --- /dev/null +++ b/modules/experimental/src/lugraph/lu-graph-force-layout-internals.ts @@ -0,0 +1,520 @@ +// 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 {getViewBinding, getViewElementOffset} from '../gpu-primitives/graph-data-view-utils'; +import type {LuGraphForceLayout} from './lu-graph-force-layout'; + +const FORCE_LAYOUT_WORKGROUP_SIZE = 256; +const MINIMUM_REPULSION_DISTANCE_SQUARED = 0.0001; + +type ForceLayoutDataView = GraphDataView<'uint32'> | GraphDataView<'float32x2'>; + +type ImportedForceLayout = { + id: string; + vertexCount: 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'>; + overflow: GraphDataView<'uint32'>; + reverseOffsets?: GraphDataView<'uint32'>; + reverseNeighbors?: GraphDataView<'uint32'>; + reverseOverflow?: GraphDataView<'uint32'>; + maxComputeWorkgroupsPerDimension: number; +}; + +type ForceLayoutBinding = { + view: ForceLayoutDataView; + usage: GraphBufferUse['usage']; +}; + +type ForceLayoutPassProps = { + id: string; + source: string; + bindings: Record; + dispatchLayout: GPUBoundedDispatchLayout; +}; + +/** Adds exact tiled force integration using an explicit bounded dispatch limit. @internal */ +export function addLuGraphForceLayoutToGraphWithDispatchLimit( + layout: LuGraphForceLayout, + commandGraph: GPUCommandGraph, + maxComputeWorkgroupsPerDimension: number +): void { + if (layout.topology.graph.vertexCount === 0 && !layout.reset) { + return; + } + + const directed = layout.topology.graph.directed; + const reverse = layout.topology.reverse; + const state: ImportedForceLayout = { + id: layout.id, + vertexCount: layout.topology.graph.vertexCount, + seed: layout.seed, + repulsion: layout.repulsion, + attraction: layout.attraction, + gravity: layout.gravity, + damping: layout.damping, + maxVelocity: layout.maxVelocity, + timeStep: layout.timeStep, + positions: commandGraph.importGPUVector(`${layout.id}-positions`, layout.positions).data[0], + velocities: commandGraph.importGPUVector(`${layout.id}-velocities`, layout.velocities).data[0], + ...(layout.pinned + ? {pinned: commandGraph.importGPUVector(`${layout.id}-pinned`, layout.pinned).data[0]} + : {}), + ...(layout.reset + ? {reset: commandGraph.importGPUVector(`${layout.id}-reset`, layout.reset).data[0]} + : {}), + forwardOffsets: commandGraph.importGPUVector( + `${layout.id}-forward-offsets`, + layout.topology.forward.offsets + ).data[0], + forwardNeighbors: commandGraph.importGPUVector( + `${layout.id}-forward-neighbors`, + layout.topology.forward.neighbors + ).data[0], + overflow: commandGraph.importGPUVector( + `${layout.id}-forward-overflow`, + layout.topology.forward.overflow + ).data[0], + ...(directed && reverse + ? { + reverseOffsets: commandGraph.importGPUVector( + `${layout.id}-reverse-offsets`, + reverse.offsets + ).data[0], + reverseNeighbors: commandGraph.importGPUVector( + `${layout.id}-reverse-neighbors`, + reverse.neighbors + ).data[0], + reverseOverflow: commandGraph.importGPUVector( + `${layout.id}-reverse-overflow`, + reverse.overflow + ).data[0] + } + : {}), + maxComputeWorkgroupsPerDimension + }; + + if (state.reset && state.vertexCount > 0) { + addInitializationPass(commandGraph, state); + } + if (state.reset) { + addResetClearPass(commandGraph, state); + } + if (state.vertexCount === 0) { + return; + } + + for (let iteration = 0; iteration < layout.iterationsPerFrame; iteration++) { + addForcePass(commandGraph, {state, iteration}); + addIntegrationPass(commandGraph, {state, iteration}); + } +} + +/** Deterministically initializes unpinned positions only when the caller requests a reset. */ +function addInitializationPass( + commandGraph: GPUCommandGraph, + state: ImportedForceLayout +): 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'}, + overflow: {view: state.overflow, 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 pinnedGuard = state.pinned ? 'pinned[PINNED_OFFSET + index] != 0u' : 'false'; + const dispatchLayout = getLuGraphForceLayoutDispatchLayout( + state.vertexCount, + state.maxComputeWorkgroupsPerDimension + ); + 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 OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.overflow)}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(${FORCE_LAYOUT_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, FORCE_LAYOUT_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; + let hasOverflow = overflow[OVERFLOW_OFFSET] != 0u${reverseOverflow}; + if (hasOverflow || ${pinnedGuard}) { 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; +}`; + + addForceLayoutPass(commandGraph, { + id: `${state.id}-initialize`, + source, + bindings, + dispatchLayout + }); +} + +/** Clears the one-shot caller-owned reset control after all initialization invocations finish. */ +function addResetClearPass( + commandGraph: GPUCommandGraph, + state: ImportedForceLayout +): void { + const reset = state.reset!; + const bindings: Record = { + reset: {view: reset, usage: 'storage-write'} + }; + const dispatchLayout = getLuGraphForceLayoutDispatchLayout( + 1, + state.maxComputeWorkgroupsPerDimension + ); + const source = /* wgsl */ ` +const RESET_OFFSET: u32 = ${getViewElementOffset(reset)}u; +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${FORCE_LAYOUT_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, FORCE_LAYOUT_WORKGROUP_SIZE)} + if (index == 0u) { reset[RESET_OFFSET] = 0u; } +}`; + + addForceLayoutPass(commandGraph, { + id: `${state.id}-clear-reset`, + source, + bindings, + dispatchLayout + }); +} + +/** Evaluates exact tiled all-pairs repulsion and symmetric incident-edge attraction. */ +function addForcePass( + commandGraph: GPUCommandGraph, + props: {state: ImportedForceLayout; 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'}, + overflow: {view: state.overflow, usage: 'storage-read'}, + ...(state.reverseOffsets && state.reverseNeighbors && state.reverseOverflow + ? { + reverseOffsets: {view: state.reverseOffsets, usage: 'storage-read'}, + reverseNeighbors: {view: state.reverseNeighbors, usage: 'storage-read'}, + reverseOverflow: {view: state.reverseOverflow, usage: 'storage-read'} + } + : {}) + }; + const reverseConstants = + state.reverseOffsets && state.reverseNeighbors && state.reverseOverflow + ? `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 REVERSE_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.reverseOverflow)}u;` + : ''; + const reverseOverflow = state.reverseOverflow + ? ' || reverseOverflow[REVERSE_OVERFLOW_OFFSET] != 0u' + : ''; + 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 tileCount = Math.ceil(state.vertexCount / FORCE_LAYOUT_WORKGROUP_SIZE); + const dispatchLayout = getLuGraphForceLayoutDispatchLayout( + state.vertexCount, + state.maxComputeWorkgroupsPerDimension + ); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const TILE_COUNT: u32 = ${tileCount}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 OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.overflow)}u; +const REPULSION: f32 = ${state.repulsion}; +const ATTRACTION: f32 = ${state.attraction}; +const GRAVITY: f32 = ${state.gravity}; +const DAMPING: f32 = ${state.damping}; +const MAX_VELOCITY: f32 = ${state.maxVelocity}; +const TIME_STEP: f32 = ${state.timeStep}; +const MINIMUM_DISTANCE_SQUARED: f32 = ${MINIMUM_REPULSION_DISTANCE_SQUARED}; +${reverseConstants} +${getBindingDeclarations(bindings)} +var tilePositions: array, ${FORCE_LAYOUT_WORKGROUP_SIZE}>; + +fn readPosition(vertex: u32) -> vec2 { + let positionOffset = POSITIONS_OFFSET + vertex * 2u; + return vec2(positions[positionOffset], positions[positionOffset + 1u]); +} + +@compute @workgroup_size(${FORCE_LAYOUT_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, FORCE_LAYOUT_WORKGROUP_SIZE)} + if (workgroupIndex >= TILE_COUNT) { return; } + let isActiveVertex = index < VERTEX_COUNT; + let hasOverflow = overflow[OVERFLOW_OFFSET] != 0u${reverseOverflow}; + var position = vec2(0.0); + if (isActiveVertex) { position = readPosition(index); } + var force = -GRAVITY * position; + + for (var tile = 0u; tile < TILE_COUNT; tile++) { + let sourceVertex = tile * ${FORCE_LAYOUT_WORKGROUP_SIZE}u + localInvocationIndex; + if (sourceVertex < VERTEX_COUNT) { + tilePositions[localInvocationIndex] = readPosition(sourceVertex); + } else { + tilePositions[localInvocationIndex] = vec2(0.0); + } + workgroupBarrier(); + + if (isActiveVertex && !hasOverflow) { + let firstVertex = tile * ${FORCE_LAYOUT_WORKGROUP_SIZE}u; + let count = min(${FORCE_LAYOUT_WORKGROUP_SIZE}u, VERTEX_COUNT - firstVertex); + for (var localVertex = 0u; localVertex < count; localVertex++) { + if (firstVertex + localVertex != index) { + let difference = position - tilePositions[localVertex]; + let distanceSquared = max(dot(difference, difference), MINIMUM_DISTANCE_SQUARED); + force += REPULSION * difference / distanceSquared; + } + } + } + workgroupBarrier(); + } + + if (!isActiveVertex) { return; } + let velocityOffset = VELOCITIES_OFFSET + index * 2u; + if (hasOverflow) { + velocities[velocityOffset] = 0.0; + velocities[velocityOffset + 1u] = 0.0; + return; + } + + 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 previousVelocity = vec2(velocities[velocityOffset], velocities[velocityOffset + 1u]); + var velocity = (previousVelocity + 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; +}`; + + addForceLayoutPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-forces`, + source, + bindings, + dispatchLayout + }); +} + +/** Integrates one globally synchronized velocity field while preserving pinned positions. */ +function addIntegrationPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedForceLayout; 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'}, + overflow: {view: state.overflow, 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 = getLuGraphForceLayoutDispatchLayout( + state.vertexCount, + state.maxComputeWorkgroupsPerDimension + ); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const TIME_STEP: f32 = ${state.timeStep}; +const POSITIONS_OFFSET: u32 = ${getViewElementOffset(state.positions)}u; +const VELOCITIES_OFFSET: u32 = ${getViewElementOffset(state.velocities)}u; +const OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.overflow)}u; +${reverseOffset} +${pinnedOffset} +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${FORCE_LAYOUT_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, FORCE_LAYOUT_WORKGROUP_SIZE)} + if (index >= VERTEX_COUNT) { return; } + let velocityOffset = VELOCITIES_OFFSET + index * 2u; + let blocked = overflow[OVERFLOW_OFFSET] != 0u${reverseOverflow}${pinned}; + if (blocked) { + 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; +}`; + + addForceLayoutPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-integrate`, + source, + bindings, + dispatchLayout + }); +} + +/** Declares uint32 metadata and float32 scalar components in portable 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 compute pass without graph-owned scratch or hidden submission. */ +function addForceLayoutPass( + commandGraph: GPUCommandGraph, + props: ForceLayoutPassProps +): 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() + }; + } + }); +} + +/** Plans bounded three-dimensional position initialization and force integration. @internal */ +export function getLuGraphForceLayoutDispatchLayout( + elementCount: number, + maxComputeWorkgroupsPerDimension: number +): GPUBoundedDispatchLayout { + return getBoundedDispatchLayout( + 'LuGraphForceLayout', + elementCount, + FORCE_LAYOUT_WORKGROUP_SIZE, + maxComputeWorkgroupsPerDimension + ); +} diff --git a/modules/experimental/src/lugraph/lu-graph-force-layout.ts b/modules/experimental/src/lugraph/lu-graph-force-layout.ts new file mode 100644 index 0000000000..63015ddcd6 --- /dev/null +++ b/modules/experimental/src/lugraph/lu-graph-force-layout.ts @@ -0,0 +1,267 @@ +// 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 {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 {addLuGraphForceLayoutToGraphWithDispatchLimit} from './lu-graph-force-layout-internals'; +import type {LuGraphAdjacency, LuGraphTopology} from './lu-graph-topology'; + +const MAXIMUM_UINT32 = 0xffffffff; +const MAXIMUM_LAYOUT_ITERATIONS = 1024; +const SCALAR_BYTE_LENGTH = 4; + +/** Existing graph topology and caller-owned, directly renderable force-layout resources. */ +export type LuGraphForceLayoutProps = { + /** Prefix for generated command-graph nodes and imported resources. */ + id?: string; + /** Existing GPU-resident topology; directed graphs require reverse adjacency. */ + topology: LuGraphTopology; + /** Caller-owned, vertex-bindable packed two-component positions for every graph vertex. */ + positions: GPUVector<'float32x2'>; + /** Caller-owned packed two-component velocities for every graph vertex. */ + velocities: GPUVector<'float32x2'>; + /** Optional caller-owned vertex mask; nonzero rows preserve their current positions. */ + pinned?: GPUVector<'uint32'>; + /** Optional caller-owned scalar requesting deterministic initialization; consumed on encoding. */ + reset?: GPUVector<'uint32'>; + /** Unsigned seed used by deterministic GPU position initialization. Defaults to zero. */ + seed?: number; + /** Bounded number of synchronized force and integration steps per encode. Defaults to four. */ + iterationsPerFrame?: number; + /** Nonnegative strength of exact all-vertex repulsion. Defaults to one. */ + repulsion?: number; + /** Nonnegative strength of edge-based attraction. Defaults to 0.1. */ + attraction?: number; + /** Nonnegative attraction toward the coordinate origin. Defaults to 0.01. */ + gravity?: number; + /** Velocity-retention factor between zero and one. Defaults to 0.9. */ + damping?: number; + /** Positive maximum velocity magnitude applied during integration. Defaults to one. */ + maxVelocity?: number; + /** Positive integration time step. Defaults to one. */ + timeStep?: number; +}; + +/** + * Updates caller-owned, directly renderable graph positions entirely on the GPU. + * + * Each iteration computes exact `O(V² + E)` all-vertex repulsion and bidirectional edge + * attraction; directed topologies therefore require reverse adjacency. Existing edge weights are + * ignored. Separate globally synchronized force and integration passes avoid floating-point + * atomics and hidden scratch allocations. Repeated encodes preserve positions and velocities as + * warm starts unless the optional GPU reset scalar requests deterministic reinitialization. + * Pinned positions remain unchanged, and adjacency overflow preserves all positions while clearing + * velocities. + */ +export class LuGraphForceLayout { + /** Prefix for generated command-graph nodes and imported resources. */ + readonly id: string; + /** Existing caller-owned GPU graph topology. */ + readonly topology: LuGraphTopology; + /** Caller-owned, vertex-bindable packed two-component positions. */ + readonly positions: GPUVector<'float32x2'>; + /** Caller-owned packed two-component progressive velocities. */ + readonly velocities: GPUVector<'float32x2'>; + /** Optional caller-owned vertex mask preserving pinned positions. */ + readonly pinned?: GPUVector<'uint32'>; + /** Optional caller-owned, automatically consumed GPU initialization request. */ + readonly reset?: GPUVector<'uint32'>; + /** Unsigned seed used by deterministic GPU position initialization. */ + readonly seed: number; + /** Number of force and integration steps performed by each encoding. */ + readonly iterationsPerFrame: number; + /** Exact all-vertex repulsion strength. */ + readonly repulsion: number; + /** Forward and reverse edge-attraction strength. */ + readonly attraction: number; + /** Coordinate-origin attraction strength. */ + readonly gravity: number; + /** Velocity-retention factor. */ + readonly damping: number; + /** Maximum integrated velocity magnitude. */ + readonly maxVelocity: number; + /** Integration time step. */ + readonly timeStep: number; + + /** Validates caller-owned layout metadata without allocating, submitting, or reading GPU work. */ + constructor(props: LuGraphForceLayoutProps) { + this.id = props.id ?? 'lu-graph-force-layout'; + this.topology = props.topology; + this.positions = props.positions; + this.velocities = props.velocities; + this.pinned = props.pinned; + this.reset = props.reset; + this.seed = props.seed ?? 0; + this.iterationsPerFrame = props.iterationsPerFrame ?? 4; + this.repulsion = props.repulsion ?? 1; + this.attraction = props.attraction ?? 0.1; + this.gravity = props.gravity ?? 0.01; + this.damping = props.damping ?? 0.9; + this.maxVelocity = props.maxVelocity ?? 1; + this.timeStep = props.timeStep ?? 1; + + if (this.topology.graph.directed && !this.topology.reverse) { + throw new Error(`${this.id} directed force layout requires reverse adjacency`); + } + if (!Number.isSafeInteger(this.seed) || this.seed < 0 || this.seed > MAXIMUM_UINT32) { + throw new Error(`${this.id} seed must be an unsigned 32-bit integer`); + } + if ( + !Number.isSafeInteger(this.iterationsPerFrame) || + this.iterationsPerFrame < 1 || + this.iterationsPerFrame > MAXIMUM_LAYOUT_ITERATIONS + ) { + throw new Error(`${this.id} iterationsPerFrame must be an integer between one and 1024`); + } + validateNonNegativeParameter(this.repulsion, `${this.id} repulsion`); + validateNonNegativeParameter(this.attraction, `${this.id} attraction`); + validateNonNegativeParameter(this.gravity, `${this.id} gravity`); + if (!Number.isFinite(this.damping) || this.damping < 0 || this.damping > 1) { + throw new Error(`${this.id} damping must be a finite number between zero and one`); + } + validatePositiveParameter(this.maxVelocity, `${this.id} maxVelocity`); + validatePositiveParameter(this.timeStep, `${this.id} timeStep`); + + const vertexCount = this.topology.graph.vertexCount; + validateLayoutVector(this.positions, 'float32x2', vertexCount, `${this.id} positions`); + validateLayoutVector(this.velocities, 'float32x2', vertexCount, `${this.id} velocities`); + if (this.pinned) { + validateLayoutVector(this.pinned, 'uint32', vertexCount, `${this.id} pinned`); + } + if (this.reset) { + validateLayoutVector(this.reset, 'uint32', 1, `${this.id} reset`); + } + + const positionUsage = getPhysicalBuffer(this.positions.data[0]).usage; + const requiredPositionUsage = Buffer.STORAGE | Buffer.VERTEX; + if ((positionUsage & requiredPositionUsage) !== requiredPositionUsage) { + throw new Error(`${this.id} positions require both STORAGE and VERTEX buffer usage`); + } + if ((getPhysicalBuffer(this.velocities.data[0]).usage & Buffer.STORAGE) === 0) { + throw new Error(`${this.id} velocities require STORAGE buffer usage`); + } + validateDistinctLayoutVectors(this); + } + + /** Declares bounded progressive layout passes without submitting commands or reading results. */ + addToGraph(commandGraph: GPUCommandGraph): void { + addLuGraphForceLayoutToGraphWithDispatchLimit( + this, + commandGraph, + commandGraph.device.limits.maxComputeWorkgroupsPerDimension + ); + } +} + +/** Rejects non-finite or negative user-configured force strengths. */ +function validateNonNegativeParameter(value: number, name: string): void { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${name} must be a finite non-negative number`); + } +} + +/** Rejects non-finite or non-positive integration limits. */ +function validatePositiveParameter(value: number, name: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a finite positive number`); + } +} + +/** Requires one packed scalar or two-component chunk with its exact logical row count. */ +function validateLayoutVector( + vector: GPUVector, + format: Format, + length: number, + name: string +): 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 + ) { + throw new Error(`${name} must contain exactly one packed ${format} chunk`); + } + if (vector.length !== length) { + throw new Error(`${name} must contain exactly ${length} ${format} rows`); + } + + const chunk = vector.data[0]; + if ( + chunk.format !== format || + chunk.length !== 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`); + } +} + +/** Keeps layout state, pins, and reset controls disjoint from every existing graph allocation. */ +function validateDistinctLayoutVectors(layout: LuGraphForceLayout): void { + const topology = layout.topology; + const inputVectors = [ + 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 + ]; + const allocations = new Set(); + for (const vector of inputVectors) { + for (const chunk of vector.data) { + allocations.add(getPhysicalBuffer(chunk)); + } + } + + const layoutVectors = [ + {name: 'positions', vector: layout.positions}, + {name: 'velocities', vector: layout.velocities}, + ...(layout.pinned ? [{name: 'pinned', vector: layout.pinned}] : []), + ...(layout.reset ? [{name: 'reset', vector: layout.reset}] : []) + ]; + for (const {name, vector} of layoutVectors) { + const buffer = getPhysicalBuffer(vector.data[0]); + if (allocations.has(buffer)) { + throw new Error(`${layout.id} ${name} must use a distinct physical buffer allocation`); + } + allocations.add(buffer); + } +} + +/** Enumerates existing adjacency and status columns without changing any chunk identities. */ +function getAdjacencyVectors( + adjacency: LuGraphAdjacency +): (GPUVector<'uint32'> | GPUVector<'float32'>)[] { + return [ + adjacency.offsets, + adjacency.neighbors, + adjacency.edgeIds, + ...(adjacency.edgeWeights ? [adjacency.edgeWeights] : []), + adjacency.count, + adjacency.overflow + ]; +} + +/** Resolves a replaceable engine wrapper to its current underlying physical allocation. */ +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-force-layout.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-force-layout.node.spec.ts new file mode 100644 index 0000000000..8dab4ac6bf --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-force-layout.node.spec.ts @@ -0,0 +1,593 @@ +// 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, + LuGraphTopology, + type LuGraphAdjacency, + type LuGraphForceLayoutProps +} 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 LayoutFixture = { + device: NullDevice; + buffers: Buffer[]; + dynamicBuffers: DynamicBuffer[]; + vectors: GPUVector[]; +}; + +type VectorOptions = { + buffer?: Buffer | DynamicBuffer; + byteOffset?: number; + byteStride?: number; + rowByteLength?: number; + stride?: number; + usage?: number; +}; + +const layoutFixtures: LayoutFixture[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const fixture of layoutFixtures.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('LuGraphForceLayout optional API and render-ready caller-owned buffers', () => { + test('exports exact force-directed layout only through the optional luGraph subpath', () => { + expect(typeof LuGraphForceLayout).toBe('function'); + expect('LuGraphForceLayout' in experimentalModule).toBe(false); + }); + + test('preserves topology, positions, velocities, controls, and all default physics', () => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(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 layout = new LuGraphForceLayout({...props, id: 'borrowed-render-layout'}); + + expect(layout.id).toBe('borrowed-render-layout'); + expect(layout.topology).toBe(props.topology); + expect(layout.positions).toBe(props.positions); + expect(layout.velocities).toBe(props.velocities); + expect(layout.pinned).toBe(props.pinned); + expect(layout.reset).toBe(props.reset); + expect(layout.seed).toBe(0); + expect(layout.iterationsPerFrame).toBe(4); + expect(layout.repulsion).toBe(1); + expect(layout.attraction).toBe(0.1); + expect(layout.gravity).toBe(0.01); + expect(layout.damping).toBe(0.9); + expect(layout.maxVelocity).toBe(1); + expect(layout.timeStep).toBe(1); + expect(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(layout, 'destroy')).toBe(false); + + for (const vector of fixture.vectors) vector.destroy(); + expect(fixture.buffers.every(buffer => !buffer.destroyed)).toBe(true); + }); + + test('requires reverse directed adjacency and accepts undirected or weighted topology', () => { + const fixture = createLayoutFixture(); + const directed = createLayoutProps(fixture, {reverse: false}); + const undirected = createLayoutProps(fixture, {directed: false, reverse: false}); + const weighted = createLayoutProps(fixture, {weighted: true}); + + expect(() => new LuGraphForceLayout(directed)).toThrow(/directed|reverse|adjacency/); + expect(new LuGraphForceLayout(undirected).topology.reverse).toBeUndefined(); + expect(new LuGraphForceLayout(weighted).topology.graph.edgeWeights).toBeDefined(); + }); + + test('accepts empty render vectors with a caller-owned optional reset scalar', () => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture, {vertexCount: 0, reset: true}); + const layout = new LuGraphForceLayout(props); + + expect(layout.positions.length).toBe(0); + expect(layout.velocities.length).toBe(0); + expect(layout.positions.data).toHaveLength(1); + expect(layout.reset?.length).toBe(1); + }); +}); + +describe('LuGraphForceLayout bounded deterministic simulation parameters', () => { + test.each([ + 0, 1, 0xffffffff + ])('accepts an unsigned deterministic initialization seed: %i', seed => { + const fixture = createLayoutFixture(); + expect(new LuGraphForceLayout({...createLayoutProps(fixture), seed}).seed).toBe(seed); + }); + + test.each([ + -1, + 0.5, + 0x100000000, + Number.NaN, + Number.POSITIVE_INFINITY + ])('rejects a seed outside the uint32 domain: %s', seed => { + const fixture = createLayoutFixture(); + expect(() => new LuGraphForceLayout({...createLayoutProps(fixture), seed})).toThrow( + /seed|uint32/ + ); + }); + + test.each([1, 4, 1024])('accepts a bounded iteration count per frame: %i', iterationsPerFrame => { + const fixture = createLayoutFixture(); + expect( + new LuGraphForceLayout({...createLayoutProps(fixture), iterationsPerFrame}).iterationsPerFrame + ).toBe(iterationsPerFrame); + }); + + test.each([ + 0, + -1, + 1.5, + 1025, + Number.NaN, + Number.POSITIVE_INFINITY + ])('rejects invalid per-frame iterations: %s', iterationsPerFrame => { + const fixture = createLayoutFixture(); + expect( + () => new LuGraphForceLayout({...createLayoutProps(fixture), iterationsPerFrame}) + ).toThrow(/iterationsPerFrame|iteration|1024/); + }); + + test.each([ + 'repulsion', + 'attraction', + 'gravity' + ] as const)('accepts zero or positive finite %s', parameter => { + const fixture = createLayoutFixture(); + expect(new LuGraphForceLayout({...createLayoutProps(fixture), [parameter]: 0})[parameter]).toBe( + 0 + ); + expect(new LuGraphForceLayout({...createLayoutProps(fixture), [parameter]: 2})[parameter]).toBe( + 2 + ); + }); + + test.each([ + ['repulsion', -1], + ['attraction', Number.NaN], + ['gravity', Number.POSITIVE_INFINITY], + ['damping', -0.01], + ['damping', 1.01], + ['damping', Number.NaN], + ['maxVelocity', 0], + ['maxVelocity', -1], + ['maxVelocity', Number.POSITIVE_INFINITY], + ['timeStep', 0], + ['timeStep', -0.5], + ['timeStep', Number.NaN] + ] as const)('rejects invalid %s = %s', (parameter, value) => { + const fixture = createLayoutFixture(); + expect( + () => new LuGraphForceLayout({...createLayoutProps(fixture), [parameter]: value}) + ).toThrow(new RegExp(`${parameter}|finite|positive|between`)); + }); + + test.each([0, 0.5, 1])('accepts damping factors in the closed unit interval: %s', damping => { + const fixture = createLayoutFixture(); + expect(new LuGraphForceLayout({...createLayoutProps(fixture), damping}).damping).toBe(damping); + }); +}); + +describe('LuGraphForceLayout packed vectors and physical allocation safety', () => { + test.each([ + 'positions', + 'velocities' + ] as const)('requires one float32x2 row for every vertex in %s', field => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture); + const output = createVector(fixture, `short-${field}`, 'float32x2', [new Float32Array(10)]); + expect(() => new LuGraphForceLayout({...props, [field]: output})).toThrow( + new RegExp(`${field}|row|vertexCount`) + ); + }); + + test.each([ + 'positions', + 'velocities' + ] as const)('rejects scalar and partitioned %s buffers', field => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture); + const wrongFormat = createVector(fixture, `scalar-${field}`, 'float32', [new Float32Array(6)]); + const partitioned = createVector(fixture, `partitioned-${field}`, 'float32x2', [ + new Float32Array(6), + new Float32Array(6) + ]); + expect(() => new LuGraphForceLayout({...props, [field]: wrongFormat})).toThrow( + new RegExp(`${field}|float32x2|packed`) + ); + expect(() => new LuGraphForceLayout({...props, [field]: partitioned})).toThrow( + new RegExp(`${field}|one|single|chunk`) + ); + }); + + test.each([ + ['misaligned byte offset', {byteOffset: 2}], + ['padded byte stride', {byteStride: 12}], + ['oversized row payload', {rowByteLength: 12}], + ['incorrect scalar component count', {stride: 1}] + ] as [string, VectorOptions][])('rejects unpacked render positions: %s', (_name, options) => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture); + const positions = createVector( + fixture, + 'unpacked-positions', + 'float32x2', + [new Float32Array(12)], + options + ); + expect(() => new LuGraphForceLayout({...props, positions})).toThrow( + /positions|packed|aligned|float32x2/ + ); + }); + + test('accepts directly renderable float32x2 views at four-byte storage offsets', () => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture); + const positions = createVector( + fixture, + 'offset-positions', + 'float32x2', + [new Float32Array(12)], + { + byteOffset: 4 + } + ); + const velocities = createVector( + fixture, + 'offset-velocities', + 'float32x2', + [new Float32Array(12)], + { + byteOffset: 4 + } + ); + const layout = new LuGraphForceLayout({...props, positions, velocities}); + expect(layout.positions.data[0].byteOffset).toBe(4); + expect(layout.velocities.data[0].byteOffset).toBe(4); + }); + + test('requires render positions to support both STORAGE and VERTEX usages', () => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture); + const withoutVertex = createVector( + fixture, + 'storage-only-positions', + 'float32x2', + [new Float32Array(12)], + {usage: Buffer.STORAGE | Buffer.COPY_DST | Buffer.COPY_SRC} + ); + const withoutStorage = createVector( + fixture, + 'vertex-only-positions', + 'float32x2', + [new Float32Array(12)], + {usage: Buffer.VERTEX | Buffer.COPY_DST | Buffer.COPY_SRC} + ); + expect(() => new LuGraphForceLayout({...props, positions: withoutVertex})).toThrow( + /positions|VERTEX|vertex|render/ + ); + expect(() => new LuGraphForceLayout({...props, positions: withoutStorage})).toThrow( + /positions|STORAGE|storage/ + ); + }); + + test('requires writable velocities to support STORAGE usage', () => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture); + const velocities = createVector( + fixture, + 'vertex-only-velocities', + 'float32x2', + [new Float32Array(12)], + {usage: Buffer.VERTEX | Buffer.COPY_DST | Buffer.COPY_SRC} + ); + expect(() => new LuGraphForceLayout({...props, velocities})).toThrow( + /velocities|STORAGE|storage/ + ); + }); + + test.each([5, 7])('requires a uint32 pinned flag per vertex: %i', length => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture, {pinned: true}); + const pinned = createVector(fixture, 'incorrect-pinned-length', 'uint32', [ + new Uint32Array(length) + ]); + expect(() => new LuGraphForceLayout({...props, pinned})).toThrow(/pinned|row|vertexCount/); + }); + + test('rejects non-uint32 and partitioned pinned masks', () => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture, {pinned: true}); + const wrongFormat = createVector(fixture, 'float-pinned', 'float32', [new Float32Array(6)]); + const partitioned = createVector(fixture, 'partitioned-pinned', 'uint32', [ + new Uint32Array(3), + new Uint32Array(3) + ]); + expect( + () => new LuGraphForceLayout({...props, pinned: wrongFormat as GPUVector<'uint32'>}) + ).toThrow(/pinned|uint32|packed/); + expect(() => new LuGraphForceLayout({...props, pinned: partitioned})).toThrow( + /pinned|one|single|chunk/ + ); + }); + + test.each([0, 2])('requires exactly one uint32 reset scalar: %i', length => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture, {reset: true}); + const reset = createVector(fixture, 'incorrect-reset-length', 'uint32', [ + new Uint32Array(length) + ]); + expect(() => new LuGraphForceLayout({...props, reset})).toThrow(/reset|one|row|scalar/); + }); + + test('rejects float and partitioned deterministic reset controls', () => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture, {reset: true}); + const wrongFormat = createVector(fixture, 'float-reset', 'float32', [new Float32Array(1)]); + const partitioned = createVector(fixture, 'partitioned-reset', 'uint32', [ + new Uint32Array(1), + new Uint32Array(0) + ]); + expect( + () => new LuGraphForceLayout({...props, reset: wrongFormat as GPUVector<'uint32'>}) + ).toThrow(/reset|uint32|packed/); + expect(() => new LuGraphForceLayout({...props, reset: partitioned})).toThrow( + /reset|one|single|chunk/ + ); + }); + + 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' + ])('rejects position output backed by existing graph allocation %s', vectorName => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture, {vertexCount: 1, weighted: true}); + const vector = getTopologyVector(props.topology, vectorName); + const positions = createVector( + fixture, + 'aliased-positions', + 'float32x2', + [new Float32Array(2)], + { + buffer: vector.data[0].buffer + } + ); + expect(() => new LuGraphForceLayout({...props, positions})).toThrow( + /distinct|physical|allocation/ + ); + }); + + test.each([ + 'velocities', + 'pinned', + 'reset' + ] as const)('requires each mutable layout buffer and control allocation to be distinct: %s', field => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture, {vertexCount: 1, pinned: true, reset: true}); + const format = field === 'velocities' ? 'float32x2' : 'uint32'; + const values = field === 'velocities' ? new Float32Array(2) : new Uint32Array(1); + const alias = createVector(fixture, `aliased-${field}`, format, [values], { + buffer: props.positions.data[0].buffer + }); + expect(() => new LuGraphForceLayout({...props, [field]: alias})).toThrow( + /distinct|physical|allocation/ + ); + }); + + test('unwraps borrowed DynamicBuffer views before comparing physical layout allocations', () => { + const fixture = createLayoutFixture(); + const props = createLayoutProps(fixture, {vertexCount: 1}); + const concreteBuffer = props.topology.forward.offsets.data[0].buffer as Buffer; + const dynamicBuffer = new DynamicBuffer(fixture.device, { + id: 'borrowed-layout-wrapper', + buffer: concreteBuffer, + ownsBuffer: false + }); + fixture.dynamicBuffers.push(dynamicBuffer); + const positions = createVector( + fixture, + 'dynamic-aliased-layout', + 'float32x2', + [new Float32Array(2)], + {buffer: dynamicBuffer} + ); + + expect(() => new LuGraphForceLayout({...props, positions})).toThrow( + /distinct|physical|allocation/ + ); + expect(concreteBuffer.destroyed).toBe(false); + }); +}); + +function createLayoutFixture(): LayoutFixture { + const fixture = {device: new NullDevice({}), buffers: [], dynamicBuffers: [], vectors: []}; + layoutFixtures.push(fixture); + return fixture; +} + +function createLayoutProps( + fixture: LayoutFixture, + options: { + vertexCount?: number; + directed?: boolean; + reverse?: boolean; + weighted?: boolean; + pinned?: boolean; + reset?: boolean; + } = {} +): LuGraphForceLayoutProps { + 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, 'sourceWeights', 'float32', [ + Float32Array.from([0.5, 2]), + new Float32Array(0), + Float32Array.from([1, 4, 8]) + ]) + : undefined; + const edgeIds = options.weighted + ? createVector(fixture, 'sourceEdgeIds', '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 = + (options.reverse ?? directed) + ? createAdjacency(fixture, 'reverse', vertexCount, 5, options.weighted) + : undefined; + const invalidEdgeCount = createVector(fixture, 'invalidEdgeCount', 'uint32', [ + new Uint32Array(1) + ]); + const topology = new LuGraphTopology({graph, forward, reverse, invalidEdgeCount}); + const positions = createVector(fixture, 'renderPositions', 'float32x2', [ + new Float32Array(vertexCount * 2) + ]); + const velocities = createVector(fixture, 'layoutVelocities', 'float32x2', [ + new Float32Array(vertexCount * 2) + ]); + const pinned = options.pinned + ? createVector(fixture, 'pinnedVertices', 'uint32', [new Uint32Array(vertexCount)]) + : undefined; + const reset = options.reset + ? createVector(fixture, 'layoutReset', 'uint32', [new Uint32Array(1)]) + : undefined; + return {topology, positions, velocities, pinned, reset}; +} + +function createAdjacency( + fixture: LayoutFixture, + 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 getTopologyVector(topology: LuGraphTopology, name: string): GPUVector { + 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: LayoutFixture, + 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: options.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-force-layout.spec.ts b/modules/experimental/test/lugraph/lu-graph-force-layout.spec.ts new file mode 100644 index 0000000000..6bac3d9209 --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-force-layout.spec.ts @@ -0,0 +1,975 @@ +// 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, + 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 { + addLuGraphForceLayoutToGraphWithDispatchLimit, + getLuGraphForceLayoutDispatchLayout +} from '../../src/lugraph/lu-graph-force-layout-internals'; + +const PHYSICS_TOLERANCE = 1e-4; +const MINIMUM_SQUARED_DISTANCE = 0.0001; + +type ScalarFormat = 'uint32' | 'float32'; + +type ForceLayoutScenario = { + name: string; + vertexCount: number; + sourceChunks: number[][]; + targetChunks: number[][]; + positions: number[]; + velocities?: number[]; + pinned?: number[]; + reset?: number; + seed?: 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; + assertNoScratch?: boolean; +}; + +type ExpectedForceLayout = { + positions: number[]; + velocities: number[]; + reset?: number; + invalidEdgeCount: number; + forwardCount: number; + reverseCount: number; + forwardOverflow: boolean; + reverseOverflow: boolean; + failed: boolean; +}; + +type LayoutExecutionFixture = { + device: Device; + buffers: Buffer[]; + vectors: GPUVector[]; + graph: LuGraph; + topology: LuGraphTopology; + layout: LuGraphForceLayout; + commandGraph: GPUCommandGraph; + compiled?: ReturnType; +}; + +const layoutScenarios: ForceLayoutScenario[] = [ + { + name: 'empty undirected layouts do not require reverse adjacency or nonempty vertex passes', + vertexCount: 0, + sourceChunks: [], + targetChunks: [], + positions: [], + directed: false, + iterationsPerFrame: 1 + }, + { + name: 'empty directed layouts consume and clear an optional deterministic reset scalar', + vertexCount: 0, + sourceChunks: [], + targetChunks: [], + positions: [], + reset: 1, + iterationsPerFrame: 1 + }, + { + name: 'an isolated vertex combines gravity, prior velocity, damping, and timestep', + vertexCount: 1, + sourceChunks: [[]], + targetChunks: [[]], + positions: [2, -4], + velocities: [0.5, 0.25], + gravity: 0.2, + damping: 0.8, + timeStep: 0.5, + maxVelocity: 10, + iterationsPerFrame: 2 + }, + { + name: 'exact all-pairs repulsion moves disconnected vertices in opposite directions', + vertexCount: 2, + sourceChunks: [[]], + targetChunks: [[]], + positions: [-1, 0, 1, 0], + gravity: 0, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1 + }, + { + name: 'coincident vertices remain finite under softened exact repulsion', + vertexCount: 3, + sourceChunks: [[]], + targetChunks: [[]], + positions: [0, 0, 0, 0, 1, 0], + gravity: 0, + maxVelocity: 10, + iterationsPerFrame: 1 + }, + { + name: 'directed forward and reverse CSR produce symmetric endpoint spring attraction', + vertexCount: 2, + sourceChunks: [[0]], + targetChunks: [[1]], + positions: [-2, 0, 2, 0], + repulsion: 0, + gravity: 0, + attraction: 0.25, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1, + assertNoScratch: true + }, + { + name: 'undirected attraction consumes symmetric forward adjacency only once', + vertexCount: 3, + sourceChunks: [[0, 1]], + targetChunks: [[1, 2]], + positions: [-2, 0, 0, 1, 2, 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 no displacement', + vertexCount: 3, + sourceChunks: [[0, 0], [], [1, 2]], + targetChunks: [[1, 1], [], [1, 0]], + positions: [-1, 0, 1, 0, 0, 2], + repulsion: 0, + gravity: 0, + attraction: 0.2, + damping: 1, + maxVelocity: 10, + iterationsPerFrame: 1 + }, + { + name: 'existing weighted topology is explicitly treated as unweighted spring adjacency', + vertexCount: 4, + sourceChunks: [[0, 1], [], [2]], + targetChunks: [[1, 2], [], [3]], + weightChunks: [[0.001, 100], [], [42]], + positions: [0, 0, 1, 0, 1, 1, -1, 0], + gravity: 0.03, + maxVelocity: 5, + iterationsPerFrame: 2 + }, + { + name: 'invalid source endpoints are excluded while original source chunk boundaries survive', + vertexCount: 5, + sourceChunks: [[0, 8], [], [2, 3, 4]], + targetChunks: [[1, 2], [], [9, 4, 4]], + positions: [-2, 0, -1, 1, 0, 0, 1, -1, 2, 0], + iterationsPerFrame: 2 + }, + { + name: 'pinned vertices retain their exact positions and publish zero velocity', + vertexCount: 4, + sourceChunks: [[0, 1, 2]], + targetChunks: [[1, 2, 3]], + positions: [-2, 1, -1, 0, 1, 0, 3, -1], + velocities: [1, 2, 0.5, -1, 0, 0, -3, 4], + pinned: [1, 0, 0, 9], + gravity: 0.1, + maxVelocity: 10, + iterationsPerFrame: 3 + }, + { + name: 'Euclidean velocity clipping bounds per-step displacement without changing direction', + vertexCount: 2, + sourceChunks: [[]], + targetChunks: [[]], + positions: [0, 0, 0.01, 0], + repulsion: 100, + gravity: 0, + damping: 1, + maxVelocity: 0.125, + timeStep: 0.5, + iterationsPerFrame: 1 + }, + { + name: 'zero damping clears existing velocities and prevents all displacement', + vertexCount: 3, + sourceChunks: [[0, 1]], + targetChunks: [[1, 2]], + positions: [-1, 0, 0, 1, 1, 0], + velocities: [4, -5, 1, 2, -3, 7], + damping: 0, + iterationsPerFrame: 2 + }, + { + name: 'a GPU reset deterministically initializes coordinates from the exact uint32 seed', + vertexCount: 5, + sourceChunks: [[0, 2]], + targetChunks: [[1, 3]], + positions: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], + velocities: [3, 3, 2, 2, 1, 1, 4, 4, 5, 5], + reset: 1, + seed: 123456789, + iterationsPerFrame: 2 + }, + { + name: 'deterministic reset preserves pinned coordinates and zeroes pinned velocities', + vertexCount: 4, + sourceChunks: [[0, 1], [], [2]], + targetChunks: [[1, 2], [], [3]], + positions: [9, -3, 2, 4, -8, 1, 7, 6], + pinned: [1, 0, 2, 0], + reset: 17, + seed: 42, + iterationsPerFrame: 2 + }, + { + name: 'a cleared GPU reset scalar preserves caller-seeded warm-start coordinates', + vertexCount: 3, + sourceChunks: [[0]], + targetChunks: [[1]], + positions: [2, 0, -1, 1, 0, -2], + velocities: [0.5, 0, 0, -0.5, 0.25, 0.25], + reset: 0, + seed: 99, + iterationsPerFrame: 1 + }, + { + name: 'forward CSR overflow preserves render positions and clears simulation velocities', + vertexCount: 4, + sourceChunks: [[0, 1, 2]], + targetChunks: [[1, 2, 3]], + positions: [-2, 1, 0, 0, 1, -1, 3, 2], + velocities: [1, 2, 3, 4, 5, 6, 7, 8], + capacity: 1, + iterationsPerFrame: 2 + }, + { + name: 'reverse overflow also preserves positions, suppresses reset, and clears its scalar', + vertexCount: 4, + sourceChunks: [[0, 1, 2]], + targetChunks: [[1, 2, 3]], + positions: [-2, 1, 0, 0, 1, -1, 3, 2], + velocities: [1, 2, 3, 4, 5, 6, 7, 8], + reset: 1, + seed: 7, + reverseCapacity: 1, + iterationsPerFrame: 2 + }, + { + name: 'undirected forward overflow leaves vertex render attributes untouched', + vertexCount: 3, + sourceChunks: [[0, 1]], + targetChunks: [[1, 2]], + positions: [-1, 0, 0, 1, 1, 0], + directed: false, + capacity: 2, + iterationsPerFrame: 1 + }, + { + name: 'packed float32x2 storage and render views support four-byte non-256-aligned offsets', + vertexCount: 4, + sourceChunks: [[0, 2]], + targetChunks: [[1, 3]], + positions: [-2, 0, -1, 1, 1, -1, 2, 0], + velocities: [0.25, 0, 0, -0.25, 0.1, 0.2, 0, 0], + pinned: [0, 1, 0, 0], + reset: 0, + byteOffset: 4, + iterationsPerFrame: 2 + }, + { + name: 'bounded 3D tiled exact repulsion and incident springs process 1025 vertices', + 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) / 1024 : 0 + ), + repulsion: 0.001, + gravity: 0, + maxVelocity: 0.5, + iterationsPerFrame: 1, + maximumWorkgroups: 2 + } +]; + +test('LuGraphForceLayout plans bounded three-dimensional exact repulsion dispatch', tapeTest => { + tapeTest.deepEqual(getLuGraphForceLayoutDispatchLayout(0, 2), {x: 1, y: 1, z: 1}); + tapeTest.deepEqual(getLuGraphForceLayoutDispatchLayout(512, 2), {x: 2, y: 1, z: 1}); + tapeTest.deepEqual(getLuGraphForceLayoutDispatchLayout(513, 2), {x: 2, y: 2, z: 1}); + tapeTest.deepEqual(getLuGraphForceLayoutDispatchLayout(1025, 2), {x: 2, y: 2, z: 2}); + tapeTest.throws(() => getLuGraphForceLayoutDispatchLayout(2049, 2), /3D dispatch limit/); + tapeTest.end(); +}); + +for (const scenario of layoutScenarios) { + test(`LuGraphForceLayout exact GPU physics: ${scenario.name}`, async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const expected = calculateExpectedForceLayout(scenario); + const fixture = createExecutionFixture(device, scenario, expected); + try { + compileLayout(fixture, scenario.maximumWorkgroups); + executeLayout(fixture); + await assertForceLayout(tapeTest, fixture, scenario, expected); + tapeTest.deepEqual( + fixture.graph.sourceVertices.data.map(chunk => chunk.length), + scenario.sourceChunks.map(chunk => chunk.length), + 'exact GPU layout preserves caller-owned source chunks and empty record batches' + ); + if (scenario.assertNoScratch) { + tapeTest.equal( + fixture.compiled?.stats.logicalTransientBufferCount, + 6, + 'exact force and integration passes allocate no scratch beyond forward/reverse CSR' + ); + } + } finally { + destroyExecutionFixture(tapeTest, fixture); + } + + tapeTest.end(); + }); +} + +test('LuGraphForceLayout progressively warm-starts and repeats deterministic seeded reset', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const initial: ForceLayoutScenario = { + name: 'progressive deterministic layout', + vertexCount: 4, + sourceChunks: [[0, 1], [], [2]], + targetChunks: [[1, 2], [], [3]], + positions: [9, 8, 7, 6, 5, 4, 3, 2], + velocities: [1, 1, 2, 2, 3, 3, 4, 4], + reset: 1, + seed: 456, + iterationsPerFrame: 2 + }; + const expectedInitial = calculateExpectedForceLayout(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 { + compileLayout(fixture); + tapeTest.equal(submitSpy.mock.calls.length, 0, 'force layout construction never submits work'); + tapeTest.ok( + sourceReadbackSpies.every(spy => spy.mock.calls.length === 0), + 'exact layout never reads graph or render data back to the CPU' + ); + submitSpy.mockRestore(); + for (const sourceReadbackSpy of sourceReadbackSpies) sourceReadbackSpy.mockRestore(); + + executeLayout(fixture); + await assertForceLayout(tapeTest, fixture, initial, expectedInitial); + + const warmStart = { + ...initial, + positions: expectedInitial.positions, + velocities: expectedInitial.velocities, + reset: 0 + }; + executeLayout(fixture); + await assertForceLayout(tapeTest, fixture, warmStart, calculateExpectedForceLayout(warmStart)); + + const resetBuffer = fixture.layout.reset!.data[0].buffer as Buffer; + resetBuffer.write(Uint32Array.from([1])); + executeLayout(fixture); + await assertForceLayout(tapeTest, fixture, initial, expectedInitial); + tapeTest.equal( + fixture.layout.positions.data[0].buffer.usage & Buffer.VERTEX, + Buffer.VERTEX, + 'the exact caller-owned layout buffer remains directly usable as a render vertex attribute' + ); + } finally { + submitSpy.mockRestore(); + for (const sourceReadbackSpy of sourceReadbackSpies) sourceReadbackSpy.mockRestore(); + destroyExecutionFixture(tapeTest, fixture); + } + + tapeTest.end(); +}); + +/** Evaluates exact all-pairs repulsion and undirected incident-edge attraction on the CPU. */ +function calculateExpectedForceLayout(scenario: ForceLayoutScenario): ExpectedForceLayout { + 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 failed = 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 (failed) { + velocities.fill(0); + } else { + if (scenario.reset) { + 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 + ); + } + } + velocities.fill(0); + } + + const repulsion = scenario.repulsion ?? 1; + const attraction = scenario.attraction ?? 0.1; + const gravity = scenario.gravity ?? 0.01; + const damping = scenario.damping ?? 0.9; + const maxVelocity = scenario.maxVelocity ?? 1; + const timeStep = scenario.timeStep ?? 1; + + for (let iteration = 0; iteration < (scenario.iterationsPerFrame ?? 4); iteration++) { + const nextVelocities = Array.from(velocities); + for (let vertexIndex = 0; vertexIndex < scenario.vertexCount; vertexIndex++) { + const positionX = positions[vertexIndex * 2]; + const positionY = positions[vertexIndex * 2 + 1]; + let forceX = -gravity * positionX; + let forceY = -gravity * positionY; + + for (let neighborIndex = 0; neighborIndex < scenario.vertexCount; neighborIndex++) { + if (neighborIndex === vertexIndex) continue; + const distanceX = positionX - positions[neighborIndex * 2]; + const distanceY = positionY - positions[neighborIndex * 2 + 1]; + const squaredDistance = Math.max( + distanceX * distanceX + distanceY * distanceY, + MINIMUM_SQUARED_DISTANCE + ); + forceX += (repulsion * distanceX) / squaredDistance; + forceY += (repulsion * distanceY) / squaredDistance; + } + + const neighbors = + scenario.directed === false + ? outgoing[vertexIndex] + : [...outgoing[vertexIndex], ...incoming[vertexIndex]]; + for (const neighborIndex of neighbors) { + forceX += attraction * (positions[neighborIndex * 2] - positionX); + forceY += attraction * (positions[neighborIndex * 2 + 1] - positionY); + } + + let velocityX = (velocities[vertexIndex * 2] + forceX * timeStep) * damping; + let velocityY = (velocities[vertexIndex * 2 + 1] + forceY * timeStep) * damping; + const speed = Math.hypot(velocityX, velocityY); + if (speed > maxVelocity) { + velocityX *= maxVelocity / speed; + velocityY *= maxVelocity / 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] * timeStep; + positions[vertexIndex * 2 + 1] += velocities[vertexIndex * 2 + 1] * timeStep; + } + } + } + } + + return { + positions, + velocities, + ...(scenario.reset !== undefined ? {reset: 0} : {}), + invalidEdgeCount, + forwardCount, + reverseCount, + forwardOverflow, + reverseOverflow, + failed + }; +} + +/** Mirrors the shader's avalanche hash without JavaScript signed-integer multiplication. */ +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: ForceLayoutScenario, + expected: ExpectedForceLayout +): LayoutExecutionFixture { + 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 invalidEdgeCount = createScalarVector( + device, + buffers, + vectors, + 'invalid-edges', + 'uint32', + 1 + ); + const topology = new LuGraphTopology({graph, forward, reverse, invalidEdgeCount}); + 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 + }); + + return { + device, + buffers, + vectors, + graph, + topology, + layout, + 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 compileLayout(fixture: LayoutExecutionFixture, maximumWorkgroups?: number): void { + fixture.topology.addToGraph(fixture.commandGraph); + if (maximumWorkgroups === undefined) { + fixture.layout.addToGraph(fixture.commandGraph); + } else { + addLuGraphForceLayoutToGraphWithDispatchLimit( + fixture.layout, + fixture.commandGraph, + maximumWorkgroups + ); + } + fixture.compiled = fixture.commandGraph.compile(); +} + +function executeLayout(fixture: LayoutExecutionFixture): void { + const commandEncoder = fixture.device.createCommandEncoder({id: 'lu-graph-force-layout-test'}); + fixture.compiled!.encode(commandEncoder, {parameters: undefined}); + fixture.device.submit(commandEncoder.finish()); +} + +async function assertForceLayout( + tapeTest: Test, + fixture: LayoutExecutionFixture, + scenario: ForceLayoutScenario, + expected: ExpectedForceLayout +): Promise { + const [positions, velocities, reset, invalidEdgeCount, forwardOverflow, reverseOverflow] = + await Promise.all([ + readCoordinateVector(fixture.layout.positions), + readCoordinateVector(fixture.layout.velocities), + 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) + ]); + + tapeTest.equal( + positions.length, + scenario.vertexCount * 2, + 'two render coordinates remain per vertex' + ); + assertCloseCoordinates( + tapeTest, + positions, + expected.positions, + 'GPU positions match exact CPU force physics' + ); + assertCloseCoordinates( + tapeTest, + velocities, + expected.velocities, + 'GPU velocities match damped CPU force and clipping' + ); + tapeTest.ok( + positions.every(Number.isFinite) && velocities.every(Number.isFinite), + 'all softened coordinates and velocities remain finite' + ); + tapeTest.equal( + invalidEdgeCount[0], + expected.invalidEdgeCount, + 'invalid graph endpoints are excluded' + ); + tapeTest.equal( + forwardOverflow[0], + Number(expected.forwardOverflow), + 'forward capacity remains explicit' + ); + if (reverseOverflow) { + tapeTest.equal( + reverseOverflow[0], + Number(expected.reverseOverflow), + 'reverse capacity remains explicit' + ); + } + if (reset) + tapeTest.equal(reset[0], 0, 'GPU consumes and clears deterministic initialization control'); + + for (const [vertexIndex, pinned] of (scenario.pinned ?? []).entries()) { + if (pinned) { + tapeTest.equal( + positions[vertexIndex * 2], + scenario.positions[vertexIndex * 2], + 'pinned x coordinate never moves' + ); + tapeTest.equal( + positions[vertexIndex * 2 + 1], + scenario.positions[vertexIndex * 2 + 1], + 'pinned y coordinate never moves' + ); + tapeTest.equal(velocities[vertexIndex * 2], 0, 'pinned horizontal velocity is cleared'); + tapeTest.equal(velocities[vertexIndex * 2 + 1], 0, 'pinned vertical velocity is cleared'); + } + } + + if (expected.failed) { + tapeTest.deepEqual( + positions, + Array.from(new Float32Array(scenario.positions)), + 'CSR overflow preserves all render positions' + ); + tapeTest.ok( + velocities.every(velocity => velocity === 0), + 'CSR overflow clears every simulation velocity' + ); + } +} + +function assertCloseCoordinates( + tapeTest: Test, + actual: number[], + expected: number[], + message: string +): void { + const largestError = actual.reduce( + (largest, value, coordinateIndex) => + Math.max(largest, Math.abs(value - expected[coordinateIndex])), + 0 + ); + tapeTest.ok( + largestError <= PHYSICS_TOLERANCE, + `${message} within ${PHYSICS_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: LayoutExecutionFixture): void { + fixture.compiled?.destroy(); + for (const vector of fixture.vectors) vector.destroy(); + tapeTest.ok( + fixture.buffers.every(buffer => !buffer.destroyed), + 'destroying borrowed force-layout vectors preserves every caller-owned physical 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 8f220526bc..8256b2b684 100644 --- a/test/examples/lugraph-docs.node.spec.ts +++ b/test/examples/lugraph-docs.node.spec.ts @@ -49,6 +49,7 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(graphDocumentation).toContain('**Question: Which entities can I reach'); expect(graphDocumentation).toContain('**Question: Which vertices belong to the same connected'); expect(graphDocumentation).toContain('**Question: Which vertices receive influence'); + expect(graphDocumentation).toContain('**Question: How can I position connected entities'); }); test('introduces every available operation and composes its actual optional entry point', () => { @@ -58,7 +59,8 @@ describe('luGraph GPU-resident graph analytics documentation', () => { 'LuGraphDegree', 'LuGraphBreadthFirstSearch', 'LuGraphConnectedComponents', - 'LuGraphPageRank' + 'LuGraphPageRank', + 'LuGraphForceLayout' ]) { expect(graphDocumentation, graphOperation).toContain(graphOperation); } @@ -68,6 +70,7 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(packageDocumentation).toContain('breadth-first shortest paths'); expect(packageDocumentation).toContain('weakly connected components'); expect(packageDocumentation).toContain('normalized PageRank'); + expect(packageDocumentation).toContain('progressive exact force-directed layout'); expect(graphDocumentation).toContain("from '@luma.gl/experimental/lugraph';"); expect(graphDocumentation).toContain('topology.addToGraph(workflow);'); expect(graphDocumentation).toContain('const compiled = workflow.compile();'); @@ -95,6 +98,35 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(graphDocumentation).toContain('does not imply distributed or multi-GPU execution'); }); + test('explains exact render-ready force layout, interaction controls, and scalability limits', () => { + expect(graphDocumentation).toContain('## Reveal relationships with LuGraphForceLayout'); + expect(graphDocumentation).toContain('a social graph'); + expect(graphDocumentation).toContain('service dependencies'); + expect(graphDocumentation).toContain('transaction investigation'); + expect(graphDocumentation).toContain("GPUVector<'float32x2'>"); + expect(graphDocumentation).toContain('`Buffer.STORAGE` and\n`Buffer.VERTEX`'); + expect(graphDocumentation).toContain('an application can bind as a'); + expect(graphDocumentation).toContain('render vertex attribute'); + expect(graphDocumentation).toContain('requires both forward and reverse adjacency'); + expect(graphDocumentation).toContain('intentionally ignored by this unweighted'); + expect(graphDocumentation).toContain('`pinned` row to any nonzero value'); + expect(graphDocumentation).toContain('one-row `uint32` `reset` vector'); + expect(graphDocumentation).toContain('clearing it'); + expect(graphDocumentation).toContain('warm-start from the'); + expect(graphDocumentation).toContain('`seed: 0`, `iterationsPerFrame: 4`'); + expect(graphDocumentation).toContain('`repulsion: 1`, `attraction: 0.1`'); + expect(graphDocumentation).toContain('`gravity: 0.01`, `damping: 0.9`'); + expect(graphDocumentation).toContain('`maxVelocity: 1`, and `timeStep: 1`'); + expect(graphDocumentation).toContain('`O(V² + E)`'); + expect(graphDocumentation).toContain('doubling the vertex count roughly quadruples'); + expect(graphDocumentation).toContain( + 'preserves\nevery existing position and clears all velocities' + ); + expect(graphDocumentation).toContain('does not approximate pairwise interactions'); + expect(graphDocumentation).toContain('ForceAtlas2 or Barnes–Hut'); + expect(graphDocumentation).toContain('new LuGraphForceLayout({'); + }); + test('preserves independent MIT ownership and accurate NVIDIA RAPIDS inspiration', () => { for (const documentation of [graphDocumentation, packageDocumentation]) { expect(documentation).toContain('NVIDIA RAPIDS cuGraph');