From 9871996188863863ffcfe1b7b3368f583522de9f Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 23:07:46 -0400 Subject: [PATCH 1/4] feat(experimental): add deterministic GPU community detection --- docs/api-reference/experimental/lugraph.md | 47 +- modules/experimental/src/lugraph/index.ts | 2 + .../lu-graph-label-propagation-internals.ts | 470 +++++++++++ .../src/lugraph/lu-graph-label-propagation.ts | 173 ++++ .../lu-graph-label-propagation.node.spec.ts | 473 +++++++++++ .../lu-graph-label-propagation.spec.ts | 797 ++++++++++++++++++ 6 files changed, 1958 insertions(+), 4 deletions(-) create mode 100644 modules/experimental/src/lugraph/lu-graph-label-propagation-internals.ts create mode 100644 modules/experimental/src/lugraph/lu-graph-label-propagation.ts create mode 100644 modules/experimental/test/lugraph/lu-graph-label-propagation.node.spec.ts create mode 100644 modules/experimental/test/lugraph/lu-graph-label-propagation.spec.ts diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index 1c864d3839..c809b07def 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -15,10 +15,10 @@ represent their relationships. `@luma.gl/experimental/lugraph` answers these questions directly on a browser WebGPU device. It describes caller-owned GPU edge columns, builds reusable compressed adjacency, and publishes vertex -degrees, shortest-path neighborhoods, weakly connected groups, PageRank importance, and progressive -two-dimensional graph layouts into caller-owned GPU buffers. Layout can evaluate every repulsive -interaction exactly or explicitly approximate distant groups through a caller-owned uniform grid. -Every operation composes with the existing `GPUCommandGraph`. +degrees, shortest-path neighborhoods, weakly connected groups, densely connected communities, +PageRank importance, and progressive two-dimensional graph layouts into caller-owned GPU buffers. +Layout can evaluate every repulsive interaction exactly or explicitly approximate distant groups +through a caller-owned uniform grid. Every operation composes with the existing `GPUCommandGraph`. This is an experimental, headless graph analytics API, not a graph database, visualization framework, file importer, or general-purpose dataframe. Applications decide how data reaches the @@ -450,6 +450,45 @@ one only when the final iteration reaches a fixed point; zero means convergence or the required adjacency overflowed. A connected component answers whether entities connect at all; it does not claim to discover densely connected communities within one connected network. +## Discover densely connected communities with LuGraphLabelPropagation + +**Question: Which vertices form closely connected communities inside a network that is otherwise +connected?** + +`LuGraphLabelPropagation` groups vertices by the labels most common in their immediate +neighborhood. Use it to reveal circles of friends within a social network, identify related +transaction accounts within a larger fraud investigation, separate service ownership groups inside +a connected dependency graph, or color locally cohesive regions of a citation network. + +A connected component answers whether any path links two vertices. Community detection asks a +different question: are these vertices more strongly connected to one another than to the rest of +the same network? Two tightly linked teams connected by one shared service remain in a single weak +component but can receive different community labels. + +```ts +import {LuGraphLabelPropagation} from '@luma.gl/experimental/lugraph'; + +const communities = new LuGraphLabelPropagation({ + topology, + output: communityIds, + iterations: 32, + converged: communitiesConverged +}); +``` + +Each vertex starts with its stable identifier. Every iteration considers one self vote and every +incoming or outgoing neighbor occurrence, choosing the most frequent label and breaking ties with +the lowest identifier. Self-loops add no extra votes; duplicate and reciprocal edges vote +independently. Directed graphs therefore require both forward and reverse adjacency; undirected +graphs reuse their symmetric forward adjacency. A narrow bridge can leave two dense communities +distinct even when they belong to the same weakly connected component. + +The optional GPU convergence scalar is one only when the final synchronous iteration changes no +labels; bounded propagation can oscillate or stop without proving convergence. Required adjacency +overflow publishes `0xffffffff` labels and zero convergence. This deterministic heuristic is not +Louvain, Leiden, or modularity optimization, and its worst-case work per iteration is +`O(sum(degree²))`. + ## Rank incoming influence with LuGraphPageRank **Question: Which vertices receive influence from other important vertices?** diff --git a/modules/experimental/src/lugraph/index.ts b/modules/experimental/src/lugraph/index.ts index f15acff580..57300d519c 100644 --- a/modules/experimental/src/lugraph/index.ts +++ b/modules/experimental/src/lugraph/index.ts @@ -16,6 +16,8 @@ export type { } from './lu-graph-breadth-first-search'; export {LuGraphConnectedComponents} from './lu-graph-connected-components'; export type {LuGraphConnectedComponentsProps} from './lu-graph-connected-components'; +export {LuGraphLabelPropagation} from './lu-graph-label-propagation'; +export type {LuGraphLabelPropagationProps} from './lu-graph-label-propagation'; export {LuGraphPageRank} from './lu-graph-page-rank'; export type {LuGraphPageRankProps} from './lu-graph-page-rank'; export {LuGraphForceLayout} from './lu-graph-force-layout'; diff --git a/modules/experimental/src/lugraph/lu-graph-label-propagation-internals.ts b/modules/experimental/src/lugraph/lu-graph-label-propagation-internals.ts new file mode 100644 index 0000000000..87e39c233e --- /dev/null +++ b/modules/experimental/src/lugraph/lu-graph-label-propagation-internals.ts @@ -0,0 +1,470 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +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 { + createTransientView, + getViewBinding, + getViewElementOffset +} from '../gpu-primitives/graph-data-view-utils'; +import type {LuGraphLabelPropagation} from './lu-graph-label-propagation'; + +const LABEL_PROPAGATION_WORKGROUP_SIZE = 256; +const INVALID_COMMUNITY = 0xffffffff; + +type ImportedLabelPropagation = { + id: string; + vertexCount: number; + forwardOffsets: GraphDataView<'uint32'>; + forwardNeighbors: GraphDataView<'uint32'>; + forwardOverflow: GraphDataView<'uint32'>; + reverseOffsets?: GraphDataView<'uint32'>; + reverseNeighbors?: GraphDataView<'uint32'>; + reverseOverflow?: GraphDataView<'uint32'>; + output: GraphDataView<'uint32'>; + scratch?: GraphDataView<'uint32'>; + converged?: GraphDataView<'uint32'>; + maxComputeWorkgroupsPerDimension: number; +}; + +type LabelPropagationBinding = { + view: GraphDataView<'uint32'>; + usage: GraphBufferUse['usage']; + atomic?: boolean; +}; + +type LabelPropagationPassProps = { + id: string; + source: string; + bindings: Record; + dispatchLayout: GPUBoundedDispatchLayout; +}; + +/** Adds synchronous deterministic neighborhood-majority community propagation. @internal */ +export function addLuGraphLabelPropagationToGraphWithDispatchLimit( + propagation: LuGraphLabelPropagation, + commandGraph: GPUCommandGraph, + maxComputeWorkgroupsPerDimension: number +): void { + const vertexCount = propagation.topology.graph.vertexCount; + if (vertexCount === 0 && !propagation.converged) return; + + const reverse = propagation.topology.graph.directed ? propagation.topology.reverse : undefined; + const state: ImportedLabelPropagation = { + id: propagation.id, + vertexCount, + forwardOffsets: commandGraph.importGPUVector( + `${propagation.id}-forward-offsets`, + propagation.topology.forward.offsets + ).data[0], + forwardNeighbors: commandGraph.importGPUVector( + `${propagation.id}-forward-neighbors`, + propagation.topology.forward.neighbors + ).data[0], + forwardOverflow: commandGraph.importGPUVector( + `${propagation.id}-forward-overflow`, + propagation.topology.forward.overflow + ).data[0], + ...(reverse + ? { + reverseOffsets: commandGraph.importGPUVector( + `${propagation.id}-reverse-offsets`, + reverse.offsets + ).data[0], + reverseNeighbors: commandGraph.importGPUVector( + `${propagation.id}-reverse-neighbors`, + reverse.neighbors + ).data[0], + reverseOverflow: commandGraph.importGPUVector( + `${propagation.id}-reverse-overflow`, + reverse.overflow + ).data[0] + } + : {}), + output: commandGraph.importGPUVector(`${propagation.id}-output`, propagation.output).data[0], + ...(propagation.converged + ? { + converged: commandGraph.importGPUVector( + `${propagation.id}-converged`, + propagation.converged + ).data[0] + } + : {}), + ...(vertexCount > 0 + ? { + scratch: createTransientView( + commandGraph, + `${propagation.id}-next-labels`, + 'uint32', + vertexCount + ) + } + : {}), + maxComputeWorkgroupsPerDimension + }; + + addInitializationPass(commandGraph, state); + if (vertexCount === 0) return; + + for (let iteration = 0; iteration < propagation.iterations; iteration++) { + if (state.converged) addConvergenceResetPass(commandGraph, {state, iteration}); + addVotingPass(commandGraph, {state, iteration}); + addPublishPass(commandGraph, {state, iteration}); + } +} + +/** Initializes one stable identity label per vertex or publishes explicit overflow sentinels. */ +function addInitializationPass( + commandGraph: GPUCommandGraph, + state: ImportedLabelPropagation +): void { + const bindings: Record = { + output: {view: state.output, usage: 'storage-write'}, + forwardOverflow: {view: state.forwardOverflow, usage: 'storage-read'}, + ...(state.reverseOverflow + ? {reverseOverflow: {view: state.reverseOverflow, usage: 'storage-read'}} + : {}), + ...(state.converged + ? {converged: {view: state.converged, usage: 'storage-write', atomic: true}} + : {}) + }; + const reverseOffset = state.reverseOverflow + ? `const REVERSE_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.reverseOverflow)}u;` + : ''; + const reverseOverflow = state.reverseOverflow + ? ' || reverseOverflow[REVERSE_OVERFLOW_OFFSET] != 0u' + : ''; + const convergenceOffset = state.converged + ? `const CONVERGED_OFFSET: u32 = ${getViewElementOffset(state.converged)}u;` + : ''; + const initializeConvergence = state.converged + ? `if (index == 0u) { + atomicStore(&converged[CONVERGED_OFFSET], select(0u, 1u, VERTEX_COUNT == 0u && !hasOverflow)); + }` + : ''; + const dispatchLayout = getLabelPropagationDispatchLayout(state, Math.max(state.vertexCount, 1)); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const OUTPUT_OFFSET: u32 = ${getViewElementOffset(state.output)}u; +const FORWARD_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.forwardOverflow)}u; +${reverseOffset} +${convergenceOffset} +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${LABEL_PROPAGATION_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, LABEL_PROPAGATION_WORKGROUP_SIZE)} + let hasOverflow = forwardOverflow[FORWARD_OVERFLOW_OFFSET] != 0u${reverseOverflow}; + if (index < VERTEX_COUNT) { + output[OUTPUT_OFFSET + index] = select(index, ${INVALID_COMMUNITY}u, hasOverflow); + } + ${initializeConvergence} +}`; + addLabelPropagationPass(commandGraph, { + id: `${state.id}-initialize`, + source, + bindings, + dispatchLayout + }); +} + +/** Publishes a fresh optimistic convergence scalar after a globally synchronized boundary. */ +function addConvergenceResetPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedLabelPropagation; iteration: number} +): void { + const {state} = props; + const converged = state.converged!; + const bindings: Record = { + forwardOverflow: {view: state.forwardOverflow, usage: 'storage-read'}, + ...(state.reverseOverflow + ? {reverseOverflow: {view: state.reverseOverflow, usage: 'storage-read'}} + : {}), + converged: {view: converged, usage: 'storage-write', atomic: true} + }; + const reverseOffset = state.reverseOverflow + ? `const REVERSE_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.reverseOverflow)}u;` + : ''; + const reverseOverflow = state.reverseOverflow + ? ' || reverseOverflow[REVERSE_OVERFLOW_OFFSET] != 0u' + : ''; + const dispatchLayout = getLabelPropagationDispatchLayout(state, 1); + const source = /* wgsl */ ` +const FORWARD_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.forwardOverflow)}u; +const CONVERGED_OFFSET: u32 = ${getViewElementOffset(converged)}u; +${reverseOffset} +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${LABEL_PROPAGATION_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, LABEL_PROPAGATION_WORKGROUP_SIZE)} + if (index != 0u) { return; } + let hasOverflow = forwardOverflow[FORWARD_OVERFLOW_OFFSET] != 0u${reverseOverflow}; + atomicStore(&converged[CONVERGED_OFFSET], select(1u, 0u, hasOverflow)); +}`; + addLabelPropagationPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-reset`, + source, + bindings, + dispatchLayout + }); +} + +/** Counts every weak-neighbor occurrence from one immutable label snapshot, using eight bindings. */ +function addVotingPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedLabelPropagation; iteration: number} +): void { + const {state} = props; + const scratch = state.scratch!; + const bindings: Record = { + output: {view: state.output, usage: 'storage-read'}, + scratch: {view: scratch, usage: 'storage-write'}, + forwardOffsets: {view: state.forwardOffsets, usage: 'storage-read'}, + forwardNeighbors: {view: state.forwardNeighbors, usage: 'storage-read'}, + forwardOverflow: {view: state.forwardOverflow, 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 hasReverse = Boolean( + state.reverseOffsets && state.reverseNeighbors && state.reverseOverflow + ); + const reverseConstants = hasReverse + ? `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 = hasReverse ? ' || reverseOverflow[REVERSE_OVERFLOW_OFFSET] != 0u' : ''; + const countReverseVotes = hasReverse + ? `let reverseFirst = min(reverseOffsets[REVERSE_OFFSETS_OFFSET + vertex], REVERSE_CAPACITY); + let reverseLast = min(reverseOffsets[REVERSE_OFFSETS_OFFSET + vertex + 1u], REVERSE_CAPACITY); + for (var slot = reverseFirst; slot < reverseLast; slot++) { + let neighbor = reverseNeighbors[REVERSE_NEIGHBORS_OFFSET + slot]; + if (neighbor < VERTEX_COUNT && neighbor != vertex && output[OUTPUT_OFFSET + neighbor] == candidate) { + votes++; + } + }` + : ''; + const selectReverseCandidates = hasReverse + ? `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 || neighbor == index) { continue; } + let candidate = output[OUTPUT_OFFSET + neighbor]; + let votes = countCandidateVotes(index, candidate); + if (votes > selectedVotes || (votes == selectedVotes && candidate < selectedLabel)) { + selectedLabel = candidate; + selectedVotes = votes; + } + }` + : ''; + const dispatchLayout = getLabelPropagationDispatchLayout(state, state.vertexCount); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const FORWARD_CAPACITY: u32 = ${state.forwardNeighbors.length}u; +const OUTPUT_OFFSET: u32 = ${getViewElementOffset(state.output)}u; +const SCRATCH_OFFSET: u32 = ${getViewElementOffset(scratch)}u; +const FORWARD_OFFSETS_OFFSET: u32 = ${getViewElementOffset(state.forwardOffsets)}u; +const FORWARD_NEIGHBORS_OFFSET: u32 = ${getViewElementOffset(state.forwardNeighbors)}u; +const FORWARD_OVERFLOW_OFFSET: u32 = ${getViewElementOffset(state.forwardOverflow)}u; +${reverseConstants} +${getBindingDeclarations(bindings)} + +fn countCandidateVotes(vertex: u32, candidate: u32) -> u32 { + var votes = select(0u, 1u, output[OUTPUT_OFFSET + vertex] == candidate); + let first = min(forwardOffsets[FORWARD_OFFSETS_OFFSET + vertex], FORWARD_CAPACITY); + let last = min(forwardOffsets[FORWARD_OFFSETS_OFFSET + vertex + 1u], FORWARD_CAPACITY); + for (var slot = first; slot < last; slot++) { + let neighbor = forwardNeighbors[FORWARD_NEIGHBORS_OFFSET + slot]; + if (neighbor < VERTEX_COUNT && neighbor != vertex && output[OUTPUT_OFFSET + neighbor] == candidate) { + votes++; + } + } + ${countReverseVotes} + return votes; +} + +@compute @workgroup_size(${LABEL_PROPAGATION_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, LABEL_PROPAGATION_WORKGROUP_SIZE)} + if (index >= VERTEX_COUNT) { return; } + if (forwardOverflow[FORWARD_OVERFLOW_OFFSET] != 0u${reverseOverflow}) { + scratch[SCRATCH_OFFSET + index] = ${INVALID_COMMUNITY}u; + return; + } + + var selectedLabel = output[OUTPUT_OFFSET + index]; + var selectedVotes = countCandidateVotes(index, selectedLabel); + 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 || neighbor == index) { continue; } + let candidate = output[OUTPUT_OFFSET + neighbor]; + let votes = countCandidateVotes(index, candidate); + if (votes > selectedVotes || (votes == selectedVotes && candidate < selectedLabel)) { + selectedLabel = candidate; + selectedVotes = votes; + } + } + ${selectReverseCandidates} + scratch[SCRATCH_OFFSET + index] = selectedLabel; +}`; + addLabelPropagationPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-vote`, + source, + bindings, + dispatchLayout + }); +} + +/** Publishes one globally synchronized label snapshot and atomically marks actual changes. */ +function addPublishPass( + commandGraph: GPUCommandGraph, + props: {state: ImportedLabelPropagation; iteration: number} +): void { + const {state} = props; + const scratch = state.scratch!; + const bindings: Record = { + output: {view: state.output, usage: 'storage-read-write'}, + scratch: {view: scratch, usage: 'storage-read'}, + ...(state.converged + ? {converged: {view: state.converged, usage: 'storage-read-write', atomic: true}} + : {}) + }; + const convergenceOffset = state.converged + ? `const CONVERGED_OFFSET: u32 = ${getViewElementOffset(state.converged)}u;` + : ''; + const markChanged = state.converged + ? `if (nextLabel != previousLabel) { atomicStore(&converged[CONVERGED_OFFSET], 0u); }` + : ''; + const dispatchLayout = getLabelPropagationDispatchLayout(state, state.vertexCount); + const source = /* wgsl */ ` +const VERTEX_COUNT: u32 = ${state.vertexCount}u; +const OUTPUT_OFFSET: u32 = ${getViewElementOffset(state.output)}u; +const SCRATCH_OFFSET: u32 = ${getViewElementOffset(scratch)}u; +${convergenceOffset} +${getBindingDeclarations(bindings)} + +@compute @workgroup_size(${LABEL_PROPAGATION_WORKGROUP_SIZE}) +fn main( + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localInvocationIndex: u32 +) { + ${getBoundedInvocationIndexSource(dispatchLayout, LABEL_PROPAGATION_WORKGROUP_SIZE)} + if (index >= VERTEX_COUNT) { return; } + let previousLabel = output[OUTPUT_OFFSET + index]; + let nextLabel = scratch[SCRATCH_OFFSET + index]; + output[OUTPUT_OFFSET + index] = nextLabel; + ${markChanged} +}`; + addLabelPropagationPass(commandGraph, { + id: `${state.id}-iteration-${props.iteration}-publish`, + source, + bindings, + dispatchLayout + }); +} + +/** Declares packed scalar and optional atomic convergence views in exact 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.atomic ? 'atomic' : 'u32'; + return `@group(0) @binding(${location}) var ${name}: array<${element}>;`; + }) + .join('\n'); +} + +/** Compiles one graph-owned bounded community pass without submission or CPU synchronization. */ +function addLabelPropagationPass( + commandGraph: GPUCommandGraph, + props: LabelPropagationPassProps +): 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() + }; + } + }); +} + +function getLabelPropagationDispatchLayout( + state: ImportedLabelPropagation, + elementCount: number +): GPUBoundedDispatchLayout { + return getLuGraphLabelPropagationDispatchLayout( + elementCount, + state.maxComputeWorkgroupsPerDimension + ); +} + +/** Plans bounded true three-dimensional community-label or convergence dispatch. @internal */ +export function getLuGraphLabelPropagationDispatchLayout( + elementCount: number, + maxComputeWorkgroupsPerDimension: number +): GPUBoundedDispatchLayout { + return getBoundedDispatchLayout( + 'LuGraphLabelPropagation', + elementCount, + LABEL_PROPAGATION_WORKGROUP_SIZE, + maxComputeWorkgroupsPerDimension + ); +} diff --git a/modules/experimental/src/lugraph/lu-graph-label-propagation.ts b/modules/experimental/src/lugraph/lu-graph-label-propagation.ts new file mode 100644 index 0000000000..11455e3aca --- /dev/null +++ b/modules/experimental/src/lugraph/lu-graph-label-propagation.ts @@ -0,0 +1,173 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Buffer} from '@luma.gl/core'; +import {DynamicBuffer} from '@luma.gl/engine'; +import type {GPUData, GPUVector} from '@luma.gl/tables'; +import type {GPUCommandGraph} from '../gpu-primitives/gpu-command-graph'; +import {addLuGraphLabelPropagationToGraphWithDispatchLimit} from './lu-graph-label-propagation-internals'; +import type {LuGraphAdjacency, LuGraphTopology} from './lu-graph-topology'; + +const DEFAULT_LABEL_ITERATIONS = 32; +const MAXIMUM_LABEL_ITERATIONS = 1024; +const SCALAR_BYTE_LENGTH = 4; + +/** Existing caller-owned graph adjacency, unsigned community labels, and optional GPU status. */ +export type LuGraphLabelPropagationProps = { + /** Prefix for generated command-graph node and imported-resource identifiers. */ + id?: string; + /** Existing weak-neighborhood adjacency; directed graphs require reverse CSR. */ + topology: LuGraphTopology; + /** One caller-owned packed unsigned community label for every stable graph vertex. */ + output: GPUVector<'uint32'>; + /** Bounded number of synchronous majority-vote iterations. Defaults to 32. */ + iterations?: number; + /** Optional scalar set only when the final compiled iteration reaches a fixed point. */ + converged?: GPUVector<'uint32'>; +}; + +/** + * Publishes deterministic majority-vote communities without leaving browser GPU memory. + * + * Labels begin at stable vertex identifiers. Every synchronous iteration selects the most + * frequent weak-neighbor label plus one self vote, resolving equal support by the lowest label. + * Self-loop edges add no extra self votes; duplicate and reciprocated graph edges vote separately. + * Directed graphs require reverse CSR, while undirected graphs reuse symmetric forward adjacency. + * + * Selected adjacency overflow publishes `0xffffffff` labels and zero convergence. Empty graphs + * report convergence when a status buffer is provided. This bounded label-propagation heuristic + * is neither Louvain nor Leiden, does not optimize modularity, and has worst-case + * `O(sum(degree²))` work per iteration. + */ +export class LuGraphLabelPropagation { + /** Prefix for generated command-graph node and imported-resource identifiers. */ + readonly id: string; + /** Existing caller-owned GPU graph topology. */ + readonly topology: LuGraphTopology; + /** Caller-owned vertex-aligned community labels. */ + readonly output: GPUVector<'uint32'>; + /** Number of compiled, explicitly synchronized majority-vote iterations. */ + readonly iterations: number; + /** Optional caller-owned GPU-resident final fixed-point status. */ + readonly converged?: GPUVector<'uint32'>; + + /** Validates graph metadata without allocating, submitting, destroying, or reading GPU work. */ + constructor(props: LuGraphLabelPropagationProps) { + this.id = props.id ?? 'lu-graph-label-propagation'; + this.topology = props.topology; + this.output = props.output; + this.iterations = props.iterations ?? DEFAULT_LABEL_ITERATIONS; + this.converged = props.converged; + + if ( + !Number.isSafeInteger(this.iterations) || + this.iterations < 1 || + this.iterations > MAXIMUM_LABEL_ITERATIONS + ) { + throw new Error(`${this.id} iterations must be a safe integer between one and 1024`); + } + if (this.topology.graph.directed && !this.topology.reverse) { + throw new Error(`${this.id} directed weak-neighbor votes require reverse adjacency`); + } + + validateLabelVector(this.output, this.topology.graph.vertexCount, `${this.id} output`); + if (this.converged) { + validateLabelVector(this.converged, 1, `${this.id} converged`); + } + validateDistinctLabelOutputs(this); + } + + /** Declares bounded majority-vote passes without queue submission or CPU synchronization. */ + addToGraph(commandGraph: GPUCommandGraph): void { + addLuGraphLabelPropagationToGraphWithDispatchLimit( + this, + commandGraph, + commandGraph.device.limits.maxComputeWorkgroupsPerDimension + ); + } +} + +/** Requires exactly one packed, aligned unsigned output chunk and its precise logical length. */ +function validateLabelVector(vector: GPUVector<'uint32'>, length: number, name: string): void { + if ( + vector.data.length !== 1 || + vector.format !== 'uint32' || + vector.stride !== 1 || + vector.byteStride !== SCALAR_BYTE_LENGTH || + vector.rowByteLength !== SCALAR_BYTE_LENGTH || + vector.valueLength !== vector.length || + vector.bufferLayout + ) { + throw new Error(`${name} must contain exactly one packed uint32 chunk`); + } + if (vector.length !== length) { + throw new Error(`${name} must contain exactly ${length} uint32 rows`); + } + + const chunk = vector.data[0]; + if ( + chunk.format !== 'uint32' || + chunk.length !== length || + chunk.stride !== 1 || + chunk.byteStride !== SCALAR_BYTE_LENGTH || + chunk.rowByteLength !== SCALAR_BYTE_LENGTH || + 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 chunk`); + } +} + +/** Protects graph inputs, CSR statuses, and caller-owned output allocations from physical alias. */ +function validateDistinctLabelOutputs(propagation: LuGraphLabelPropagation): void { + const {topology} = propagation; + 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 outputs = [ + {name: 'output', vector: propagation.output}, + ...(propagation.converged ? [{name: 'converged', vector: propagation.converged}] : []) + ]; + for (const {name, vector} of outputs) { + const buffer = getPhysicalBuffer(vector.data[0]); + if (allocations.has(buffer)) { + throw new Error(`${propagation.id} ${name} must use a distinct physical buffer allocation`); + } + allocations.add(buffer); + } +} + +/** Enumerates existing CSR payloads and statuses without changing their source chunk identity. */ +function getAdjacencyVectors( + adjacency: LuGraphAdjacency +): (GPUVector<'uint32'> | GPUVector<'float32'>)[] { + return [ + adjacency.offsets, + adjacency.neighbors, + adjacency.edgeIds, + ...(adjacency.edgeWeights ? [adjacency.edgeWeights] : []), + adjacency.count, + adjacency.overflow + ]; +} + +/** Resolves DynamicBuffer wrappers so non-overlapping slices cannot disguise physical aliasing. */ +function getPhysicalBuffer(chunk: GPUData<'uint32'> | GPUData<'float32'>): Buffer { + return chunk.buffer instanceof DynamicBuffer ? chunk.buffer.buffer : chunk.buffer; +} diff --git a/modules/experimental/test/lugraph/lu-graph-label-propagation.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-label-propagation.node.spec.ts new file mode 100644 index 0000000000..18851dfbc7 --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-label-propagation.node.spec.ts @@ -0,0 +1,473 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// 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, + LuGraphLabelPropagation, + LuGraphTopology, + type LuGraphAdjacency, + type LuGraphLabelPropagationProps +} 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 ScalarFormat = 'uint32' | 'float32'; +type ScalarValues = Uint32Array | Float32Array; + +type PropagationFixture = { + device: NullDevice; + buffers: Buffer[]; + dynamicBuffers: DynamicBuffer[]; + vectors: GPUVector[]; +}; + +type VectorOptions = { + buffer?: Buffer | DynamicBuffer; + byteOffset?: number; + byteStride?: number; + rowByteLength?: number; + stride?: number; +}; + +const propagationFixtures: PropagationFixture[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const fixture of propagationFixtures.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('LuGraphLabelPropagation public contract and ownership', () => { + test('keeps community detection isolated in the optional luGraph entry point', () => { + expect(typeof LuGraphLabelPropagation).toBe('function'); + expect('LuGraphLabelPropagation' in experimentalModule).toBe(false); + }); + + test('preserves graph topology and caller outputs without allocations, submission, or readback', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture, {reverse: true, weighted: true, status: 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 propagation = new LuGraphLabelPropagation({...props, id: 'borrowed-communities'}); + + expect(propagation.id).toBe('borrowed-communities'); + expect(propagation.topology).toBe(props.topology); + expect(propagation.output).toBe(props.output); + expect(propagation.converged).toBe(props.converged); + expect(propagation.iterations).toBe(32); + expect(propagation.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(propagation, 'destroy')).toBe(false); + + for (const vector of fixture.vectors) vector.destroy(); + expect(fixture.buffers.every(buffer => !buffer.destroyed)).toBe(true); + }); + + test('requires reverse adjacency for directed weak-neighbor community votes', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture, {reverse: false}); + + expect(() => new LuGraphLabelPropagation(props)).toThrow(/directed|reverse/); + }); + + test('accepts directed reverse CSR without optional convergence status', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + const propagation = new LuGraphLabelPropagation(props); + + expect(propagation.id).toBe('lu-graph-label-propagation'); + expect(propagation.topology.graph.directed).toBe(true); + expect(propagation.topology.reverse).toBeDefined(); + expect(propagation.converged).toBeUndefined(); + }); + + test('accepts symmetric undirected forward adjacency without reverse CSR', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture, {directed: false}); + const propagation = new LuGraphLabelPropagation(props); + + expect(propagation.topology.graph.directed).toBe(false); + expect(propagation.topology.reverse).toBeUndefined(); + }); + + test('accepts empty graph outputs and optional uint32 convergence status', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture, {vertexCount: 0, status: true}); + const propagation = new LuGraphLabelPropagation(props); + + expect(propagation.output.length).toBe(0); + expect(propagation.output.data).toHaveLength(1); + expect(propagation.output.data[0].buffer.byteLength).toBeGreaterThanOrEqual(4); + expect(propagation.converged?.length).toBe(1); + }); +}); + +describe('LuGraphLabelPropagation iteration and vector validation', () => { + test.each([1, 32, 1024])('accepts a positive bounded iteration count: %i', iterations => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + + expect(new LuGraphLabelPropagation({...props, iterations}).iterations).toBe(iterations); + }); + + test.each([ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + 1025 + ])('rejects an invalid or excessive iteration count: %s', iterations => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + + expect(() => new LuGraphLabelPropagation({...props, iterations})).toThrow( + /iterations|positive|1024/ + ); + }); + + test.each([5, 7])('requires exactly one output row per graph vertex: %i', length => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + const output = createVector(fixture, `component-length-${length}`, 'uint32', [ + new Uint32Array(length) + ]); + + expect(() => new LuGraphLabelPropagation({...props, output})).toThrow( + /output|vertexCount|length/ + ); + }); + + test('requires uint32 component IDs instead of float32', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + const output = createVector(fixture, 'float-components', 'float32', [ + new Float32Array(props.topology.graph.vertexCount) + ]) as unknown as GPUVector<'uint32'>; + + expect(() => new LuGraphLabelPropagation({...props, output})).toThrow(/output|uint32|packed/); + }); + + test.each([0, 2])('requires exactly one physical component-ID chunk: %i', chunkCount => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + const chunks = chunkCount === 0 ? [] : [new Uint32Array(3), new Uint32Array(3)]; + const output = createVector(fixture, 'partitioned-components', 'uint32', chunks); + + expect(() => new LuGraphLabelPropagation({...props, output})).toThrow( + /output|one|single|chunk/ + ); + }); + + test.each([ + ['misaligned byte offset', {byteOffset: 2}], + ['padded byte stride', {byteStride: 8}], + ['oversized row payload', {rowByteLength: 8}], + ['multi-component scalar stride', {stride: 2}] + ] as [string, VectorOptions][])('rejects unpacked component output: %s', (_name, options) => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + const output = createVector( + fixture, + 'unpacked-components', + 'uint32', + [new Uint32Array(props.topology.graph.vertexCount)], + options + ); + + expect(() => new LuGraphLabelPropagation({...props, output})).toThrow( + /output|packed|aligned|uint32/ + ); + }); + + test.each([0, 2])('requires exactly one convergence status row: %i', length => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture, {status: true}); + const converged = createVector(fixture, `convergence-length-${length}`, 'uint32', [ + new Uint32Array(length) + ]); + + expect(() => new LuGraphLabelPropagation({...props, converged})).toThrow( + /converged|one|row|scalar/ + ); + }); + + test('requires packed uint32 convergence status with exactly one chunk', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture, {status: true}); + const wrongFormat = createVector(fixture, 'float-convergence', 'float32', [ + new Float32Array(1) + ]) as unknown as GPUVector<'uint32'>; + const partitioned = createVector(fixture, 'partitioned-convergence', 'uint32', [ + new Uint32Array(1), + new Uint32Array(0) + ]); + + expect(() => new LuGraphLabelPropagation({...props, converged: wrongFormat})).toThrow( + /converged|uint32|packed/ + ); + expect(() => new LuGraphLabelPropagation({...props, converged: partitioned})).toThrow( + /converged|one|single|chunk/ + ); + }); + + test('accepts uint32-aligned component and status ranges at non-256-byte offsets', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + const output = createVector( + fixture, + 'offset-components', + 'uint32', + [new Uint32Array(props.topology.graph.vertexCount)], + {byteOffset: 4} + ); + const converged = createVector(fixture, 'offset-convergence', 'uint32', [new Uint32Array(1)], { + byteOffset: 4 + }); + + const propagation = new LuGraphLabelPropagation({...props, output, converged}); + expect(propagation.output.data[0].byteOffset).toBe(4); + expect(propagation.converged?.data[0].byteOffset).toBe(4); + }); + + test.each([ + 'sourceVertices', + 'targetVertices', + 'edgeWeights', + 'edgeIds', + 'forward.offsets', + 'forward.neighbors', + 'forward.edgeIds', + 'forward.edgeWeights', + 'forward.count', + 'forward.overflow', + 'reverse.offsets', + 'reverse.neighbors', + 'reverse.edgeIds', + 'reverse.edgeWeights', + 'reverse.count', + 'reverse.overflow', + 'invalidEdgeCount' + ])('rejects writable labels backed by existing physical allocation: %s', vectorName => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture, {vertexCount: 1, reverse: true, weighted: true}); + const vector = getTopologyVector(props.topology, vectorName); + const output = createVector(fixture, 'aliased-components', 'uint32', [new Uint32Array(1)], { + buffer: vector.data[0].buffer + }); + + expect(() => new LuGraphLabelPropagation({...props, output})).toThrow( + /output|distinct|physical|allocation/ + ); + }); + + test('rejects convergence status aliasing component labels or topology allocations', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture, {vertexCount: 1, status: true}); + const outputAlias = createVector( + fixture, + 'aliased-output-status', + 'uint32', + [new Uint32Array(1)], + { + buffer: props.output.data[0].buffer + } + ); + const topologyAlias = createVector( + fixture, + 'aliased-topology-status', + 'uint32', + [new Uint32Array(1)], + {buffer: props.topology.invalidEdgeCount.data[0].buffer} + ); + + expect(() => new LuGraphLabelPropagation({...props, converged: outputAlias})).toThrow( + /converged|distinct|physical|allocation/ + ); + expect(() => new LuGraphLabelPropagation({...props, converged: topologyAlias})).toThrow( + /converged|distinct|physical|allocation/ + ); + }); + + test('unwraps borrowed DynamicBuffer wrappers when checking physical component aliases', () => { + const fixture = createPropagationFixture(); + const props = createPropagationProps(fixture); + const concreteBuffer = props.topology.forward.offsets.data[0].buffer as Buffer; + const dynamicBuffer = new DynamicBuffer(fixture.device, { + id: 'borrowed-offset-wrapper', + buffer: concreteBuffer, + ownsBuffer: false + }); + fixture.dynamicBuffers.push(dynamicBuffer); + const output = createVector( + fixture, + 'dynamic-aliased-components', + 'uint32', + [new Uint32Array(props.topology.graph.vertexCount)], + {buffer: dynamicBuffer} + ); + + expect(() => new LuGraphLabelPropagation({...props, output})).toThrow( + /distinct|physical|allocation/ + ); + expect(concreteBuffer.destroyed).toBe(false); + }); +}); + +function createPropagationFixture(): PropagationFixture { + const fixture = {device: new NullDevice({}), buffers: [], dynamicBuffers: [], vectors: []}; + propagationFixtures.push(fixture); + return fixture; +} + +function createPropagationProps( + fixture: PropagationFixture, + options: { + vertexCount?: number; + directed?: boolean; + reverse?: boolean; + weighted?: boolean; + status?: boolean; + } = {} +): LuGraphLabelPropagationProps { + 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 graph = new LuGraph({ + vertexCount, + sourceVertices, + targetVertices, + edgeWeights, + edgeIds, + directed: options.directed + }); + const forward = createAdjacency(fixture, 'forward', vertexCount, 5, options.weighted); + const reverse = + (options.reverse ?? options.directed !== false) + ? 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 output = createVector(fixture, 'componentIds', 'uint32', [new Uint32Array(vertexCount)]); + const converged = options.status + ? createVector(fixture, 'converged', 'uint32', [new Uint32Array(1)]) + : undefined; + return {topology, output, converged}; +} + +function createAdjacency( + fixture: PropagationFixture, + 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: PropagationFixture, + name: string, + format: Format, + chunks: readonly ScalarValues[], + options: VectorOptions = {} +): GPUVector { + const byteOffset = options.byteOffset ?? 0; + const byteStride = options.byteStride ?? Uint32Array.BYTES_PER_ELEMENT; + const rowByteLength = options.rowByteLength ?? Uint32Array.BYTES_PER_ELEMENT; + const stride = options.stride ?? 1; + const data = chunks.map((values, chunkIndex) => { + const buffer = + options.buffer ?? + fixture.device.createBuffer({ + id: `${name}-chunk-${chunkIndex}-${fixture.buffers.length}`, + byteLength: byteOffset + Math.max(Math.max(values.length, 1) * byteStride, rowByteLength), + usage: Buffer.STORAGE | Buffer.COPY_DST | Buffer.COPY_SRC + }); + if (!options.buffer) fixture.buffers.push(buffer as Buffer); + return new GPUData({ + buffer, + format, + length: values.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-label-propagation.spec.ts b/modules/experimental/test/lugraph/lu-graph-label-propagation.spec.ts new file mode 100644 index 0000000000..03d80b7508 --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-label-propagation.spec.ts @@ -0,0 +1,797 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Buffer, type Device} from '@luma.gl/core'; +import {GPUCommandGraph} from '@luma.gl/experimental'; +import { + LuGraph, + LuGraphLabelPropagation, + 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 { + addLuGraphLabelPropagationToGraphWithDispatchLimit, + getLuGraphLabelPropagationDispatchLayout +} from '../../src/lugraph/lu-graph-label-propagation-internals'; + +const INVALID_LABEL = 0xffffffff; + +type ScalarFormat = 'uint32' | 'float32'; + +type PropagationScenario = { + name: string; + vertexCount: number; + sourceChunks: number[][]; + targetChunks: number[][]; + weightChunks?: number[][]; + directed?: boolean; + reverse?: boolean; + capacity?: number; + reverseCapacity?: number; + iterations?: number; + status?: boolean; + maximumWorkgroups?: number; + byteOffset?: number; + assertSnapshotAllocation?: boolean; + expectedLabels?: number[]; + expectedConvergence?: boolean; + proveSingleWeakComponent?: boolean; +}; + +type ExpectedPropagation = { + labels: number[]; + invalidEdgeCount: number; + forwardCount: number; + reverseCount: number; + forwardOverflow: boolean; + reverseOverflow: boolean; + converged: boolean; +}; + +type PropagationExecutionFixture = { + device: Device; + buffers: Buffer[]; + vectors: GPUVector[]; + graph: LuGraph; + topology: LuGraphTopology; + propagation: LuGraphLabelPropagation; + commandGraph: GPUCommandGraph; + compiled?: ReturnType; +}; + +const propagationScenarios: PropagationScenario[] = [ + { + name: 'empty graphs publish a converged status and preserve zero-length label ownership', + vertexCount: 0, + sourceChunks: [], + targetChunks: [], + capacity: 0, + assertSnapshotAllocation: true + }, + { + name: 'empty graphs without convergence status allocate no graph-owned snapshot', + vertexCount: 0, + sourceChunks: [], + targetChunks: [], + capacity: 0, + status: false, + assertSnapshotAllocation: true + }, + { + name: 'isolated vertices retain their own stable community identifiers and converge', + vertexCount: 7, + sourceChunks: [[], []], + targetChunks: [[], []], + capacity: 0, + iterations: 1, + expectedLabels: [0, 1, 2, 3, 4, 5, 6], + expectedConvergence: true + }, + { + name: 'directed votes include both original forward and reverse weak neighbors', + vertexCount: 6, + sourceChunks: [[3, 2], [], [1]], + targetChunks: [[2, 1], [], [0]], + iterations: 6, + assertSnapshotAllocation: true + }, + { + name: 'disconnected directed communities retain independent stable labels', + vertexCount: 8, + sourceChunks: [[4, 3], [], [6]], + targetChunks: [[1, 2], [], [5]], + iterations: 4 + }, + { + name: 'duplicate and reciprocal occurrences each influence synchronous majority votes', + vertexCount: 4, + sourceChunks: [[1, 1], [], [1, 2, 0]], + targetChunks: [[2, 2], [], [2, 1, 1]], + iterations: 1, + expectedLabels: [0, 2, 1, 3], + expectedConvergence: false + }, + { + name: 'self-loop edges never contribute extra self votes or change tie resolution', + vertexCount: 3, + sourceChunks: [[1, 1], [], [1, 1, 0]], + targetChunks: [[1, 1], [], [1, 0, 1]], + iterations: 1, + expectedLabels: [1, 0, 2], + expectedConvergence: false + }, + { + name: 'one self vote and equal neighbor support resolve to the lowest numeric label', + vertexCount: 3, + sourceChunks: [[1], [], [1]], + targetChunks: [[0], [], [2]], + iterations: 1, + expectedLabels: [0, 0, 1], + expectedConvergence: false + }, + { + name: 'two dense bridged cliques remain separate communities within one weak component', + vertexCount: 8, + sourceChunks: [ + [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3], + [], + [4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 3] + ], + targetChunks: [ + [1, 2, 3, 0, 2, 3, 0, 1, 3, 0, 1, 2], + [], + [5, 6, 7, 4, 6, 7, 4, 5, 7, 4, 5, 6, 4] + ], + iterations: 3, + expectedLabels: [0, 0, 0, 0, 4, 4, 4, 4], + expectedConvergence: true, + proveSingleWeakComponent: true + }, + { + name: 'the same bridged cliques report an unproven fixed point after their changed second round', + vertexCount: 8, + sourceChunks: [ + [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3], + [], + [4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 3] + ], + targetChunks: [ + [1, 2, 3, 0, 2, 3, 0, 1, 3, 0, 1, 2], + [], + [5, 6, 7, 4, 6, 7, 4, 5, 7, 4, 5, 6, 4] + ], + iterations: 2, + expectedLabels: [0, 0, 0, 0, 4, 4, 4, 4], + expectedConvergence: false + }, + { + name: 'cycles, duplicate edges, diamonds, and self-loops remain deterministic', + vertexCount: 8, + sourceChunks: [[4, 3, 3], [], [1, 2, 2, 6]], + targetChunks: [[3, 2, 2], [], [2, 4, 2, 6]], + iterations: 8 + }, + { + name: 'reciprocal majority votes can oscillate without falsely proving convergence', + vertexCount: 2, + sourceChunks: [[0], [], [1]], + targetChunks: [[1], [], [0]], + iterations: 2, + expectedLabels: [0, 1], + expectedConvergence: false + }, + { + name: 'weighted undirected chunks vote symmetrically while ignoring preserved edge weights', + vertexCount: 7, + sourceChunks: [[0, 3], [], [3, 5]], + targetChunks: [[1, 2], [], [4, 5]], + weightChunks: [[0.5, 2], [], [4, 8]], + directed: false, + iterations: 5 + }, + { + name: 'invalid endpoints are excluded without connecting unrelated isolated vertices', + vertexCount: 6, + sourceChunks: [[0, 9], [], [2, 4, 5]], + targetChunks: [[1, 2], [], [8, 5, 5]], + iterations: 5 + }, + { + name: 'optional convergence output can be omitted while labels remain deterministic', + vertexCount: 5, + sourceChunks: [[4, 2]], + targetChunks: [[2, 1]], + iterations: 5, + status: false + }, + { + name: 'required directed reverse overflow fails closed across all community labels', + vertexCount: 5, + sourceChunks: [[0, 1, 3]], + targetChunks: [[1, 2, 4]], + reverseCapacity: 0, + iterations: 5 + }, + { + name: 'zero forward capacity fails closed with invalid labels and unconverged status', + vertexCount: 4, + sourceChunks: [[0, 1]], + targetChunks: [[1, 2]], + capacity: 0, + iterations: 4 + }, + { + name: 'partial forward adjacency also fails closed without exposing partial communities', + vertexCount: 5, + sourceChunks: [[0, 1, 2]], + targetChunks: [[1, 2, 3]], + capacity: 2, + iterations: 4 + }, + { + name: 'a final changed iteration conservatively reports incomplete despite final labels', + vertexCount: 2, + sourceChunks: [[0]], + targetChunks: [[1]], + iterations: 1, + expectedLabels: [0, 0], + expectedConvergence: false + }, + { + name: 'a bounded synchronous chain publishes deterministic partial community labels', + vertexCount: 12, + sourceChunks: [[11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]], + targetChunks: [[10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]], + iterations: 1, + expectedConvergence: false + }, + { + name: 'a no-change final synchronous vote proves community convergence', + vertexCount: 2, + sourceChunks: [[0]], + targetChunks: [[1]], + iterations: 2, + expectedLabels: [0, 0], + expectedConvergence: true + }, + { + name: 'non-256-aligned CSR offsets, community labels, and convergence status stay correct', + vertexCount: 5, + sourceChunks: [[3, 1]], + targetChunks: [[2, 0]], + iterations: 4, + byteOffset: 4 + }, + { + name: 'bounded three-dimensional synchronous voting reaches the final of 1025 vertices', + vertexCount: 1025, + sourceChunks: [[1024]], + targetChunks: [[0]], + iterations: 2, + maximumWorkgroups: 2 + } +]; + +test('LuGraphLabelPropagation plans bounded three-dimensional vertex dispatch', tapeTest => { + tapeTest.deepEqual(getLuGraphLabelPropagationDispatchLayout(0, 2), {x: 1, y: 1, z: 1}); + tapeTest.deepEqual(getLuGraphLabelPropagationDispatchLayout(512, 2), {x: 2, y: 1, z: 1}); + tapeTest.deepEqual(getLuGraphLabelPropagationDispatchLayout(513, 2), {x: 2, y: 2, z: 1}); + tapeTest.deepEqual(getLuGraphLabelPropagationDispatchLayout(1025, 2), {x: 2, y: 2, z: 2}); + tapeTest.throws(() => getLuGraphLabelPropagationDispatchLayout(2049, 2), /3D dispatch limit/); + tapeTest.end(); +}); + +for (const scenario of propagationScenarios) { + test(`LuGraphLabelPropagation GPU labeling: ${scenario.name}`, async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const expected = calculateExpectedPropagation(scenario); + const fixture = createExecutionFixture(device, scenario, expected); + try { + compilePropagation(fixture, scenario.maximumWorkgroups); + executePropagation(fixture); + await assertPropagation(tapeTest, fixture, expected); + tapeTest.deepEqual( + fixture.graph.sourceVertices.data.map(chunk => chunk.length), + scenario.sourceChunks.map(chunk => chunk.length), + 'community voting preserves every original source chunk' + ); + if (scenario.expectedLabels) { + tapeTest.deepEqual( + expected.labels, + scenario.expectedLabels, + 'the independent CPU majority oracle matches the explicit stable-label fixture' + ); + } + if (scenario.expectedConvergence !== undefined) { + tapeTest.equal( + expected.converged, + scenario.expectedConvergence, + 'the independent CPU oracle proves convergence only after an unchanged final round' + ); + } + if (scenario.proveSingleWeakComponent) { + tapeTest.equal( + countWeakComponents(scenario), + 1, + 'both distinct dense community labels belong to one actual weakly connected component' + ); + } + if (scenario.assertSnapshotAllocation) { + const propagationOnlyGraph = new GPUCommandGraph(device); + fixture.propagation.addToGraph(propagationOnlyGraph); + const compiledPropagationOnly = propagationOnlyGraph.compile(); + tapeTest.equal( + compiledPropagationOnly.stats.logicalTransientBufferCount, + scenario.vertexCount > 0 ? 1 : 0, + 'synchronous propagation owns exactly one snapshot only when vertices exist' + ); + compiledPropagationOnly.destroy(); + } + } finally { + destroyExecutionFixture(tapeTest, fixture); + } + + tapeTest.end(); + }); +} + +test('LuGraphLabelPropagation rebuilds communities after source updates without hidden execution', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const original: PropagationScenario = { + name: 'repeat deterministic communities', + vertexCount: 6, + sourceChunks: [[0, 1], [], [3, 4]], + targetChunks: [[1, 2], [], [4, 5]], + iterations: 5 + }; + const fixture = createExecutionFixture(device, original, calculateExpectedPropagation(original)); + const submitSpy = vi.spyOn(device, 'submit'); + const sourceReadbackSpies = [ + ...fixture.graph.sourceVertices.data, + ...fixture.graph.targetVertices.data + ].map(chunk => vi.spyOn(chunk.buffer, 'readAsync')); + + try { + compilePropagation(fixture); + tapeTest.equal( + submitSpy.mock.calls.length, + 0, + 'construction and compilation never submit work' + ); + tapeTest.ok( + sourceReadbackSpies.every(spy => spy.mock.calls.length === 0), + 'community propagation never reads graph source buffers back' + ); + submitSpy.mockRestore(); + for (const sourceReadbackSpy of sourceReadbackSpies) sourceReadbackSpy.mockRestore(); + + executePropagation(fixture); + await assertPropagation(tapeTest, fixture, calculateExpectedPropagation(original)); + + const sourceBuffer = fixture.graph.sourceVertices.data[0].buffer as Buffer; + sourceBuffer.write(Uint32Array.from([9, 1])); + const updated = {...original, sourceChunks: [[9, 1], [], [3, 4]]}; + executePropagation(fixture); + await assertPropagation(tapeTest, fixture, calculateExpectedPropagation(updated)); + tapeTest.equal( + fixture.graph.sourceVertices.data[0].buffer, + sourceBuffer, + 'source updates retain exact caller-owned chunk identity' + ); + } finally { + submitSpy.mockRestore(); + for (const sourceReadbackSpy of sourceReadbackSpies) sourceReadbackSpy.mockRestore(); + destroyExecutionFixture(tapeTest, fixture); + } + + tapeTest.end(); +}); + +/** Evaluates independent synchronous weak-neighbor majority voting from stable vertex IDs. */ +function calculateExpectedPropagation(scenario: PropagationScenario): ExpectedPropagation { + const directed = scenario.directed !== false; + const hasReverse = scenario.reverse ?? directed; + const neighbors: number[][] = Array.from({length: scenario.vertexCount}, () => []); + let invalidEdgeCount = 0; + let validEdgeCount = 0; + let forwardCount = 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++; + forwardCount += !directed && source !== target ? 2 : 1; + if (source !== target) { + neighbors[source].push(target); + neighbors[target].push(source); + } + } + } + + const forwardOverflow = forwardCount > (scenario.capacity ?? forwardCount); + const reverseCount = hasReverse ? validEdgeCount : 0; + const reverseOverflow = reverseCount > (scenario.reverseCapacity ?? reverseCount); + const failed = forwardOverflow || (directed && reverseOverflow); + let labels = Array.from({length: scenario.vertexCount}, (_, vertexIndex) => vertexIndex); + let converged = scenario.vertexCount === 0; + + if (failed) { + labels.fill(INVALID_LABEL); + converged = false; + } else { + const iterations = scenario.iterations ?? 32; + for (let iteration = 0; iteration < iterations; iteration++) { + const previousLabels = labels; + labels = previousLabels.map((previousLabel, vertexIndex) => { + const votes = new Map([[previousLabel, 1]]); + for (const neighbor of neighbors[vertexIndex]) { + const label = previousLabels[neighbor]; + votes.set(label, (votes.get(label) ?? 0) + 1); + } + + let winningLabel = previousLabel; + let winningVotes = votes.get(winningLabel)!; + for (const [label, voteCount] of votes) { + if (voteCount > winningVotes || (voteCount === winningVotes && label < winningLabel)) { + winningLabel = label; + winningVotes = voteCount; + } + } + return winningLabel; + }); + converged = labels.every((label, vertexIndex) => label === previousLabels[vertexIndex]); + } + } + + return { + labels, + invalidEdgeCount, + forwardCount, + reverseCount, + forwardOverflow, + reverseOverflow, + converged + }; +} + +/** Separately proves that bridged dense communities are not weak-component identifiers. */ +function countWeakComponents(scenario: PropagationScenario): number { + const parents = Array.from({length: scenario.vertexCount}, (_, vertexIndex) => vertexIndex); + const findRoot = (vertex: number): number => { + let root = vertex; + while (parents[root] !== root) root = parents[root]; + return root; + }; + + 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) continue; + const sourceRoot = findRoot(source); + const targetRoot = findRoot(target); + if (sourceRoot !== targetRoot) + parents[Math.max(sourceRoot, targetRoot)] = Math.min(sourceRoot, targetRoot); + } + } + + return new Set(parents.map((_parent, vertexIndex) => findRoot(vertexIndex))).size; +} + +function createExecutionFixture( + device: Device, + scenario: PropagationScenario, + expected: ExpectedPropagation +): PropagationExecutionFixture { + 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 graph = new LuGraph({ + vertexCount: scenario.vertexCount, + sourceVertices, + targetVertices, + edgeWeights, + directed: scenario.directed + }); + const forward = createOutputAdjacency( + device, + buffers, + vectors, + 'forward', + scenario.vertexCount, + scenario.capacity ?? expected.forwardCount, + Boolean(edgeWeights), + scenario.byteOffset + ); + const reverse = + (scenario.reverse ?? scenario.directed !== false) + ? createOutputAdjacency( + device, + buffers, + vectors, + 'reverse', + scenario.vertexCount, + scenario.reverseCapacity ?? expected.reverseCount, + Boolean(edgeWeights), + scenario.byteOffset + ) + : undefined; + const invalidEdgeCount = createOutputVector( + device, + buffers, + vectors, + 'invalid-edges', + 'uint32', + 1 + ); + const topology = new LuGraphTopology({graph, forward, reverse, invalidEdgeCount}); + const output = createOutputVector( + device, + buffers, + vectors, + 'community-identifiers', + 'uint32', + scenario.vertexCount, + scenario.byteOffset + ); + const converged = + scenario.status === false + ? undefined + : createOutputVector( + device, + buffers, + vectors, + 'communities-converged', + 'uint32', + 1, + scenario.byteOffset + ); + const propagation = new LuGraphLabelPropagation({ + topology, + output, + iterations: scenario.iterations, + converged + }); + + return { + device, + buffers, + vectors, + graph, + topology, + propagation, + 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: createOutputVector( + device, + buffers, + vectors, + `${name}-offsets`, + 'uint32', + vertexCount + 1, + byteOffset + ), + neighbors: createOutputVector( + device, + buffers, + vectors, + `${name}-neighbors`, + 'uint32', + capacity + ), + edgeIds: createOutputVector(device, buffers, vectors, `${name}-edge-ids`, 'uint32', capacity), + edgeWeights: weighted + ? createOutputVector(device, buffers, vectors, `${name}-weights`, 'float32', capacity) + : undefined, + count: createOutputVector(device, buffers, vectors, `${name}-count`, 'uint32', 1), + overflow: createOutputVector(device, buffers, vectors, `${name}-overflow`, 'uint32', 1) + }; +} + +function createOutputVector( + device: Device, + buffers: Buffer[], + vectors: GPUVector[], + name: string, + format: Format, + length: number, + byteOffset = 0 +): GPUVector { + const buffer = device.createBuffer({ + id: name, + byteLength: byteOffset + Math.max(length, 1) * Uint32Array.BYTES_PER_ELEMENT, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }); + buffers.push(buffer); + const vector = new GPUVector({ + type: 'buffer', + name, + format, + buffer, + length, + byteOffset, + ownsBuffer: false + }); + vectors.push(vector); + return vector; +} + +function compilePropagation( + fixture: PropagationExecutionFixture, + maximumWorkgroups?: number +): void { + fixture.topology.addToGraph(fixture.commandGraph); + if (maximumWorkgroups === undefined) { + fixture.propagation.addToGraph(fixture.commandGraph); + } else { + addLuGraphLabelPropagationToGraphWithDispatchLimit( + fixture.propagation, + fixture.commandGraph, + maximumWorkgroups + ); + } + fixture.compiled = fixture.commandGraph.compile(); +} + +function executePropagation(fixture: PropagationExecutionFixture): void { + const commandEncoder = fixture.device.createCommandEncoder({id: 'lu-graph-community-test'}); + fixture.compiled!.encode(commandEncoder, {parameters: undefined}); + fixture.device.submit(commandEncoder.finish()); +} + +async function assertPropagation( + tapeTest: Test, + fixture: PropagationExecutionFixture, + expected: ExpectedPropagation +): Promise { + const [labels, convergence, invalidEdgeCount, forwardOverflow, reverseOverflow] = + await Promise.all([ + readUint32Vector(fixture.propagation.output), + fixture.propagation.converged + ? readUint32Vector(fixture.propagation.converged) + : Promise.resolve(undefined), + readUint32Vector(fixture.topology.invalidEdgeCount), + readUint32Vector(fixture.topology.forward.overflow), + fixture.topology.reverse + ? readUint32Vector(fixture.topology.reverse.overflow) + : Promise.resolve(undefined) + ]); + + tapeTest.deepEqual( + labels, + expected.labels, + 'GPU communities exactly match independent synchronous weak-neighbor majority voting' + ); + + if (convergence) { + tapeTest.equal( + convergence[0], + Number(expected.converged), + 'convergence is proven only when the final iteration makes no changes' + ); + } + tapeTest.equal( + invalidEdgeCount[0], + expected.invalidEdgeCount, + 'invalid source edges stay excluded' + ); + tapeTest.equal( + forwardOverflow[0], + Number(expected.forwardOverflow), + 'forward adjacency overflow remains explicit' + ); + if (reverseOverflow) { + tapeTest.equal( + reverseOverflow[0], + Number(expected.reverseOverflow), + 'required reverse adjacency overflow remains explicit' + ); + } + if (expected.forwardOverflow || expected.reverseOverflow) { + tapeTest.ok( + labels.every(label => label === INVALID_LABEL), + 'truncated required adjacency publishes no misleading partial community labels' + ); + } +} + +async function readUint32Vector(vector: GPUVector<'uint32'>): Promise { + if (vector.length === 0) return []; + const data = vector.data[0]; + const bytes = await (data.buffer as Buffer).readAsync( + data.byteOffset, + vector.length * Uint32Array.BYTES_PER_ELEMENT + ); + return Array.from(new Uint32Array(bytes.buffer, bytes.byteOffset, vector.length)); +} + +function destroyExecutionFixture(tapeTest: Test, fixture: PropagationExecutionFixture): void { + fixture.compiled?.destroy(); + for (const vector of fixture.vectors) vector.destroy(); + tapeTest.ok( + fixture.buffers.every(buffer => !buffer.destroyed), + 'compiled community graphs and borrowed vectors never destroy caller-owned buffers' + ); + for (const buffer of fixture.buffers) buffer.destroy(); +} From e5be07fce1c5afe8627fa4bfef31dc9a370c2ae9 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 23:14:37 -0400 Subject: [PATCH 2/4] docs(experimental): complete community detection import example --- docs/api-reference/experimental/lugraph.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index c809b07def..d7715955ab 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -261,6 +261,7 @@ provide the other unsupported features above. | `LuGraphDegree` | How many relationships touch each vertex in one direction? | One `uint32` degree per vertex | `O(V)` after adjacency exists | | `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 | +| `LuGraphLabelPropagation` | Which densely connected communities exist inside a connected network? | One deterministic `uint32` community label per vertex | At most `O(K × sum(degree²))` for `K` bounded iterations | | `LuGraphPageRank` | Which vertices receive influence from other important vertices? | One normalized `float32` score per vertex | `O(K × (V + E))` for `K` iterations | | `LuGraphForceLayout` | How can related vertices be positioned as a readable network? | Directly renderable `float32x2` positions and persistent velocities | `O(V² + E)` per exact force iteration | | `LuGraphSpatialForceLayout` | Can distant graph regions be approximated while nearby relationships remain exact? | The existing renderable layout positions plus explicit uniform-grid diagnostics | `Θ(V × G + P + E)` per spatial force iteration | @@ -735,6 +736,7 @@ import { LuGraphConnectedComponents, LuGraphDegree, LuGraphForceLayout, + LuGraphLabelPropagation, LuGraphPageRank, LuGraphSpatialForceLayout, LuGraphTopology @@ -785,6 +787,12 @@ new LuGraphConnectedComponents({ iterations: 32, converged: componentsConverged }).addToGraph(workflow); +new LuGraphLabelPropagation({ + topology, + output: communityIds, + iterations: 32, + converged: communitiesConverged +}).addToGraph(workflow); new LuGraphPageRank({ topology, output: importanceScores, From 56b98abe5b5a350b5a19feb9d778718f6c174c4e Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 5 Aug 2026 20:23:24 -0400 Subject: [PATCH 3/4] docs(lugraph): attribute independent cuGraph-inspired implementation --- docs/api-reference/experimental/lugraph.md | 11 ++++ .../lu-graph-label-propagation-internals.ts | 3 +- .../src/lugraph/lu-graph-label-propagation.ts | 3 +- .../examples/lugraph-attribution.node.spec.ts | 50 +++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 test/examples/lugraph-attribution.node.spec.ts diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index d7715955ab..af21498d97 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -24,6 +24,17 @@ This is an experimental, headless graph analytics API, not a graph database, vis framework, file importer, or general-purpose dataframe. Applications decide how data reaches the GPU, which results they render, when commands are submitted, and whether anything is read back. +## Attribution and licensing + +luGraph is inspired by [NVIDIA RAPIDS cuGraph](https://github.com/rapidsai/cugraph) and the NVIDIA +and RAPIDS contributors advancing GPU graph analytics. cuGraph is distributed under the +[Apache License 2.0](https://github.com/rapidsai/cugraph/blob/main/LICENSE). + +luGraph is an independently written, [MIT-licensed](https://github.com/visgl/luma.gl/blob/master/LICENSE) +vis.gl implementation for browser-native WebGPU. It does not copy or translate cuGraph source code +or CUDA implementations. It does not claim CUDA or cuGraph API compatibility, feature parity, +NVIDIA affiliation, or NVIDIA endorsement. + ## Explore a live GPU graph **What do graph relationships, vertex influence, connected groups, and neighborhood searches look diff --git a/modules/experimental/src/lugraph/lu-graph-label-propagation-internals.ts b/modules/experimental/src/lugraph/lu-graph-label-propagation-internals.ts index 87e39c233e..83a87385a6 100644 --- a/modules/experimental/src/lugraph/lu-graph-label-propagation-internals.ts +++ b/modules/experimental/src/lugraph/lu-graph-label-propagation-internals.ts @@ -1,6 +1,7 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// 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'; diff --git a/modules/experimental/src/lugraph/lu-graph-label-propagation.ts b/modules/experimental/src/lugraph/lu-graph-label-propagation.ts index 11455e3aca..c920cd690a 100644 --- a/modules/experimental/src/lugraph/lu-graph-label-propagation.ts +++ b/modules/experimental/src/lugraph/lu-graph-label-propagation.ts @@ -1,6 +1,7 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors +// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph. import type {Buffer} from '@luma.gl/core'; import {DynamicBuffer} from '@luma.gl/engine'; diff --git a/test/examples/lugraph-attribution.node.spec.ts b/test/examples/lugraph-attribution.node.spec.ts new file mode 100644 index 0000000000..9aa45772dc --- /dev/null +++ b/test/examples/lugraph-attribution.node.spec.ts @@ -0,0 +1,50 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import {readdirSync, readFileSync} from 'node:fs'; +import {describe, expect, test} from 'vitest'; + +const SOURCE_DIRECTORY = new URL('../../modules/experimental/src/lugraph/', import.meta.url); +const DOCUMENTATION_URL = new URL( + '../../docs/api-reference/experimental/lugraph.md', + import.meta.url +); +const EXPECTED_SOURCE_HEADER = [ + '// luma.gl', + '// SPDX-License-Identifier: MIT', + '// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors', + '// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph.' +].join('\n'); + +describe('luGraph RAPIDS attribution', () => { + test('accurately attributes every independently written production source file', () => { + const sourceFileNames = readdirSync(SOURCE_DIRECTORY) + .filter(fileName => fileName.endsWith('.ts')) + .sort(); + + expect(sourceFileNames.length).toBeGreaterThan(0); + + for (const sourceFileName of sourceFileNames) { + const source = readFileSync(new URL(sourceFileName, SOURCE_DIRECTORY), 'utf8'); + + expect(source.startsWith(EXPECTED_SOURCE_HEADER), sourceFileName).toBe(true); + expect(source).not.toContain('SPDX-License-Identifier: Apache-2.0'); + expect(source).not.toMatch(/SPDX-FileCopyrightText:.*NVIDIA/); + } + }); + + test('documents upstream licensing without claiming copied code or endorsement', () => { + const documentation = readFileSync(DOCUMENTATION_URL, 'utf8'); + + expect(documentation).toContain('## Attribution and licensing'); + expect(documentation).toContain('https://github.com/rapidsai/cugraph'); + expect(documentation).toContain('https://github.com/rapidsai/cugraph/blob/main/LICENSE'); + expect(documentation).toContain('[Apache License 2.0]'); + expect(documentation).toContain('[MIT-licensed]'); + expect(documentation).toContain('does not copy or translate cuGraph source code'); + expect(documentation).toContain('or CUDA implementations'); + expect(documentation).toContain('feature parity'); + expect(documentation).toContain('NVIDIA affiliation, or NVIDIA endorsement'); + }); +}); From cf7d00077e126987092cfe5960374a2b9e706f40 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Fri, 7 Aug 2026 10:37:11 -0400 Subject: [PATCH 4/4] docs(lugraph): explain deterministic GPU community detection --- docs/api-reference/experimental/lugraph.md | 100 ++++++++++-------- .../arrow/gpu/arrow-gpu-analytics-adapters.ts | 2 +- .../arrow-gpu-analytics-adapters.node.spec.ts | 2 +- .../arrow-gpu-analytics-adapters.spec.ts | 2 +- modules/experimental/src/lugraph/README.md | 27 +++-- .../lu-graph-label-propagation.node.spec.ts | 2 +- .../lu-graph-label-propagation.spec.ts | 2 +- .../examples/lugraph-attribution.node.spec.ts | 2 +- test/examples/lugraph-docs.node.spec.ts | 47 ++++++++ 9 files changed, 124 insertions(+), 62 deletions(-) diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index af21498d97..790af91379 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -9,9 +9,9 @@ import {LuGraphExplorerExample} from '@site/src/examples'; ## Overview A graph answers questions that individual table rows cannot: which accounts share a transaction, -which services depend on a failed service, which people are two introductions apart, and which -pages matter because other important pages link to them. Vertices represent those entities; edges -represent their relationships. +which services depend on a failed service, which people are two introductions apart, which tightly +connected groups exist inside a wider network, and which pages matter because other important +pages link to them. Vertices represent those entities; edges 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 @@ -24,17 +24,6 @@ This is an experimental, headless graph analytics API, not a graph database, vis framework, file importer, or general-purpose dataframe. Applications decide how data reaches the GPU, which results they render, when commands are submitted, and whether anything is read back. -## Attribution and licensing - -luGraph is inspired by [NVIDIA RAPIDS cuGraph](https://github.com/rapidsai/cugraph) and the NVIDIA -and RAPIDS contributors advancing GPU graph analytics. cuGraph is distributed under the -[Apache License 2.0](https://github.com/rapidsai/cugraph/blob/main/LICENSE). - -luGraph is an independently written, [MIT-licensed](https://github.com/visgl/luma.gl/blob/master/LICENSE) -vis.gl implementation for browser-native WebGPU. It does not copy or translate cuGraph source code -or CUDA implementations. It does not claim CUDA or cuGraph API compatibility, feature parity, -NVIDIA affiliation, or NVIDIA endorsement. - ## Explore a live GPU graph **What do graph relationships, vertex influence, connected groups, and neighborhood searches look @@ -224,7 +213,7 @@ luGraph keeps the complete intermediate pipeline on one WebGPU device: ```text Existing GPU edge columns -> compressed adjacency - -> degree / shortest paths / weak components / PageRank / force layout + -> degree / shortest paths / weak components / communities / PageRank / force layout -> caller-owned GPU result columns and directly renderable positions ``` @@ -244,12 +233,14 @@ 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, rank influential accounts, and arrange connected - people into a readable map. + of introductions, distinguish friend circles within connected networks, 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. + isolated dependency islands, reveal tightly linked ownership groups, and identify packages that + many important packages depend on. - **Transaction and fraud investigations:** follow transfers around a selected account, identify - connected groups of counterparties, and prioritize structurally important entities. + coordinated clusters inside a larger connected group of counterparties, and prioritize + structurally important entities. - **Transport and infrastructure maps:** inspect junction degree, unweighted hop reachability, disconnected subnetworks, and relationship-driven importance across a network. - **Knowledge and citation graphs:** follow citation links, identify connected collections, and @@ -278,9 +269,9 @@ provide the other unsupported features above. | `LuGraphSpatialForceLayout` | Can distant graph regions be approximated while nearby relationships remain exact? | The existing renderable layout positions plus explicit uniform-grid diagnostics | `Θ(V × G + P + E)` per spatial force iteration | `V` is the graph's explicit vertex count, `E` is its source-edge count, `G` is the uniform-grid -cell count, and `P` counts individual interactions in near or insufficiently distant cells. -Undirected adjacency contains both directions for ordinary edges; an undirected self-loop appears -once. +cell count, `P` counts individual interactions in near or insufficiently distant cells, and +`sum(degree²)` adds the squared weak-neighbor count of every vertex. Undirected adjacency contains +both directions for ordinary edges; an undirected self-loop appears once. ## Describe existing relationships with LuGraph @@ -474,8 +465,11 @@ a connected dependency graph, or color locally cohesive regions of a citation ne A connected component answers whether any path links two vertices. Community detection asks a different question: are these vertices more strongly connected to one another than to the rest of -the same network? Two tightly linked teams connected by one shared service remain in a single weak -component but can receive different community labels. +the same network? Imagine two teams whose members interact frequently within their own team but +share only one relationship across teams. That single bridge makes the whole network one weakly +connected component, while label propagation can still give each team a different community label. +Use weak components to find disconnected islands; use community labels to inspect local structure +within an island. ```ts import {LuGraphLabelPropagation} from '@luma.gl/experimental/lugraph'; @@ -488,18 +482,33 @@ const communities = new LuGraphLabelPropagation({ }); ``` -Each vertex starts with its stable identifier. Every iteration considers one self vote and every -incoming or outgoing neighbor occurrence, choosing the most frequent label and breaking ties with -the lowest identifier. Self-loops add no extra votes; duplicate and reciprocal edges vote -independently. Directed graphs therefore require both forward and reverse adjacency; undirected -graphs reuse their symmetric forward adjacency. A narrow bridge can leave two dense communities -distinct even when they belong to the same weakly connected component. - -The optional GPU convergence scalar is one only when the final synchronous iteration changes no -labels; bounded propagation can oscillate or stop without proving convergence. Required adjacency -overflow publishes `0xffffffff` labels and zero convergence. This deterministic heuristic is not -Louvain, Leiden, or modularity optimization, and its worst-case work per iteration is -`O(sum(degree²))`. +Every vertex begins with its stable vertex identifier as its label. Each synchronous round reads +the preceding round's complete label snapshot and selects the most frequent label among one self +vote and all incoming or outgoing neighbor occurrences. Equal vote counts choose the numerically +lowest label, so the result does not depend on unspecified adjacency ordering. Self-loops add no +extra self votes; duplicate edges and reciprocal directed edges vote independently. Existing edge +weights are preserved by topology but ignored by this unweighted majority vote. + +Directed graphs require both forward and reverse adjacency to include every weak neighbor. +Undirected graphs reuse symmetric forward adjacency without reverse CSR. `output` is a +caller-owned, packed `GPUVector<'uint32'>` containing exactly one community label per vertex; +the optional `converged` output is a separate, caller-owned one-row `GPUVector<'uint32'>`. +Neither allocation may physically alias graph inputs, adjacency storage, or another writable +output. + +The default is `32` synchronous rounds; applications can explicitly choose an integer from `1` +through `1024`. Every declared round is encoded without CPU synchronization, automatic readback, +or early termination. `converged` becomes one only when the final round changes no labels; zero +means that the chosen budget did not establish a fixed point. Some graphs can oscillate between +label assignments, so a bounded round count never guarantees convergence. An empty graph reports +convergence, and an isolated vertex retains its own identifier. + +If required forward or reverse adjacency overflows, all output labels become `0xffffffff` and +`converged` becomes zero rather than publishing partial communities. The worst-case work is +`O(sum(degree²))` per round because counting support for each candidate can rescan a vertex's +neighborhood; a high-degree hub can therefore be disproportionately expensive. This deterministic +label-propagation heuristic is not Louvain or Leiden, does not optimize modularity, and does not +guarantee objectively correct communities or a particular clustering quality. ## Rank incoming influence with LuGraphPageRank @@ -852,16 +861,17 @@ when exact all-pairs repulsion is the better fit. - Writable outputs require physically distinct GPU buffer allocations, including when a `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. Force layout preserves its existing positions and - clears velocities on required adjacency overflow. + unreachable distances, weak components and community detection publish `0xffffffff`, and + PageRank publishes zero scores when a required neighbor list overflowed. Force layout preserves + its existing positions and clears velocities on required adjacency overflow. - Spatial layout also preserves positions and clears velocities when its accepted count excludes any out-of-domain vertex or its explicit vertex-ID capacity overflows. - Degree remains exact under neighbor overflow because its input is the complete CSR offset range. - Renderable layout positions require both `Buffer.STORAGE` and `Buffer.VERTEX` usage on their original caller-owned allocation; position readback or repacking is never implicit. -- 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. +- Fixed component, community, 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 preservation does not imply distributed or multi-GPU execution. - The optional graph subpath does not supply automatic Arrow import, rendering, graph persistence, @@ -876,7 +886,7 @@ luGraph is inspired by [NVIDIA RAPIDS cuGraph](https://github.com/rapidsai/cugra and RAPIDS contributors advancing GPU graph analytics. cuGraph is distributed under the [Apache License 2.0](https://github.com/rapidsai/cugraph/blob/main/LICENSE). -This is an independently written, [MIT-licensed](https://github.com/visgl/luma.gl/blob/master/LICENSE) -vis.gl implementation for browser-native WebGPU; it does not copy or translate cuGraph source code. -It does not claim CUDA or cuGraph API compatibility, feature parity, NVIDIA affiliation, or NVIDIA -endorsement. +luGraph is an independently written, [MIT-licensed](https://github.com/visgl/luma.gl/blob/master/LICENSE) +vis.gl implementation for browser-native WebGPU. It does not copy or translate cuGraph source code +or CUDA implementations. It does not claim CUDA or cuGraph API compatibility, feature parity, +NVIDIA affiliation, or NVIDIA endorsement. diff --git a/modules/arrow/src/arrow/gpu/arrow-gpu-analytics-adapters.ts b/modules/arrow/src/arrow/gpu/arrow-gpu-analytics-adapters.ts index 45befb7811..9524644d75 100644 --- a/modules/arrow/src/arrow/gpu/arrow-gpu-analytics-adapters.ts +++ b/modules/arrow/src/arrow/gpu/arrow-gpu-analytics-adapters.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {Buffer, type Device} from '@luma.gl/core'; import { diff --git a/modules/arrow/test/arrow/arrow-gpu-analytics-adapters.node.spec.ts b/modules/arrow/test/arrow/arrow-gpu-analytics-adapters.node.spec.ts index 574947a0bd..97e4f77e24 100644 --- a/modules/arrow/test/arrow/arrow-gpu-analytics-adapters.node.spec.ts +++ b/modules/arrow/test/arrow/arrow-gpu-analytics-adapters.node.spec.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {readFileSync} from 'node:fs'; diff --git a/modules/arrow/test/arrow/arrow-gpu-analytics-adapters.spec.ts b/modules/arrow/test/arrow/arrow-gpu-analytics-adapters.spec.ts index 45547cb078..16d55d735d 100644 --- a/modules/arrow/test/arrow/arrow-gpu-analytics-adapters.spec.ts +++ b/modules/arrow/test/arrow/arrow-gpu-analytics-adapters.spec.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {makeGPUAnalyticsTableFromArrowTable} from '@luma.gl/arrow'; import {Buffer} from '@luma.gl/core'; diff --git a/modules/experimental/src/lugraph/README.md b/modules/experimental/src/lugraph/README.md index 4bfee4966d..50ca009698 100644 --- a/modules/experimental/src/lugraph/README.md +++ b/modules/experimental/src/lugraph/README.md @@ -7,22 +7,27 @@ 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, normalized PageRank with dangling-vertex redistribution, and -progressive exact force-directed layout with directly renderable GPU positions. An optional -`LuGraphSpatialForceLayout` can approximate distant uniform-grid cells while keeping nearby forces -exact; it preserves the same positions, explicit bounds, caller-owned indexing buffers, and -observable overflow status. These operations contribute work to a caller-owned `GPUCommandGraph`; -applications retain ownership of their buffers, rendering, command submission, and any explicitly -requested result readback. +weakly connected components, deterministic label-propagation communities, normalized PageRank with +dangling-vertex redistribution, and progressive exact force-directed layout with directly +renderable GPU positions. An optional `LuGraphSpatialForceLayout` can approximate distant +uniform-grid cells while keeping nearby forces exact; it preserves the same positions, explicit +bounds, caller-owned indexing buffers, and observable overflow status. These operations contribute +work to a caller-owned `GPUCommandGraph`; applications retain ownership of their buffers, +rendering, command submission, and any explicitly requested result readback. ## When to use luGraph Use luGraph to explore relationships that already live on the GPU: inspect social connections, trace service dependencies, investigate transaction networks, or rank linked documents without -copying every intermediate result back to JavaScript. Exact layout suits smaller graphs and -accuracy-sensitive workflows; the optional flat-grid approximation suits applications that can -trade some far-field accuracy for fewer individual force calculations. It is not Barnes–Hut, -ForceAtlas2, or a guaranteed subquadratic layout. +copying every intermediate result back to JavaScript. Weakly connected components find disconnected +islands; `LuGraphLabelPropagation` can expose friend circles, related service groups, or coordinated +accounts inside a connected island. Its deterministic, bounded majority-vote heuristic is not +Louvain or Leiden, does not optimize modularity, and does not guarantee convergence. + +Exact layout suits smaller graphs and accuracy-sensitive workflows; the optional +flat-grid approximation suits applications that can trade some far-field accuracy for fewer +individual force calculations. It is not Barnes–Hut, ForceAtlas2, or a guaranteed subquadratic +layout. See the [luGraph graph analytics guide](/docs/api-reference/experimental/lugraph) for when to use each operation, complete GPU-resident composition examples, and ownership and capacity contracts. diff --git a/modules/experimental/test/lugraph/lu-graph-label-propagation.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-label-propagation.node.spec.ts index 18851dfbc7..9985b4928a 100644 --- a/modules/experimental/test/lugraph/lu-graph-label-propagation.node.spec.ts +++ b/modules/experimental/test/lugraph/lu-graph-label-propagation.node.spec.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {Buffer} from '@luma.gl/core'; import {DynamicBuffer} from '@luma.gl/engine'; diff --git a/modules/experimental/test/lugraph/lu-graph-label-propagation.spec.ts b/modules/experimental/test/lugraph/lu-graph-label-propagation.spec.ts index 03d80b7508..2f80f059de 100644 --- a/modules/experimental/test/lugraph/lu-graph-label-propagation.spec.ts +++ b/modules/experimental/test/lugraph/lu-graph-label-propagation.spec.ts @@ -1,6 +1,6 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {Buffer, type Device} from '@luma.gl/core'; import {GPUCommandGraph} from '@luma.gl/experimental'; diff --git a/test/examples/lugraph-attribution.node.spec.ts b/test/examples/lugraph-attribution.node.spec.ts index 9aa45772dc..738160b73d 100644 --- a/test/examples/lugraph-attribution.node.spec.ts +++ b/test/examples/lugraph-attribution.node.spec.ts @@ -37,7 +37,7 @@ describe('luGraph RAPIDS attribution', () => { test('documents upstream licensing without claiming copied code or endorsement', () => { const documentation = readFileSync(DOCUMENTATION_URL, 'utf8'); - expect(documentation).toContain('## Attribution and licensing'); + expect(documentation.match(/^## Attribution and licensing$/gmu)).toHaveLength(1); expect(documentation).toContain('https://github.com/rapidsai/cugraph'); expect(documentation).toContain('https://github.com/rapidsai/cugraph/blob/main/LICENSE'); expect(documentation).toContain('[Apache License 2.0]'); diff --git a/test/examples/lugraph-docs.node.spec.ts b/test/examples/lugraph-docs.node.spec.ts index 2da5b96138..1c42651d4c 100644 --- a/test/examples/lugraph-docs.node.spec.ts +++ b/test/examples/lugraph-docs.node.spec.ts @@ -153,6 +153,7 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(graphDocumentation).toContain('**Question: How many direct relationships'); 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 form closely connected'); expect(graphDocumentation).toContain('**Question: Which vertices receive influence'); expect(graphDocumentation).toContain('**Question: How can I position connected entities'); }); @@ -164,6 +165,7 @@ describe('luGraph GPU-resident graph analytics documentation', () => { 'LuGraphDegree', 'LuGraphBreadthFirstSearch', 'LuGraphConnectedComponents', + 'LuGraphLabelPropagation', 'LuGraphPageRank', 'LuGraphForceLayout', 'LuGraphSpatialForceLayout' @@ -175,6 +177,8 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(packageDocumentation).toContain('vertex-degree queries'); expect(packageDocumentation).toContain('breadth-first shortest paths'); expect(packageDocumentation).toContain('weakly connected components'); + expect(packageDocumentation).toContain('deterministic label-propagation communities'); + expect(packageDocumentation).toContain('LuGraphLabelPropagation'); expect(packageDocumentation).toContain('normalized PageRank'); expect(packageDocumentation).toContain('progressive exact force-directed layout'); expect(packageDocumentation).toContain('LuGraphSpatialForceLayout'); @@ -188,6 +192,49 @@ describe('luGraph GPU-resident graph analytics documentation', () => { expect(graphDocumentation).toContain('device.submit(encoder.finish());'); }); + test('explains deterministic GPU communities, practical use cases, and honest limitations', () => { + expect(graphDocumentation).toContain( + '## Discover densely connected communities with LuGraphLabelPropagation' + ); + expect(graphDocumentation).toContain('circles of friends'); + expect(graphDocumentation).toContain('transaction accounts'); + expect(graphDocumentation).toContain('service ownership groups'); + expect(graphDocumentation).toContain('one weakly\nconnected component'); + expect(graphDocumentation).toContain('different community label'); + expect(graphDocumentation).toContain('Use weak components to find disconnected islands'); + expect(graphDocumentation).toContain('new LuGraphLabelPropagation({'); + expect(graphDocumentation).toContain('output: communityIds'); + expect(graphDocumentation).toContain('converged: communitiesConverged'); + expect(graphDocumentation).toContain('stable vertex identifier'); + expect(graphDocumentation).toContain("preceding round's complete label snapshot"); + expect(graphDocumentation).toContain('one self\nvote'); + expect(graphDocumentation).toContain('numerically\nlowest label'); + expect(graphDocumentation).toContain('Self-loops add no\nextra self votes'); + expect(graphDocumentation).toContain('duplicate edges and reciprocal directed edges'); + expect(graphDocumentation).toContain('ignored by this unweighted majority vote'); + expect(graphDocumentation).toContain('Directed graphs require both forward and reverse'); + expect(graphDocumentation).toContain('Undirected graphs reuse symmetric forward adjacency'); + expect(graphDocumentation).toContain("GPUVector<'uint32'>"); + expect(graphDocumentation).toContain('default is `32` synchronous rounds'); + expect(graphDocumentation).toContain('an integer from `1`'); + expect(graphDocumentation).toContain('through `1024`'); + expect(graphDocumentation).toContain('or early termination'); + expect(graphDocumentation).toContain('final round changes no labels'); + expect(graphDocumentation).toContain('graphs can oscillate'); + expect(graphDocumentation).toContain('An empty graph reports'); + expect(graphDocumentation).toContain('an isolated vertex retains its own identifier'); + expect(graphDocumentation).toContain('all output labels become `0xffffffff`'); + expect(graphDocumentation).toContain('`converged` becomes zero'); + expect(graphDocumentation).toContain('`O(sum(degree²))` per round'); + expect(graphDocumentation).toContain('a high-degree hub'); + expect(graphDocumentation).toContain('not Louvain or Leiden'); + expect(graphDocumentation).toContain('does not optimize modularity'); + expect(graphDocumentation).toContain('does not\nguarantee objectively correct communities'); + expect(packageDocumentation).toContain('find disconnected\nislands'); + expect(packageDocumentation).toContain('inside a connected island'); + expect(packageDocumentation).toContain('does not guarantee convergence'); + }); + test('documents overflow, direction, probability, iteration, and ownership boundaries honestly', () => { expect(graphDocumentation).toContain('`vertexCount + 1` rows'); expect(graphDocumentation).toContain(