Files
d 🔹 44e1fee31b fix(dashboard): O(N+K) per-layer aggregations, kill quadratic Array.includes (#102)
Three hot paths in the dashboard ran `layer.nodeIds.includes(node.id)`,
which is O(K) per check. Combined with their enclosing loops they
collectively spent quadratic time per render of the overview / per
filter recompute / per node-selection event. On the 4.8 MB knowledge
graph reported in #102, the overview render alone took ~470 ms of
synchronous main-thread work before ELK / React Flow ran — long
enough for the page to register as unresponsive.

Fix: precompute two indexes once when a graph is loaded.

  - `nodesById: Map<string, GraphNode>`
  - `nodeIdToLayerId: Map<string, string>`  (first layer wins, matching
    prior `findNodeLayer` semantics)

Both live in `useDashboardStore` and are rebuilt by `setGraph`. The
three call sites:

1. `useOverviewGraph` (GraphView.tsx) — per-layer complexity aggregation
   moved into a new `computeLayerStats(layer, nodesById)` helper that
   walks `layer.nodeIds` instead of filtering all `graph.nodes`. Search
   match counts now read straight from `nodeIdToLayerId` instead of
   rebuilding a layer index on every searchResults change.

2. `filterNodes` (utils/filters.ts) — takes `nodeIdToLayerId` instead of
   `Layer[]`; the layer-membership check is one Map.get() per node.
   Updated `ExportMenu.tsx` caller to pass the store-level index.

3. `findNodeLayer` (store.ts) — replaced with `nodeIdToLayerId.get()` at
   the four call sites. `navigateTourToLayer` helper updated to take the
   index rather than the whole graph.

Behavior is preserved exactly:

  - "First layer wins" semantics for nodes that appear in multiple
    layers (#102 schema doesn't forbid this).
  - 30 % aggregate-complexity threshold pinned by tests.
  - Layer filter that excludes layer-less orphans, but ungated when
    no layers are selected.

Verified locally:

  Bench (`scripts/benchmark-aggregations.mjs`, node 22):
    100 layers × 200 nodes (#102 shape):  475 ms → 2 ms  (232× faster)
    50  layers × 200 nodes:               116 ms → 0.6 ms (190× faster)
    30  layers × 100 nodes:                12 ms → 0.2 ms (63×  faster)

  Tests: `pnpm --filter @understand-anything/dashboard test`
    24 → 41 pass (+17 new tests across `layerStats.test.ts` and
    `filters.test.ts`, including a #102 perf-regression guard at
    100 layers × 100 nodes < 50 ms).
  `pnpm --filter @understand-anything/core test` — 654 / 654 pass.
  `pnpm --filter @understand-anything/dashboard exec tsc -b` — clean.
  `pnpm --filter @understand-anything/dashboard build` — clean.

Pre-existing on master and not from this branch: `pnpm lint` errors
out with "eslint: command not found" — `eslint` isn't installed by any
package and the root `lint` script is bare `eslint .`. Out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:49:20 +08:00

100 lines
3.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Per-layer aggregation perf benchmark.
//
// Mirrors the BEFORE shape (graph.nodes.filter(n => layer.nodeIds.includes(n.id))
// per layer) and the AFTER shape (single nodesById Map + iterate layer.nodeIds)
// from `useOverviewGraph` in `src/components/GraphView.tsx`. Issue #102 reported
// a 4.8 MB knowledge graph that froze the dashboard on overview render — the
// quadratic Array.includes pass was the dominant synchronous cost.
//
// We can't import the dashboard helper directly (Vite-bundled, no
// per-module dist), so the new shape is reproduced here in lockstep with
// `src/utils/layerStats.ts::computeLayerStats`.
//
// Usage:
// node understand-anything-plugin/packages/dashboard/scripts/benchmark-aggregations.mjs
import { performance } from "node:perf_hooks";
function makeGraph(layerCount, nodesPerLayer) {
const nodes = [];
const layers = [];
for (let li = 0; li < layerCount; li++) {
const ids = [];
for (let ni = 0; ni < nodesPerLayer; ni++) {
const id = `n-${li}-${ni}`;
const complexity = ["simple", "moderate", "complex"][(li + ni) % 3];
nodes.push({ id, complexity });
ids.push(id);
}
layers.push({ id: `L${li}`, nodeIds: ids });
}
return { nodes, layers };
}
// --- BEFORE: O(N × K × L) per overview render ----------------------------
function aggregateBefore(graph) {
const out = [];
for (const layer of graph.layers) {
const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id));
const c = { simple: 0, moderate: 0, complex: 0 };
for (const n of memberNodes) c[n.complexity]++;
const aggregate =
c.complex > memberNodes.length * 0.3
? "complex"
: c.moderate > memberNodes.length * 0.3
? "moderate"
: "simple";
out.push({ id: layer.id, aggregateComplexity: aggregate });
}
return out;
}
// --- AFTER: O(N + Σ K_i) per overview render ----------------------------
function aggregateAfter(graph, nodesById) {
const out = [];
for (const layer of graph.layers) {
const c = { simple: 0, moderate: 0, complex: 0 };
let resolved = 0;
for (const nid of layer.nodeIds) {
const node = nodesById.get(nid);
if (!node) continue;
resolved++;
c[node.complexity]++;
}
const aggregate =
c.complex > resolved * 0.3
? "complex"
: c.moderate > resolved * 0.3
? "moderate"
: "simple";
out.push({ id: layer.id, aggregateComplexity: aggregate });
}
return out;
}
function bench(label, layerCount, nodesPerLayer) {
const graph = makeGraph(layerCount, nodesPerLayer);
const nodesById = new Map(graph.nodes.map((n) => [n.id, n]));
const t0 = performance.now();
const before = aggregateBefore(graph);
const t1 = performance.now();
const after = aggregateAfter(graph, nodesById);
const t2 = performance.now();
const beforeMs = t1 - t0;
const afterMs = t2 - t1;
const speedup = afterMs > 0 ? beforeMs / afterMs : Infinity;
const parity = JSON.stringify(before) === JSON.stringify(after);
console.log(
`${label} (${layerCount} layers × ${nodesPerLayer} nodes = ${graph.nodes.length} total): ` +
`BEFORE ${beforeMs.toFixed(1)}ms | AFTER ${afterMs.toFixed(1)}ms | ` +
`${speedup.toFixed(1)}× faster | parity ${parity}`,
);
}
bench("small", 10, 50);
bench("medium", 30, 100);
bench("large", 50, 200);
bench("issue#102 shape", 100, 200);