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:
@@ -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.0–1.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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user