diff --git a/src/analysis/bottleneck.ts b/src/analysis/bottleneck.ts index ae3d4a2..a0a58c1 100644 --- a/src/analysis/bottleneck.ts +++ b/src/analysis/bottleneck.ts @@ -1 +1,64 @@ -// bottlenecks (graphology betweenness) \ No newline at end of file +// 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; +} \ No newline at end of file diff --git a/test/analysis.test.ts b/test/analysis.test.ts index 0acebb4..17b7ce9 100644 --- a/test/analysis.test.ts +++ b/test/analysis.test.ts @@ -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 }); }); \ No newline at end of file