feat(dashboard): deriveContainers community fallback via Louvain

This commit is contained in:
Lum1104
2026-05-03 15:54:24 +08:00
Unverified
parent 5d11a6bfdc
commit 74342da070
2 changed files with 71 additions and 8 deletions
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { deriveContainers } from "../containers";
import type { GraphNode } from "@understand-anything/core/types";
import type { GraphNode, GraphEdge } from "@understand-anything/core/types";
function node(id: string, filePath?: string): GraphNode {
return {
@@ -87,3 +87,39 @@ describe("deriveContainers — folder strategy", () => {
expect(ungrouped.sort()).toEqual(["a", "b", "c"]);
});
});
describe("deriveContainers — community fallback", () => {
it("falls back to communities when only one folder present", () => {
const nodes = Array.from({ length: 10 }, (_, i) =>
node(`n${i}`, `services/n${i}.go`),
);
// Two clusters of 5 nodes; densely connected within, no edges between
const edges: GraphEdge[] = [];
for (const i of [0, 1, 2, 3, 4]) {
for (const j of [0, 1, 2, 3, 4]) {
if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge);
}
}
for (const i of [5, 6, 7, 8, 9]) {
for (const j of [5, 6, 7, 8, 9]) {
if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge);
}
}
const { containers } = deriveContainers(nodes, edges);
expect(containers.length).toBeGreaterThanOrEqual(2);
for (const c of containers) {
expect(c.strategy).toBe("community");
expect(c.name).toMatch(/^Cluster [A-Z]$/);
}
});
it("falls back when one folder holds > 60%", () => {
const nodes = [
...Array.from({ length: 8 }, (_, i) => node(`big${i}`, `big/file${i}.go`)),
node("a", "small1/a.go"),
node("b", "small2/b.go"),
];
const { containers } = deriveContainers(nodes, []);
expect(containers.every((c) => c.strategy === "community")).toBe(true);
});
});
@@ -1,12 +1,39 @@
import Graph from "graphology";
import louvain from "graphology-communities-louvain";
import type { GraphEdge } from "@understand-anything/core/types";
/** Returns [nodeId, communityId] for every node provided. */
/**
* Run Louvain community detection over the provided node set and the
* subset of edges whose endpoints are both in the set. Returns a map of
* nodeId → communityId. Disconnected nodes get unique community ids so
* they don't collapse into a single cluster.
*/
export function detectCommunities(
_nodeIds: string[],
_edges: GraphEdge[],
nodeIds: string[],
edges: GraphEdge[],
): Map<string, number> {
// Real implementation arrives in Task 3. Stub: every node in community 0.
const m = new Map<string, number>();
for (const id of _nodeIds) m.set(id, 0);
return m;
const ids = new Set(nodeIds);
const g = new Graph({ type: "undirected", multi: false });
for (const id of nodeIds) g.addNode(id);
for (const e of edges) {
if (!ids.has(e.source) || !ids.has(e.target)) continue;
if (e.source === e.target) continue;
if (g.hasEdge(e.source, e.target)) continue;
g.addEdge(e.source, e.target);
}
// graphology-communities-louvain returns Record<nodeId, communityId>
const result = louvain(g) as Record<string, number>;
const map = new Map<string, number>();
for (const id of nodeIds) {
map.set(id, result[id] ?? -1);
}
// Reassign disconnected nodes (community -1) to unique ids past the max
let next =
Math.max(...Array.from(map.values()).filter((v) => v >= 0), -1) + 1;
for (const [id, c] of map) {
if (c === -1) {
map.set(id, next++);
}
}
return map;
}