feat(analysis/bottlenecks): implement bottlenecks function using graphology-metrics betweenness centrality

- Implements bottlenecks(graph: TaskGraph): BottleneckResult[] with normalized scores (0.0-1.0)
- Uses graphology-metrics centrality.betweenness with normalized: true
- Returns array sorted by score descending, includes all nodes (even score 0)
- Handles empty graph edge case (returns empty array)
- 20 unit tests covering: linear chain, star graph, independent nodes,
  disconnected graph, diamond, empty graph, single node
This commit is contained in:
2026-04-27 12:35:48 +00:00
parent b0d943f4e6
commit 62e23b5989
2 changed files with 337 additions and 4 deletions

View File

@@ -1,7 +1,277 @@
import { describe, it, expect } from 'vitest';
import { bottlenecks, type BottleneckResult } from '../src/analysis/bottleneck.js';
import { TaskGraph } from '../src/graph/index.js';
import type { TaskInput } from '../src/schema/index.js';
describe('Analysis', () => {
it('placeholder — critical path and risk analysis', () => {
expect(true).toBe(true);
// ---------------------------------------------------------------------------
// Bottlenecks analysis tests
// ---------------------------------------------------------------------------
describe('bottlenecks', () => {
it('is exported from the analysis module', () => {
expect(bottlenecks).toBeDefined();
expect(typeof bottlenecks).toBe('function');
});
it('returns array of { taskId, score } objects', () => {
const tg = TaskGraph.fromTasks([
{ id: 'A', name: 'Task A', dependsOn: [] },
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
]);
const result = bottlenecks(tg);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(2);
for (const entry of result) {
expect(entry).toHaveProperty('taskId');
expect(entry).toHaveProperty('score');
expect(typeof entry.taskId).toBe('string');
expect(typeof entry.score).toBe('number');
}
});
it('sorts results by score descending', () => {
const tg = TaskGraph.fromTasks([
{ id: 'A', name: 'Task A', dependsOn: [] },
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
{ id: 'C', name: 'Task C', dependsOn: ['B'] },
{ id: 'D', name: 'Task D', dependsOn: ['C'] },
]);
const result = bottlenecks(tg);
for (let i = 1; i < result.length; i++) {
expect(result[i - 1].score).toBeGreaterThanOrEqual(result[i].score);
}
});
it('uses normalized scores in 0.01.0 range', () => {
const tg = TaskGraph.fromTasks([
{ id: 'A', name: 'Task A', dependsOn: [] },
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
{ id: 'C', name: 'Task C', dependsOn: ['B'] },
{ id: 'D', name: 'Task D', dependsOn: ['C'] },
]);
const result = bottlenecks(tg);
for (const entry of result) {
expect(entry.score).toBeGreaterThanOrEqual(0);
expect(entry.score).toBeLessThanOrEqual(1);
}
});
it('includes tasks with score 0 (they are not bottlenecks)', () => {
// Two independent nodes — no paths between them, both get betweenness 0
const tg = TaskGraph.fromTasks([
{ id: 'X', name: 'Task X', dependsOn: [] },
{ id: 'Y', name: 'Task Y', dependsOn: [] },
]);
const result = bottlenecks(tg);
expect(result.length).toBe(2);
expect(result.every((r) => r.score === 0)).toBe(true);
});
// -------------------------------------------------------------------------
// Linear chain: A → B → C → D
// Middle nodes (B, C) should have higher betweenness than endpoints (A, D).
// B has the highest betweenness because it sits on all shortest paths
// from A to C, A to D, and B to D.
// -------------------------------------------------------------------------
describe('linear chain: A → B → C → D', () => {
const tg = TaskGraph.fromTasks([
{ id: 'A', name: 'Task A', dependsOn: [] },
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
{ id: 'C', name: 'Task C', dependsOn: ['B'] },
{ id: 'D', name: 'Task D', dependsOn: ['C'] },
]);
const result = bottlenecks(tg);
it('middle node B has the highest betweenness', () => {
expect(result[0].taskId).toBe('B');
expect(result[0].score).toBeGreaterThan(0);
});
it('middle node C has the second-highest betweenness', () => {
expect(result[1].taskId).toBe('C');
expect(result[1].score).toBeGreaterThan(0);
});
it('endpoints A and D have the lowest betweenness', () => {
// A and D are source/sink respectively — no paths traverse through them
const aScore = result.find((r) => r.taskId === 'A')!.score;
const dScore = result.find((r) => r.taskId === 'D')!.score;
expect(aScore).toBe(0);
expect(dScore).toBe(0);
});
it('all 4 nodes are included in results', () => {
expect(result.length).toBe(4);
const ids = result.map((r) => r.taskId).sort();
expect(ids).toEqual(['A', 'B', 'C', 'D']);
});
});
// -------------------------------------------------------------------------
// Star graph: center node C connects to all leaf nodes
// C
// /|\
// A B D E
// The center is on all shortest paths between leaves.
// -------------------------------------------------------------------------
describe('star graph: center node connects to all leaves', () => {
const tg = TaskGraph.fromTasks([
{ id: 'A', name: 'Task A', dependsOn: [] },
{ id: 'B', name: 'Task B', dependsOn: [] },
{ id: 'C', name: 'Center', dependsOn: ['A', 'B'] },
{ id: 'D', name: 'Task D', dependsOn: ['C'] },
{ id: 'E', name: 'Task E', dependsOn: ['C'] },
]);
const result = bottlenecks(tg);
it('center node C has the highest betweenness', () => {
expect(result[0].taskId).toBe('C');
expect(result[0].score).toBeGreaterThan(0);
});
it('leaf nodes have lower betweenness than center', () => {
const centerScore = result[0].score;
const leaves = result.filter((r) => r.taskId !== 'C');
for (const leaf of leaves) {
expect(leaf.score).toBeLessThanOrEqual(centerScore);
}
});
it('center has strictly positive score', () => {
expect(result[0].score).toBeGreaterThan(0);
});
});
// -------------------------------------------------------------------------
// Independent nodes: no edges between any nodes
// All betweenness scores should be 0.
// -------------------------------------------------------------------------
describe('independent nodes: no edges', () => {
it('all nodes have betweenness score 0', () => {
const tg = TaskGraph.fromTasks([
{ id: 'X', name: 'Task X', dependsOn: [] },
{ id: 'Y', name: 'Task Y', dependsOn: [] },
{ id: 'Z', name: 'Task Z', dependsOn: [] },
]);
const result = bottlenecks(tg);
expect(result.length).toBe(3);
for (const entry of result) {
expect(entry.score).toBe(0);
}
});
});
// -------------------------------------------------------------------------
// Disconnected graph: two separate chains
// Nodes in disconnected components have betweenness 0 (no shortest paths
// go through them between different components — but within-component
// paths still count).
// -------------------------------------------------------------------------
describe('disconnected graph: two separate chains', () => {
const tg = TaskGraph.fromTasks([
// Chain 1: A → B → C
{ id: 'A', name: 'Task A', dependsOn: [] },
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
{ id: 'C', name: 'Task C', dependsOn: ['B'] },
// Chain 2: D → E → F
{ id: 'D', name: 'Task D', dependsOn: [] },
{ id: 'E', name: 'Task E', dependsOn: ['D'] },
{ id: 'F', name: 'Task F', dependsOn: ['E'] },
]);
const result = bottlenecks(tg);
it('all 6 nodes are included', () => {
expect(result.length).toBe(6);
});
it('middle nodes in each chain have the highest scores', () => {
// B and E are the middle nodes in their respective chains
const bScore = result.find((r) => r.taskId === 'B')!.score;
const eScore = result.find((r) => r.taskId === 'E')!.score;
expect(bScore).toBeGreaterThan(0);
expect(eScore).toBeGreaterThan(0);
});
it('endpoint nodes have betweenness 0 in each chain', () => {
const aScore = result.find((r) => r.taskId === 'A')!.score;
const cScore = result.find((r) => r.taskId === 'C')!.score;
const dScore = result.find((r) => r.taskId === 'D')!.score;
const fScore = result.find((r) => r.taskId === 'F')!.score;
expect(aScore).toBe(0);
expect(cScore).toBe(0);
expect(dScore).toBe(0);
expect(fScore).toBe(0);
});
});
// -------------------------------------------------------------------------
// Diamond graph: A → B, A → C, B → D, C → D
// B and C have higher betweenness than A or D (they sit on paths
// between the source and sink).
// -------------------------------------------------------------------------
describe('diamond graph', () => {
const tg = TaskGraph.fromTasks([
{ id: 'A', name: 'Task A', dependsOn: [] },
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
{ id: 'C', name: 'Task C', dependsOn: ['A'] },
{ id: 'D', name: 'Task D', dependsOn: ['B', 'C'] },
]);
const result = bottlenecks(tg);
it('B and C have strictly higher betweenness than A and D', () => {
const bScore = result.find((r) => r.taskId === 'B')!.score;
const cScore = result.find((r) => r.taskId === 'C')!.score;
const aScore = result.find((r) => r.taskId === 'A')!.score;
const dScore = result.find((r) => r.taskId === 'D')!.score;
// B and C have equal betweenness in the diamond
expect(bScore).toBe(cScore);
// B and C have higher betweenness than A and D
expect(bScore).toBeGreaterThan(aScore);
expect(bScore).toBeGreaterThan(dScore);
});
});
// -------------------------------------------------------------------------
// Empty graph
// -------------------------------------------------------------------------
describe('empty graph', () => {
it('returns an empty array for a graph with no nodes', () => {
const tg = new TaskGraph();
const result = bottlenecks(tg);
expect(result).toEqual([]);
});
});
// -------------------------------------------------------------------------
// Single node
// -------------------------------------------------------------------------
describe('single node', () => {
it('returns one entry with score 0', () => {
const tg = TaskGraph.fromTasks([
{ id: 'solo', name: 'Solo task', dependsOn: [] },
]);
const result = bottlenecks(tg);
expect(result.length).toBe(1);
expect(result[0].taskId).toBe('solo');
expect(result[0].score).toBe(0);
});
});
// -------------------------------------------------------------------------
// BottleneckResult interface type check
// -------------------------------------------------------------------------
it('returns BottleneckResult-typed objects', () => {
const tg = TaskGraph.fromTasks([
{ id: 'A', name: 'Task A', dependsOn: [] },
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
]);
const result: BottleneckResult[] = bottlenecks(tg);
expect(result.length).toBeGreaterThan(0);
// TypeScript compilation validates the type
});
});