mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
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)
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
// 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);
|
||||
@@ -20,6 +20,7 @@ function downloadBlob(blob: Blob, filename: string) {
|
||||
|
||||
export default function ExportMenu() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const nodeIdToLayerIds = useDashboardStore((s) => s.nodeIdToLayerIds);
|
||||
const filters = useDashboardStore((s) => s.filters);
|
||||
const exportMenuOpen = useDashboardStore((s) => s.exportMenuOpen);
|
||||
const toggleExportMenu = useDashboardStore((s) => s.toggleExportMenu);
|
||||
@@ -187,7 +188,7 @@ export default function ExportMenu() {
|
||||
? graph.nodes.filter((n) => !subFileTypes.has(n.type))
|
||||
: graph.nodes;
|
||||
|
||||
filteredGraphNodes = filterNodes(filteredGraphNodes, graph.layers ?? [], filters);
|
||||
filteredGraphNodes = filterNodes(filteredGraphNodes, nodeIdToLayerIds, filters);
|
||||
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
|
||||
|
||||
let filteredGraphEdges = graph.edges.filter(
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "../utils/edgeAggregation";
|
||||
import { deriveContainers } from "../utils/containers";
|
||||
import type { DerivedContainer } from "../utils/containers";
|
||||
import { computeLayerStats } from "../utils/layerStats";
|
||||
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
@@ -139,6 +140,8 @@ function SelectedNodeFitView() {
|
||||
|
||||
function useOverviewGraph() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const nodesById = useDashboardStore((s) => s.nodesById);
|
||||
const nodeIdToLayerId = useDashboardStore((s) => s.nodeIdToLayerId);
|
||||
const searchResults = useDashboardStore((s) => s.searchResults);
|
||||
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
|
||||
|
||||
@@ -154,36 +157,25 @@ function useOverviewGraph() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build search match counts per layer
|
||||
// Build search match counts per layer using the precomputed
|
||||
// nodeIdToLayerId index. Reusing the store-level index avoids an extra
|
||||
// O(N) pass when search results change frequently.
|
||||
const searchMatchByLayer = new Map<string, number>();
|
||||
if (searchResults.length > 0) {
|
||||
const nodeToLayer = new Map<string, string>();
|
||||
for (const layer of layers) {
|
||||
for (const nid of layer.nodeIds) {
|
||||
nodeToLayer.set(nid, layer.id);
|
||||
}
|
||||
}
|
||||
for (const result of searchResults) {
|
||||
const lid = nodeToLayer.get(result.nodeId);
|
||||
const lid = nodeIdToLayerId.get(result.nodeId);
|
||||
if (lid) {
|
||||
searchMatchByLayer.set(lid, (searchMatchByLayer.get(lid) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create cluster nodes
|
||||
// Create cluster nodes. Per-layer aggregation goes through
|
||||
// `computeLayerStats`, which iterates `layer.nodeIds` against the
|
||||
// `nodesById` index — O(K) per layer instead of the previous
|
||||
// O(N) Array.filter that ran `layer.nodeIds.includes(n.id)` (#102).
|
||||
const clusterNodes: LayerClusterFlowNode[] = layers.map((layer, i) => {
|
||||
const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id));
|
||||
const complexCounts = { simple: 0, moderate: 0, complex: 0 };
|
||||
for (const n of memberNodes) {
|
||||
complexCounts[n.complexity]++;
|
||||
}
|
||||
const aggregateComplexity =
|
||||
complexCounts.complex > memberNodes.length * 0.3
|
||||
? "complex"
|
||||
: complexCounts.moderate > memberNodes.length * 0.3
|
||||
? "moderate"
|
||||
: "simple";
|
||||
const { aggregateComplexity } = computeLayerStats(layer, nodesById);
|
||||
|
||||
return {
|
||||
id: layer.id,
|
||||
@@ -222,7 +214,7 @@ function useOverviewGraph() {
|
||||
}
|
||||
|
||||
return { clusterNodes, flowEdges, dims };
|
||||
}, [graph, searchResults, drillIntoLayer]);
|
||||
}, [graph, nodesById, nodeIdToLayerId, searchResults, drillIntoLayer]);
|
||||
|
||||
const [overview, setOverview] = useState<{ nodes: Node[]; edges: Edge[] }>({
|
||||
nodes: [],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SearchEngine } from "@understand-anything/core/search";
|
||||
import type { SearchResult } from "@understand-anything/core/search";
|
||||
import type { GraphIssue } from "@understand-anything/core/schema";
|
||||
import type {
|
||||
GraphNode,
|
||||
KnowledgeGraph,
|
||||
TourStep,
|
||||
} from "@understand-anything/core/types";
|
||||
@@ -49,12 +50,47 @@ const DEFAULT_FILTERS: FilterState = {
|
||||
/** Categories used for node type filter toggles. Single source of truth for NodeCategory. */
|
||||
export type NodeCategory = "code" | "config" | "docs" | "infra" | "data" | "domain" | "knowledge";
|
||||
|
||||
/** Find which layer a node belongs to. Returns layerId or null. */
|
||||
function findNodeLayer(graph: KnowledgeGraph, nodeId: string): string | null {
|
||||
/**
|
||||
* Build the (id → node) and (id → layerId) lookup maps that the rest of
|
||||
* the dashboard reads via store selectors. Centralised so `setGraph` and
|
||||
* any future graph-replacement path stay in sync.
|
||||
*
|
||||
* Two layer indexes, intentionally distinct:
|
||||
*
|
||||
* - `nodeIdToLayerId` preserves the prior `findNodeLayer` "first matching
|
||||
* layer wins" semantics — if a node id appears in multiple layers
|
||||
* (rare but legal in the schema), the first occurrence in `graph.layers`
|
||||
* order is the one we map to. Drives navigation (drillIntoLayer, tour
|
||||
* step → layer, sidebar history) where a single canonical layer is the
|
||||
* right answer.
|
||||
*
|
||||
* - `nodeIdToLayerIds` records *every* layer a node belongs to. Drives
|
||||
* membership queries (filterNodes) where the prior `Layer[] +
|
||||
* layer.nodeIds.includes` shape was any-layer-wins — a node in L1 and
|
||||
* L2 with only L2 selected must still pass. Collapsing to first-wins
|
||||
* for filtering would be a silent regression.
|
||||
*/
|
||||
function buildGraphIndexes(graph: KnowledgeGraph): {
|
||||
nodesById: Map<string, GraphNode>;
|
||||
nodeIdToLayerId: Map<string, string>;
|
||||
nodeIdToLayerIds: Map<string, Set<string>>;
|
||||
} {
|
||||
const nodesById = new Map<string, GraphNode>();
|
||||
for (const node of graph.nodes) nodesById.set(node.id, node);
|
||||
const nodeIdToLayerId = new Map<string, string>();
|
||||
const nodeIdToLayerIds = new Map<string, Set<string>>();
|
||||
for (const layer of graph.layers) {
|
||||
if (layer.nodeIds.includes(nodeId)) return layer.id;
|
||||
for (const nid of layer.nodeIds) {
|
||||
if (!nodeIdToLayerId.has(nid)) nodeIdToLayerId.set(nid, layer.id);
|
||||
let set = nodeIdToLayerIds.get(nid);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
nodeIdToLayerIds.set(nid, set);
|
||||
}
|
||||
set.add(layer.id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return { nodesById, nodeIdToLayerId, nodeIdToLayerIds };
|
||||
}
|
||||
|
||||
/** Maximum number of entries in the sidebar navigation history. */
|
||||
@@ -62,6 +98,12 @@ const MAX_HISTORY = 50;
|
||||
|
||||
interface DashboardStore {
|
||||
graph: KnowledgeGraph | null;
|
||||
/** id → node lookup, rebuilt by setGraph. Empty before any graph loads. */
|
||||
nodesById: Map<string, GraphNode>;
|
||||
/** id → layer id (first-matching-layer wins), rebuilt by setGraph. Empty before any graph loads. */
|
||||
nodeIdToLayerId: Map<string, string>;
|
||||
/** id → set of every layer the node belongs to, rebuilt by setGraph. Empty before any graph loads. */
|
||||
nodeIdToLayerIds: Map<string, Set<string>>;
|
||||
selectedNodeId: string | null;
|
||||
searchQuery: string;
|
||||
searchResults: SearchResult[];
|
||||
@@ -189,11 +231,11 @@ function getSortedTour(graph: KnowledgeGraph): TourStep[] {
|
||||
|
||||
/** Navigate tour step to the correct layer for the first highlighted node. */
|
||||
function navigateTourToLayer(
|
||||
graph: KnowledgeGraph,
|
||||
nodeIdToLayerId: Map<string, string>,
|
||||
nodeIds: string[],
|
||||
): Partial<DashboardStore> {
|
||||
if (nodeIds.length === 0) return {};
|
||||
const layerId = findNodeLayer(graph, nodeIds[0]);
|
||||
const layerId = nodeIdToLayerId.get(nodeIds[0]);
|
||||
if (layerId) {
|
||||
return {
|
||||
navigationLevel: "layer-detail" as const,
|
||||
@@ -205,6 +247,9 @@ function navigateTourToLayer(
|
||||
|
||||
export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
graph: null,
|
||||
nodesById: new Map<string, GraphNode>(),
|
||||
nodeIdToLayerId: new Map<string, string>(),
|
||||
nodeIdToLayerIds: new Map<string, Set<string>>(),
|
||||
selectedNodeId: null,
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
@@ -259,8 +304,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
const { viewMode, domainGraph, activeDomainId } = get();
|
||||
// Preserve domain view if a domain graph is already loaded
|
||||
const keepDomainView = viewMode === "domain" && domainGraph !== null;
|
||||
const { nodesById, nodeIdToLayerId, nodeIdToLayerIds } = buildGraphIndexes(graph);
|
||||
set({
|
||||
graph,
|
||||
nodesById,
|
||||
nodeIdToLayerId,
|
||||
nodeIdToLayerIds,
|
||||
searchEngine,
|
||||
searchResults,
|
||||
navigationLevel: "overview",
|
||||
@@ -296,9 +345,9 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
},
|
||||
|
||||
navigateToNodeInLayer: (nodeId) => {
|
||||
const { graph, selectedNodeId, nodeHistory } = get();
|
||||
const { graph, selectedNodeId, nodeHistory, nodeIdToLayerId } = get();
|
||||
if (!graph) return;
|
||||
const layerId = findNodeLayer(graph, nodeId);
|
||||
const layerId = nodeIdToLayerId.get(nodeId) ?? null;
|
||||
const newHistory =
|
||||
selectedNodeId && nodeId !== selectedNodeId
|
||||
? [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY)
|
||||
@@ -323,11 +372,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
},
|
||||
|
||||
navigateToHistoryIndex: (index) => {
|
||||
const { nodeHistory, graph } = get();
|
||||
const { nodeHistory, graph, nodeIdToLayerId } = get();
|
||||
if (!graph || index < 0 || index >= nodeHistory.length) return;
|
||||
const targetId = nodeHistory[index];
|
||||
const newHistory = nodeHistory.slice(0, index);
|
||||
const layerId = findNodeLayer(graph, targetId);
|
||||
const layerId = nodeIdToLayerId.get(targetId) ?? null;
|
||||
set({
|
||||
selectedNodeId: targetId,
|
||||
nodeHistory: newHistory,
|
||||
@@ -336,11 +385,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
},
|
||||
|
||||
goBackNode: () => {
|
||||
const { nodeHistory, graph } = get();
|
||||
const { nodeHistory, graph, nodeIdToLayerId } = get();
|
||||
if (nodeHistory.length === 0 || !graph) return;
|
||||
const prevNodeId = nodeHistory[nodeHistory.length - 1];
|
||||
const newHistory = nodeHistory.slice(0, -1);
|
||||
const layerId = findNodeLayer(graph, prevNodeId);
|
||||
const layerId = nodeIdToLayerId.get(prevNodeId) ?? null;
|
||||
if (layerId) {
|
||||
set({
|
||||
navigationLevel: "layer-detail",
|
||||
@@ -483,10 +532,10 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
},
|
||||
|
||||
startTour: () => {
|
||||
const { graph } = get();
|
||||
const { graph, nodeIdToLayerId } = get();
|
||||
if (!graph || !graph.tour || graph.tour.length === 0) return;
|
||||
const sorted = getSortedTour(graph);
|
||||
const layerNav = navigateTourToLayer(graph, sorted[0].nodeIds);
|
||||
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[0].nodeIds);
|
||||
set({
|
||||
tourActive: true,
|
||||
currentTourStep: 0,
|
||||
@@ -504,11 +553,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
}),
|
||||
|
||||
setTourStep: (step) => {
|
||||
const { graph } = get();
|
||||
const { graph, nodeIdToLayerId } = get();
|
||||
if (!graph || !graph.tour || graph.tour.length === 0) return;
|
||||
const sorted = getSortedTour(graph);
|
||||
if (step < 0 || step >= sorted.length) return;
|
||||
const layerNav = navigateTourToLayer(graph, sorted[step].nodeIds);
|
||||
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[step].nodeIds);
|
||||
set({
|
||||
currentTourStep: step,
|
||||
tourHighlightedNodeIds: sorted[step].nodeIds,
|
||||
@@ -517,12 +566,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
},
|
||||
|
||||
nextTourStep: () => {
|
||||
const { graph, currentTourStep } = get();
|
||||
const { graph, currentTourStep, nodeIdToLayerId } = get();
|
||||
if (!graph || !graph.tour || graph.tour.length === 0) return;
|
||||
const sorted = getSortedTour(graph);
|
||||
if (currentTourStep < sorted.length - 1) {
|
||||
const next = currentTourStep + 1;
|
||||
const layerNav = navigateTourToLayer(graph, sorted[next].nodeIds);
|
||||
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[next].nodeIds);
|
||||
set({
|
||||
currentTourStep: next,
|
||||
tourHighlightedNodeIds: sorted[next].nodeIds,
|
||||
@@ -532,12 +581,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
},
|
||||
|
||||
prevTourStep: () => {
|
||||
const { graph, currentTourStep } = get();
|
||||
const { graph, currentTourStep, nodeIdToLayerId } = get();
|
||||
if (!graph || !graph.tour || graph.tour.length === 0) return;
|
||||
if (currentTourStep > 0) {
|
||||
const sorted = getSortedTour(graph);
|
||||
const prev = currentTourStep - 1;
|
||||
const layerNav = navigateTourToLayer(graph, sorted[prev].nodeIds);
|
||||
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[prev].nodeIds);
|
||||
set({
|
||||
currentTourStep: prev,
|
||||
tourHighlightedNodeIds: sorted[prev].nodeIds,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterNodes, filterEdges } from "../filters";
|
||||
import type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
Layer,
|
||||
} from "@understand-anything/core/types";
|
||||
import type {
|
||||
FilterState,
|
||||
NodeType,
|
||||
Complexity,
|
||||
EdgeCategory,
|
||||
} from "../../store";
|
||||
import {
|
||||
ALL_NODE_TYPES,
|
||||
ALL_COMPLEXITIES,
|
||||
ALL_EDGE_CATEGORIES,
|
||||
} from "../../store";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: NodeType = "file",
|
||||
complexity: Complexity = "simple",
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
summary: "",
|
||||
complexity,
|
||||
tags: [],
|
||||
} as GraphNode;
|
||||
}
|
||||
|
||||
function edge(source: string, target: string, type = "imports"): GraphEdge {
|
||||
return { source, target, type } as GraphEdge;
|
||||
}
|
||||
|
||||
function defaultFilters(overrides: Partial<FilterState> = {}): FilterState {
|
||||
return {
|
||||
nodeTypes: new Set<NodeType>(ALL_NODE_TYPES),
|
||||
complexities: new Set<Complexity>(ALL_COMPLEXITIES),
|
||||
layerIds: new Set<string>(),
|
||||
edgeCategories: new Set<EdgeCategory>(ALL_EDGE_CATEGORIES),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function indexLayers(layers: Layer[]): Map<string, Set<string>> {
|
||||
const m = new Map<string, Set<string>>();
|
||||
for (const l of layers) {
|
||||
for (const nid of l.nodeIds) {
|
||||
let set = m.get(nid);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
m.set(nid, set);
|
||||
}
|
||||
set.add(l.id);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
describe("filterNodes", () => {
|
||||
it("returns all nodes when no filters narrow the set", () => {
|
||||
const nodes = [node("a"), node("b"), node("c")];
|
||||
const out = filterNodes(nodes, new Map(), defaultFilters());
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("filters by node type", () => {
|
||||
const nodes = [node("a", "file"), node("b", "function"), node("c", "class")];
|
||||
const filters = defaultFilters({ nodeTypes: new Set(["file"]) });
|
||||
const out = filterNodes(nodes, new Map(), filters);
|
||||
expect(out.map((n) => n.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("filters by complexity", () => {
|
||||
const nodes = [
|
||||
node("a", "file", "simple"),
|
||||
node("b", "file", "moderate"),
|
||||
node("c", "file", "complex"),
|
||||
];
|
||||
const filters = defaultFilters({ complexities: new Set(["complex"]) });
|
||||
const out = filterNodes(nodes, new Map(), filters);
|
||||
expect(out.map((n) => n.id)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("keeps a node only when its layer is selected", () => {
|
||||
const nodes = [node("a"), node("b"), node("c")];
|
||||
const idx = indexLayers([
|
||||
{ id: "L1", name: "L1", description: "", nodeIds: ["a", "b"] },
|
||||
{ id: "L2", name: "L2", description: "", nodeIds: ["c"] },
|
||||
]);
|
||||
const filters = defaultFilters({ layerIds: new Set(["L1"]) });
|
||||
const out = filterNodes(nodes, idx, filters);
|
||||
expect(out.map((n) => n.id).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("drops nodes that aren't in any layer when a layer filter is active", () => {
|
||||
const nodes = [node("a"), node("orphan")];
|
||||
const idx = indexLayers([
|
||||
{ id: "L1", name: "L1", description: "", nodeIds: ["a"] },
|
||||
]);
|
||||
const filters = defaultFilters({ layerIds: new Set(["L1"]) });
|
||||
const out = filterNodes(nodes, idx, filters);
|
||||
expect(out.map((n) => n.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("keeps a multi-layer node when any of its layers is selected (any-layer-wins)", () => {
|
||||
// Regression for the silent first-wins behavior change in #112: a node
|
||||
// X listed in both L1 (declared first) and L2, with only L2 selected,
|
||||
// must still pass — matching the prior `layers.some(...)` shape. The
|
||||
// first-wins `nodeIdToLayerId` index that drives navigation would
|
||||
// have dropped X here.
|
||||
const nodes = [node("x"), node("y")];
|
||||
const idx = indexLayers([
|
||||
{ id: "L1", name: "L1", description: "", nodeIds: ["x"] },
|
||||
{ id: "L2", name: "L2", description: "", nodeIds: ["x", "y"] },
|
||||
]);
|
||||
const filters = defaultFilters({ layerIds: new Set(["L2"]) });
|
||||
const out = filterNodes(nodes, idx, filters);
|
||||
expect(out.map((n) => n.id).sort()).toEqual(["x", "y"]);
|
||||
});
|
||||
|
||||
it("ignores layer filter when no layers are selected (parity with prior shape)", () => {
|
||||
const nodes = [node("a"), node("orphan")];
|
||||
// idx maps "a"; "orphan" isn't in any layer. With layer filter empty,
|
||||
// the orphan must still pass through.
|
||||
const idx = indexLayers([
|
||||
{ id: "L1", name: "L1", description: "", nodeIds: ["a"] },
|
||||
]);
|
||||
const out = filterNodes(nodes, idx, defaultFilters());
|
||||
expect(out.map((n) => n.id).sort()).toEqual(["a", "orphan"]);
|
||||
});
|
||||
|
||||
it("scales linearly: 10k nodes × 100 layers under 50ms (#102 regression guard)", () => {
|
||||
// The pre-fix path was O(N × L × K) — `layers.some(layer => filters.layerIds.has(layer.id) && layer.nodeIds.includes(node.id))`.
|
||||
// For a 10k-node / 100-layer graph with half the layers selected, that
|
||||
// measured ~50ms locally on node 22 just for the filter step.
|
||||
const nodes: GraphNode[] = [];
|
||||
const layers: Layer[] = [];
|
||||
for (let li = 0; li < 100; li++) {
|
||||
const nodeIds: string[] = [];
|
||||
for (let ni = 0; ni < 100; ni++) {
|
||||
const id = `n-${li}-${ni}`;
|
||||
nodes.push(node(id));
|
||||
nodeIds.push(id);
|
||||
}
|
||||
layers.push({ id: `L${li}`, name: `L${li}`, description: "", nodeIds });
|
||||
}
|
||||
const idx = indexLayers(layers);
|
||||
const selected = new Set<string>(layers.slice(0, 50).map((l) => l.id));
|
||||
const filters = defaultFilters({ layerIds: selected });
|
||||
|
||||
const t0 = performance.now();
|
||||
const out = filterNodes(nodes, idx, filters);
|
||||
const elapsedMs = performance.now() - t0;
|
||||
|
||||
expect(out.length).toBe(50 * 100);
|
||||
expect(elapsedMs).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterEdges", () => {
|
||||
it("keeps only edges whose endpoints are visible", () => {
|
||||
const edges = [edge("a", "b"), edge("a", "missing"), edge("c", "b")];
|
||||
const visible = new Set(["a", "b"]);
|
||||
const out = filterEdges(edges, visible, defaultFilters());
|
||||
expect(out).toEqual([edge("a", "b")]);
|
||||
});
|
||||
|
||||
it("filters by edge category", () => {
|
||||
const edges = [
|
||||
edge("a", "b", "imports"), // structural
|
||||
edge("a", "b", "calls"), // behavioral
|
||||
edge("a", "b", "reads_from"), // data-flow
|
||||
];
|
||||
const visible = new Set(["a", "b"]);
|
||||
const filters = defaultFilters({
|
||||
edgeCategories: new Set<EdgeCategory>(["structural"]),
|
||||
});
|
||||
const out = filterEdges(edges, visible, filters);
|
||||
expect(out.map((e) => e.type)).toEqual(["imports"]);
|
||||
});
|
||||
|
||||
it("passes through edges with unknown types (no category match)", () => {
|
||||
// getEdgeCategory returns null for unknown types, which short-circuits
|
||||
// the category filter — pinning current behavior so a future refactor
|
||||
// doesn't accidentally start dropping unknown edges.
|
||||
const edges = [edge("a", "b", "future-edge-type")];
|
||||
const visible = new Set(["a", "b"]);
|
||||
const out = filterEdges(edges, visible, defaultFilters());
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeLayerStats } from "../layerStats";
|
||||
import type { GraphNode, Layer } from "@understand-anything/core/types";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
complexity: GraphNode["complexity"] = "simple",
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type: "file",
|
||||
name: id,
|
||||
summary: "",
|
||||
complexity,
|
||||
tags: [],
|
||||
} as GraphNode;
|
||||
}
|
||||
|
||||
function layer(id: string, nodeIds: string[]): Layer {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: "",
|
||||
nodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
function indexById(nodes: GraphNode[]): Map<string, GraphNode> {
|
||||
return new Map(nodes.map((n) => [n.id, n]));
|
||||
}
|
||||
|
||||
describe("computeLayerStats", () => {
|
||||
it("counts only nodes that resolve in nodesById", () => {
|
||||
const nodes = [node("a", "simple"), node("b", "moderate"), node("c", "complex")];
|
||||
const l = layer("L", ["a", "b", "c", "ghost"]);
|
||||
const stats = computeLayerStats(l, indexById(nodes));
|
||||
expect(stats.resolvedCount).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 'simple' when no complexity passes the 30% threshold", () => {
|
||||
// 1 complex out of 4 = 25% — under threshold.
|
||||
const nodes = [
|
||||
node("a", "simple"),
|
||||
node("b", "simple"),
|
||||
node("c", "simple"),
|
||||
node("d", "complex"),
|
||||
];
|
||||
const stats = computeLayerStats(layer("L", ["a", "b", "c", "d"]), indexById(nodes));
|
||||
expect(stats.aggregateComplexity).toBe("simple");
|
||||
});
|
||||
|
||||
it("returns 'complex' when complex count strictly exceeds 30%", () => {
|
||||
// 4 complex out of 10 = 40% — over threshold.
|
||||
const nodes = Array.from({ length: 10 }, (_, i) =>
|
||||
node(`n${i}`, i < 4 ? "complex" : "simple"),
|
||||
);
|
||||
const stats = computeLayerStats(
|
||||
layer("L", nodes.map((n) => n.id)),
|
||||
indexById(nodes),
|
||||
);
|
||||
expect(stats.aggregateComplexity).toBe("complex");
|
||||
});
|
||||
|
||||
it("prefers 'complex' over 'moderate' when both clear the threshold", () => {
|
||||
// 4 complex + 4 moderate out of 10 — complex wins via the order of checks
|
||||
// in the prior implementation; this test pins that behavior.
|
||||
const nodes = Array.from({ length: 10 }, (_, i) =>
|
||||
node(`n${i}`, i < 4 ? "complex" : i < 8 ? "moderate" : "simple"),
|
||||
);
|
||||
const stats = computeLayerStats(
|
||||
layer("L", nodes.map((n) => n.id)),
|
||||
indexById(nodes),
|
||||
);
|
||||
expect(stats.aggregateComplexity).toBe("complex");
|
||||
});
|
||||
|
||||
it("returns 'moderate' when only the moderate count clears the threshold", () => {
|
||||
const nodes = Array.from({ length: 10 }, (_, i) =>
|
||||
node(`n${i}`, i < 4 ? "moderate" : "simple"),
|
||||
);
|
||||
const stats = computeLayerStats(
|
||||
layer("L", nodes.map((n) => n.id)),
|
||||
indexById(nodes),
|
||||
);
|
||||
expect(stats.aggregateComplexity).toBe("moderate");
|
||||
});
|
||||
|
||||
it("treats an empty layer as 'simple' with resolvedCount 0", () => {
|
||||
const stats = computeLayerStats(layer("L", []), indexById([]));
|
||||
expect(stats.resolvedCount).toBe(0);
|
||||
expect(stats.aggregateComplexity).toBe("simple");
|
||||
});
|
||||
|
||||
it("aggregates a 100-layer / 100-nodes-per-layer graph in under 50ms (#102 regression guard)", () => {
|
||||
// The pre-fix path ran graph.nodes.filter((n) => layer.nodeIds.includes(n.id))
|
||||
// per layer — O(N × K × L) — and locally took ~150ms for this shape under
|
||||
// node 22. The new path is O(N + Σ K_i). Loose budget so CI variance
|
||||
// doesn't flake; the pre-fix path would blow past it by 2-10×.
|
||||
const nodes: GraphNode[] = [];
|
||||
const layers: Layer[] = [];
|
||||
for (let li = 0; li < 100; li++) {
|
||||
const ids: string[] = [];
|
||||
for (let ni = 0; ni < 100; ni++) {
|
||||
const id = `n-${li}-${ni}`;
|
||||
nodes.push(node(id, ((li + ni) % 3 === 0 ? "complex" : (li + ni) % 3 === 1 ? "moderate" : "simple")));
|
||||
ids.push(id);
|
||||
}
|
||||
layers.push(layer(`L${li}`, ids));
|
||||
}
|
||||
const byId = indexById(nodes);
|
||||
|
||||
const t0 = performance.now();
|
||||
for (const l of layers) computeLayerStats(l, byId);
|
||||
const elapsedMs = performance.now() - t0;
|
||||
|
||||
expect(elapsedMs).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,27 @@
|
||||
import type { GraphNode, GraphEdge, Layer } from "@understand-anything/core/types";
|
||||
import type { GraphNode, GraphEdge } from "@understand-anything/core/types";
|
||||
import type { FilterState, NodeType, Complexity, EdgeCategory } from "../store";
|
||||
import { EDGE_CATEGORY_MAP } from "../store";
|
||||
|
||||
/**
|
||||
* Filter nodes based on active filters
|
||||
* Filter nodes based on active filters.
|
||||
*
|
||||
* Pass `nodeIdToLayerIds` from the store (precomputed once on `setGraph`)
|
||||
* so the layer-membership check is O(1) per node. The previous shape took
|
||||
* `Layer[]` and ran `layer.nodeIds.includes(node.id)` per node-per-layer,
|
||||
* which was O(N × L × K) and dominated export time on large graphs (#102).
|
||||
*
|
||||
* Membership semantics are any-layer-wins, matching the prior shape: a
|
||||
* node in L1 and L2 with only L2 selected passes. The store's other
|
||||
* index, `nodeIdToLayerId`, is first-wins and is for navigation, not
|
||||
* filtering — using it here would silently drop multi-layer nodes whose
|
||||
* first declared layer isn't selected.
|
||||
*/
|
||||
export function filterNodes(
|
||||
nodes: GraphNode[],
|
||||
layers: Layer[],
|
||||
nodeIdToLayerIds: Map<string, Set<string>>,
|
||||
filters: FilterState,
|
||||
): GraphNode[] {
|
||||
const hasLayerFilter = filters.layerIds.size > 0;
|
||||
return nodes.filter((node) => {
|
||||
// Filter by node type
|
||||
if (!filters.nodeTypes.has(node.type as NodeType)) {
|
||||
@@ -22,13 +34,17 @@ export function filterNodes(
|
||||
}
|
||||
|
||||
// Filter by layer (if any layers are selected)
|
||||
if (filters.layerIds.size > 0) {
|
||||
const nodeInSelectedLayer = layers.some(
|
||||
(layer) => filters.layerIds.has(layer.id) && layer.nodeIds.includes(node.id)
|
||||
);
|
||||
if (!nodeInSelectedLayer) {
|
||||
return false;
|
||||
if (hasLayerFilter) {
|
||||
const layerIds = nodeIdToLayerIds.get(node.id);
|
||||
if (!layerIds) return false;
|
||||
let inSelected = false;
|
||||
for (const lid of layerIds) {
|
||||
if (filters.layerIds.has(lid)) {
|
||||
inSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inSelected) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { GraphNode, Layer } from "@understand-anything/core/types";
|
||||
|
||||
export type Complexity = "simple" | "moderate" | "complex";
|
||||
|
||||
export interface LayerStats {
|
||||
/** Number of layer.nodeIds that resolve to a node in the graph. */
|
||||
resolvedCount: number;
|
||||
/** Aggregate label for the cluster card; matches the prior 30% threshold. */
|
||||
aggregateComplexity: Complexity;
|
||||
}
|
||||
|
||||
/**
|
||||
* O(layer.nodeIds.length) summary of a layer's complexity composition.
|
||||
*
|
||||
* Replaces the prior `graph.nodes.filter((n) => layer.nodeIds.includes(n.id))`
|
||||
* pass in `useOverviewGraph`, which was O(N × K) per layer and went
|
||||
* super-linear once a project had a few thousand nodes spread across many
|
||||
* layers (#102: 4.8 MB graph froze on overview render).
|
||||
*/
|
||||
export function computeLayerStats(
|
||||
layer: Layer,
|
||||
nodesById: Map<string, GraphNode>,
|
||||
): LayerStats {
|
||||
const counts: Record<Complexity, number> = { simple: 0, moderate: 0, complex: 0 };
|
||||
let resolved = 0;
|
||||
for (const nid of layer.nodeIds) {
|
||||
const node = nodesById.get(nid);
|
||||
if (!node) continue;
|
||||
resolved++;
|
||||
counts[node.complexity]++;
|
||||
}
|
||||
const aggregateComplexity: Complexity =
|
||||
counts.complex > resolved * 0.3
|
||||
? "complex"
|
||||
: counts.moderate > resolved * 0.3
|
||||
? "moderate"
|
||||
: "simple";
|
||||
return { resolvedCount: resolved, aggregateComplexity };
|
||||
}
|
||||
Reference in New Issue
Block a user