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 +1,64 @@
|
||||
// bottlenecks (graphology betweenness)
|
||||
// bottlenecks (graphology betweenness)
|
||||
|
||||
import { centrality } from 'graphology-metrics';
|
||||
import type { TaskGraph } from '../graph/index.js';
|
||||
|
||||
/**
|
||||
* Result of bottleneck analysis: a task ID paired with its betweenness centrality score.
|
||||
*
|
||||
* Higher scores indicate the task lies on more shortest paths between other
|
||||
* nodes, making it a structural bottleneck — delaying or failing this task
|
||||
* has outsized impact on the overall workflow.
|
||||
*/
|
||||
export interface BottleneckResult {
|
||||
taskId: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute bottleneck scores for all tasks in the graph using betweenness centrality.
|
||||
*
|
||||
* Betweenness centrality measures the fraction of shortest paths between all
|
||||
* node pairs that pass through a given node. Nodes with high betweenness are
|
||||
* structural bottlenecks: they sit on the most shortest paths and their
|
||||
* delay or failure disrupts the most communication/routes in the graph.
|
||||
*
|
||||
* Uses `graphology-metrics` betweenness centrality with `normalized: true`,
|
||||
* which produces scores in the **0.0–1.0** range. For disconnected graphs,
|
||||
* betweenness is 0 for nodes in components with fewer than 3 nodes (no
|
||||
* shortest paths can traverse through them between distinct endpoints).
|
||||
*
|
||||
* All tasks are included in the result, even those with score 0 (they are
|
||||
* not bottlenecks). Results are sorted by score descending (most critical
|
||||
* bottlenecks first).
|
||||
*
|
||||
* @param graph - The task graph to analyze
|
||||
* @returns Array of `{ taskId, score }` sorted by score descending
|
||||
*/
|
||||
export function bottlenecks(graph: TaskGraph): BottleneckResult[] {
|
||||
const raw = graph.raw;
|
||||
|
||||
// Edge case: empty graph — graphology-metrics betweenness centrality
|
||||
// throws on an empty graph (mnemonist FixedStack requires positive capacity).
|
||||
// Return an empty array since there are no nodes to score.
|
||||
if (raw.order === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Compute normalized betweenness centrality (0.0–1.0 range)
|
||||
const centralityMap = centrality.betweenness(raw, { normalized: true });
|
||||
|
||||
// Map to result objects for all nodes in the graph
|
||||
const results: BottleneckResult[] = [];
|
||||
raw.forEachNode((node) => {
|
||||
results.push({
|
||||
taskId: node,
|
||||
score: centralityMap[node] ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by score descending (highest bottleneck first)
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -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