Skip to content

Commit 0ed747e

Browse files
authored
Merge pull request #112 from voidborne-d/fix/large-graph-quadratic-aggregations
fix(dashboard): O(N+K) per-layer aggregations, kill quadratic Array.includes (#102)
2 parents 2320ac9 + 988533a commit 0ed747e

8 files changed

Lines changed: 561 additions & 51 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Per-layer aggregation perf benchmark.
2+
//
3+
// Mirrors the BEFORE shape (graph.nodes.filter(n => layer.nodeIds.includes(n.id))
4+
// per layer) and the AFTER shape (single nodesById Map + iterate layer.nodeIds)
5+
// from `useOverviewGraph` in `src/components/GraphView.tsx`. Issue #102 reported
6+
// a 4.8 MB knowledge graph that froze the dashboard on overview render — the
7+
// quadratic Array.includes pass was the dominant synchronous cost.
8+
//
9+
// We can't import the dashboard helper directly (Vite-bundled, no
10+
// per-module dist), so the new shape is reproduced here in lockstep with
11+
// `src/utils/layerStats.ts::computeLayerStats`.
12+
//
13+
// Usage:
14+
// node understand-anything-plugin/packages/dashboard/scripts/benchmark-aggregations.mjs
15+
16+
import { performance } from "node:perf_hooks";
17+
18+
function makeGraph(layerCount, nodesPerLayer) {
19+
const nodes = [];
20+
const layers = [];
21+
for (let li = 0; li < layerCount; li++) {
22+
const ids = [];
23+
for (let ni = 0; ni < nodesPerLayer; ni++) {
24+
const id = `n-${li}-${ni}`;
25+
const complexity = ["simple", "moderate", "complex"][(li + ni) % 3];
26+
nodes.push({ id, complexity });
27+
ids.push(id);
28+
}
29+
layers.push({ id: `L${li}`, nodeIds: ids });
30+
}
31+
return { nodes, layers };
32+
}
33+
34+
// --- BEFORE: O(N × K × L) per overview render ----------------------------
35+
function aggregateBefore(graph) {
36+
const out = [];
37+
for (const layer of graph.layers) {
38+
const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id));
39+
const c = { simple: 0, moderate: 0, complex: 0 };
40+
for (const n of memberNodes) c[n.complexity]++;
41+
const aggregate =
42+
c.complex > memberNodes.length * 0.3
43+
? "complex"
44+
: c.moderate > memberNodes.length * 0.3
45+
? "moderate"
46+
: "simple";
47+
out.push({ id: layer.id, aggregateComplexity: aggregate });
48+
}
49+
return out;
50+
}
51+
52+
// --- AFTER: O(N + Σ K_i) per overview render ----------------------------
53+
function aggregateAfter(graph, nodesById) {
54+
const out = [];
55+
for (const layer of graph.layers) {
56+
const c = { simple: 0, moderate: 0, complex: 0 };
57+
let resolved = 0;
58+
for (const nid of layer.nodeIds) {
59+
const node = nodesById.get(nid);
60+
if (!node) continue;
61+
resolved++;
62+
c[node.complexity]++;
63+
}
64+
const aggregate =
65+
c.complex > resolved * 0.3
66+
? "complex"
67+
: c.moderate > resolved * 0.3
68+
? "moderate"
69+
: "simple";
70+
out.push({ id: layer.id, aggregateComplexity: aggregate });
71+
}
72+
return out;
73+
}
74+
75+
function bench(label, layerCount, nodesPerLayer) {
76+
const graph = makeGraph(layerCount, nodesPerLayer);
77+
const nodesById = new Map(graph.nodes.map((n) => [n.id, n]));
78+
79+
const t0 = performance.now();
80+
const before = aggregateBefore(graph);
81+
const t1 = performance.now();
82+
const after = aggregateAfter(graph, nodesById);
83+
const t2 = performance.now();
84+
85+
const beforeMs = t1 - t0;
86+
const afterMs = t2 - t1;
87+
const speedup = afterMs > 0 ? beforeMs / afterMs : Infinity;
88+
const parity = JSON.stringify(before) === JSON.stringify(after);
89+
console.log(
90+
`${label} (${layerCount} layers × ${nodesPerLayer} nodes = ${graph.nodes.length} total): ` +
91+
`BEFORE ${beforeMs.toFixed(1)}ms | AFTER ${afterMs.toFixed(1)}ms | ` +
92+
`${speedup.toFixed(1)}× faster | parity ${parity}`,
93+
);
94+
}
95+
96+
bench("small", 10, 50);
97+
bench("medium", 30, 100);
98+
bench("large", 50, 200);
99+
bench("issue#102 shape", 100, 200);

understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function downloadBlob(blob: Blob, filename: string) {
2020

2121
export default function ExportMenu() {
2222
const graph = useDashboardStore((s) => s.graph);
23+
const nodeIdToLayerIds = useDashboardStore((s) => s.nodeIdToLayerIds);
2324
const filters = useDashboardStore((s) => s.filters);
2425
const exportMenuOpen = useDashboardStore((s) => s.exportMenuOpen);
2526
const toggleExportMenu = useDashboardStore((s) => s.toggleExportMenu);
@@ -187,7 +188,7 @@ export default function ExportMenu() {
187188
? graph.nodes.filter((n) => !subFileTypes.has(n.type))
188189
: graph.nodes;
189190

190-
filteredGraphNodes = filterNodes(filteredGraphNodes, graph.layers ?? [], filters);
191+
filteredGraphNodes = filterNodes(filteredGraphNodes, nodeIdToLayerIds, filters);
191192
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
192193

193194
let filteredGraphEdges = graph.edges.filter(

understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
} from "../utils/edgeAggregation";
5252
import { deriveContainers } from "../utils/containers";
5353
import type { DerivedContainer } from "../utils/containers";
54+
import { computeLayerStats } from "../utils/layerStats";
5455

5556
const nodeTypes = {
5657
custom: CustomNode,
@@ -139,6 +140,8 @@ function SelectedNodeFitView() {
139140

140141
function useOverviewGraph() {
141142
const graph = useDashboardStore((s) => s.graph);
143+
const nodesById = useDashboardStore((s) => s.nodesById);
144+
const nodeIdToLayerId = useDashboardStore((s) => s.nodeIdToLayerId);
142145
const searchResults = useDashboardStore((s) => s.searchResults);
143146
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
144147

@@ -154,36 +157,25 @@ function useOverviewGraph() {
154157
return null;
155158
}
156159

157-
// Build search match counts per layer
160+
// Build search match counts per layer using the precomputed
161+
// nodeIdToLayerId index. Reusing the store-level index avoids an extra
162+
// O(N) pass when search results change frequently.
158163
const searchMatchByLayer = new Map<string, number>();
159164
if (searchResults.length > 0) {
160-
const nodeToLayer = new Map<string, string>();
161-
for (const layer of layers) {
162-
for (const nid of layer.nodeIds) {
163-
nodeToLayer.set(nid, layer.id);
164-
}
165-
}
166165
for (const result of searchResults) {
167-
const lid = nodeToLayer.get(result.nodeId);
166+
const lid = nodeIdToLayerId.get(result.nodeId);
168167
if (lid) {
169168
searchMatchByLayer.set(lid, (searchMatchByLayer.get(lid) ?? 0) + 1);
170169
}
171170
}
172171
}
173172

174-
// Create cluster nodes
173+
// Create cluster nodes. Per-layer aggregation goes through
174+
// `computeLayerStats`, which iterates `layer.nodeIds` against the
175+
// `nodesById` index — O(K) per layer instead of the previous
176+
// O(N) Array.filter that ran `layer.nodeIds.includes(n.id)` (#102).
175177
const clusterNodes: LayerClusterFlowNode[] = layers.map((layer, i) => {
176-
const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id));
177-
const complexCounts = { simple: 0, moderate: 0, complex: 0 };
178-
for (const n of memberNodes) {
179-
complexCounts[n.complexity]++;
180-
}
181-
const aggregateComplexity =
182-
complexCounts.complex > memberNodes.length * 0.3
183-
? "complex"
184-
: complexCounts.moderate > memberNodes.length * 0.3
185-
? "moderate"
186-
: "simple";
178+
const { aggregateComplexity } = computeLayerStats(layer, nodesById);
187179

188180
return {
189181
id: layer.id,
@@ -222,7 +214,7 @@ function useOverviewGraph() {
222214
}
223215

224216
return { clusterNodes, flowEdges, dims };
225-
}, [graph, searchResults, drillIntoLayer]);
217+
}, [graph, nodesById, nodeIdToLayerId, searchResults, drillIntoLayer]);
226218

227219
const [overview, setOverview] = useState<{ nodes: Node[]; edges: Edge[] }>({
228220
nodes: [],

understand-anything-plugin/packages/dashboard/src/store.ts

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SearchEngine } from "@understand-anything/core/search";
33
import type { SearchResult } from "@understand-anything/core/search";
44
import type { GraphIssue } from "@understand-anything/core/schema";
55
import type {
6+
GraphNode,
67
KnowledgeGraph,
78
TourStep,
89
} from "@understand-anything/core/types";
@@ -49,19 +50,60 @@ const DEFAULT_FILTERS: FilterState = {
4950
/** Categories used for node type filter toggles. Single source of truth for NodeCategory. */
5051
export type NodeCategory = "code" | "config" | "docs" | "infra" | "data" | "domain" | "knowledge";
5152

52-
/** Find which layer a node belongs to. Returns layerId or null. */
53-
function findNodeLayer(graph: KnowledgeGraph, nodeId: string): string | null {
53+
/**
54+
* Build the (id → node) and (id → layerId) lookup maps that the rest of
55+
* the dashboard reads via store selectors. Centralised so `setGraph` and
56+
* any future graph-replacement path stay in sync.
57+
*
58+
* Two layer indexes, intentionally distinct:
59+
*
60+
* - `nodeIdToLayerId` preserves the prior `findNodeLayer` "first matching
61+
* layer wins" semantics — if a node id appears in multiple layers
62+
* (rare but legal in the schema), the first occurrence in `graph.layers`
63+
* order is the one we map to. Drives navigation (drillIntoLayer, tour
64+
* step → layer, sidebar history) where a single canonical layer is the
65+
* right answer.
66+
*
67+
* - `nodeIdToLayerIds` records *every* layer a node belongs to. Drives
68+
* membership queries (filterNodes) where the prior `Layer[] +
69+
* layer.nodeIds.includes` shape was any-layer-wins — a node in L1 and
70+
* L2 with only L2 selected must still pass. Collapsing to first-wins
71+
* for filtering would be a silent regression.
72+
*/
73+
function buildGraphIndexes(graph: KnowledgeGraph): {
74+
nodesById: Map<string, GraphNode>;
75+
nodeIdToLayerId: Map<string, string>;
76+
nodeIdToLayerIds: Map<string, Set<string>>;
77+
} {
78+
const nodesById = new Map<string, GraphNode>();
79+
for (const node of graph.nodes) nodesById.set(node.id, node);
80+
const nodeIdToLayerId = new Map<string, string>();
81+
const nodeIdToLayerIds = new Map<string, Set<string>>();
5482
for (const layer of graph.layers) {
55-
if (layer.nodeIds.includes(nodeId)) return layer.id;
83+
for (const nid of layer.nodeIds) {
84+
if (!nodeIdToLayerId.has(nid)) nodeIdToLayerId.set(nid, layer.id);
85+
let set = nodeIdToLayerIds.get(nid);
86+
if (!set) {
87+
set = new Set<string>();
88+
nodeIdToLayerIds.set(nid, set);
89+
}
90+
set.add(layer.id);
91+
}
5692
}
57-
return null;
93+
return { nodesById, nodeIdToLayerId, nodeIdToLayerIds };
5894
}
5995

6096
/** Maximum number of entries in the sidebar navigation history. */
6197
const MAX_HISTORY = 50;
6298

6399
interface DashboardStore {
64100
graph: KnowledgeGraph | null;
101+
/** id → node lookup, rebuilt by setGraph. Empty before any graph loads. */
102+
nodesById: Map<string, GraphNode>;
103+
/** id → layer id (first-matching-layer wins), rebuilt by setGraph. Empty before any graph loads. */
104+
nodeIdToLayerId: Map<string, string>;
105+
/** id → set of every layer the node belongs to, rebuilt by setGraph. Empty before any graph loads. */
106+
nodeIdToLayerIds: Map<string, Set<string>>;
65107
selectedNodeId: string | null;
66108
searchQuery: string;
67109
searchResults: SearchResult[];
@@ -189,11 +231,11 @@ function getSortedTour(graph: KnowledgeGraph): TourStep[] {
189231

190232
/** Navigate tour step to the correct layer for the first highlighted node. */
191233
function navigateTourToLayer(
192-
graph: KnowledgeGraph,
234+
nodeIdToLayerId: Map<string, string>,
193235
nodeIds: string[],
194236
): Partial<DashboardStore> {
195237
if (nodeIds.length === 0) return {};
196-
const layerId = findNodeLayer(graph, nodeIds[0]);
238+
const layerId = nodeIdToLayerId.get(nodeIds[0]);
197239
if (layerId) {
198240
return {
199241
navigationLevel: "layer-detail" as const,
@@ -205,6 +247,9 @@ function navigateTourToLayer(
205247

206248
export const useDashboardStore = create<DashboardStore>()((set, get) => ({
207249
graph: null,
250+
nodesById: new Map<string, GraphNode>(),
251+
nodeIdToLayerId: new Map<string, string>(),
252+
nodeIdToLayerIds: new Map<string, Set<string>>(),
208253
selectedNodeId: null,
209254
searchQuery: "",
210255
searchResults: [],
@@ -259,8 +304,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
259304
const { viewMode, domainGraph, activeDomainId } = get();
260305
// Preserve domain view if a domain graph is already loaded
261306
const keepDomainView = viewMode === "domain" && domainGraph !== null;
307+
const { nodesById, nodeIdToLayerId, nodeIdToLayerIds } = buildGraphIndexes(graph);
262308
set({
263309
graph,
310+
nodesById,
311+
nodeIdToLayerId,
312+
nodeIdToLayerIds,
264313
searchEngine,
265314
searchResults,
266315
navigationLevel: "overview",
@@ -296,9 +345,9 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
296345
},
297346

298347
navigateToNodeInLayer: (nodeId) => {
299-
const { graph, selectedNodeId, nodeHistory } = get();
348+
const { graph, selectedNodeId, nodeHistory, nodeIdToLayerId } = get();
300349
if (!graph) return;
301-
const layerId = findNodeLayer(graph, nodeId);
350+
const layerId = nodeIdToLayerId.get(nodeId) ?? null;
302351
const newHistory =
303352
selectedNodeId && nodeId !== selectedNodeId
304353
? [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY)
@@ -323,11 +372,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
323372
},
324373

325374
navigateToHistoryIndex: (index) => {
326-
const { nodeHistory, graph } = get();
375+
const { nodeHistory, graph, nodeIdToLayerId } = get();
327376
if (!graph || index < 0 || index >= nodeHistory.length) return;
328377
const targetId = nodeHistory[index];
329378
const newHistory = nodeHistory.slice(0, index);
330-
const layerId = findNodeLayer(graph, targetId);
379+
const layerId = nodeIdToLayerId.get(targetId) ?? null;
331380
set({
332381
selectedNodeId: targetId,
333382
nodeHistory: newHistory,
@@ -336,11 +385,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
336385
},
337386

338387
goBackNode: () => {
339-
const { nodeHistory, graph } = get();
388+
const { nodeHistory, graph, nodeIdToLayerId } = get();
340389
if (nodeHistory.length === 0 || !graph) return;
341390
const prevNodeId = nodeHistory[nodeHistory.length - 1];
342391
const newHistory = nodeHistory.slice(0, -1);
343-
const layerId = findNodeLayer(graph, prevNodeId);
392+
const layerId = nodeIdToLayerId.get(prevNodeId) ?? null;
344393
if (layerId) {
345394
set({
346395
navigationLevel: "layer-detail",
@@ -483,10 +532,10 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
483532
},
484533

485534
startTour: () => {
486-
const { graph } = get();
535+
const { graph, nodeIdToLayerId } = get();
487536
if (!graph || !graph.tour || graph.tour.length === 0) return;
488537
const sorted = getSortedTour(graph);
489-
const layerNav = navigateTourToLayer(graph, sorted[0].nodeIds);
538+
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[0].nodeIds);
490539
set({
491540
tourActive: true,
492541
currentTourStep: 0,
@@ -504,11 +553,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
504553
}),
505554

506555
setTourStep: (step) => {
507-
const { graph } = get();
556+
const { graph, nodeIdToLayerId } = get();
508557
if (!graph || !graph.tour || graph.tour.length === 0) return;
509558
const sorted = getSortedTour(graph);
510559
if (step < 0 || step >= sorted.length) return;
511-
const layerNav = navigateTourToLayer(graph, sorted[step].nodeIds);
560+
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[step].nodeIds);
512561
set({
513562
currentTourStep: step,
514563
tourHighlightedNodeIds: sorted[step].nodeIds,
@@ -517,12 +566,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
517566
},
518567

519568
nextTourStep: () => {
520-
const { graph, currentTourStep } = get();
569+
const { graph, currentTourStep, nodeIdToLayerId } = get();
521570
if (!graph || !graph.tour || graph.tour.length === 0) return;
522571
const sorted = getSortedTour(graph);
523572
if (currentTourStep < sorted.length - 1) {
524573
const next = currentTourStep + 1;
525-
const layerNav = navigateTourToLayer(graph, sorted[next].nodeIds);
574+
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[next].nodeIds);
526575
set({
527576
currentTourStep: next,
528577
tourHighlightedNodeIds: sorted[next].nodeIds,
@@ -532,12 +581,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
532581
},
533582

534583
prevTourStep: () => {
535-
const { graph, currentTourStep } = get();
584+
const { graph, currentTourStep, nodeIdToLayerId } = get();
536585
if (!graph || !graph.tour || graph.tour.length === 0) return;
537586
if (currentTourStep > 0) {
538587
const sorted = getSortedTour(graph);
539588
const prev = currentTourStep - 1;
540-
const layerNav = navigateTourToLayer(graph, sorted[prev].nodeIds);
589+
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[prev].nodeIds);
541590
set({
542591
currentTourStep: prev,
543592
tourHighlightedNodeIds: sorted[prev].nodeIds,

0 commit comments

Comments
 (0)