From 37179bc1dee52fc86393b7f1bc7c2d4bad04c192 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Mon, 27 Apr 2026 12:31:43 +0000 Subject: [PATCH] feat(analysis/parallel-groups): implement parallelGroups function with tests --- src/analysis/index.ts | 3 +- src/analysis/parallel-groups.ts | 32 ++ .../analysis/parallel-groups.md | 21 +- test/parallel-groups.test.ts | 293 ++++++++++++++++++ 4 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 src/analysis/parallel-groups.ts create mode 100644 test/parallel-groups.test.ts diff --git a/src/analysis/index.ts b/src/analysis/index.ts index a70a145..5e03de8 100644 --- a/src/analysis/index.ts +++ b/src/analysis/index.ts @@ -5,4 +5,5 @@ export * from './bottleneck.js'; export * from './risk.js'; export * from './cost-benefit.js'; export * from './decompose.js'; -export * from './defaults.js'; \ No newline at end of file +export * from './defaults.js'; +export * from './parallel-groups.js'; \ No newline at end of file diff --git a/src/analysis/parallel-groups.ts b/src/analysis/parallel-groups.ts new file mode 100644 index 0000000..4db1266 --- /dev/null +++ b/src/analysis/parallel-groups.ts @@ -0,0 +1,32 @@ +// parallelGroups — groups of tasks that can be executed concurrently + +import { topologicalGenerations } from 'graphology-dag'; +import type { TaskGraph } from '../graph/index.js'; +import { CircularDependencyError } from '../error/index.js'; +import { findCycles } from '../graph/queries.js'; + +/** + * Return groups of tasks that can be executed concurrently. + * + * Each inner array is a "generation" of tasks at the same topological depth + * from sources — tasks with zero prerequisites are in the first group. + * + * Uses `graphology-dag.topologicalGenerations()` for the generation + * computation. Works on disconnected graphs (each connected component is + * sorted independently, then merged by depth). + * + * @param graph - The TaskGraph to analyze + * @returns An array of arrays, where each inner array is a generation of + * tasks at the same depth from sources + * @throws {CircularDependencyError} If the graph contains cycles, with + * `cycles` populated from `findCycles()` + */ +export function parallelGroups(graph: TaskGraph): string[][] { + try { + return topologicalGenerations(graph.raw); + } catch { + // graphology-dag throws when the graph is cyclic — re-throw with + // our CircularDependencyError that carries cycle information + throw new CircularDependencyError(findCycles(graph.raw)); + } +} \ No newline at end of file diff --git a/tasks/implementation/analysis/parallel-groups.md b/tasks/implementation/analysis/parallel-groups.md index 0bb6034..44f39b8 100644 --- a/tasks/implementation/analysis/parallel-groups.md +++ b/tasks/implementation/analysis/parallel-groups.md @@ -1,7 +1,7 @@ --- id: analysis/parallel-groups name: Implement parallelGroups analysis function -status: pending +status: completed depends_on: - graph/construction - graph/queries @@ -17,12 +17,12 @@ Implement `parallelGroups(graph: TaskGraph): string[][]` in `src/analysis/index. ## Acceptance Criteria -- [ ] `parallelGroups` returns `string[][]` where each inner array is a generation of tasks at the same depth from sources -- [ ] Uses `graphology-dag.topologicalGenerations()` for the generation computation -- [ ] Tasks with zero prerequisites are in the first group -- [ ] Throws `CircularDependencyError` if the graph is cyclic (delegated to `topologicalGenerations` behavior) -- [ ] Works on disconnected graphs (each connected component sorted independently, then merged by depth) -- [ ] Unit tests: linear chain (each group size 1), diamond graph, disconnected components +- [x] `parallelGroups` returns `string[][]` where each inner array is a generation of tasks at the same depth from sources +- [x] Uses `graphology-dag.topologicalGenerations()` for the generation computation +- [x] Tasks with zero prerequisites are in the first group +- [x] Throws `CircularDependencyError` if the graph is cyclic (delegated to `topologicalGenerations` behavior) +- [x] Works on disconnected graphs (each connected component sorted independently, then merged by depth) +- [x] Unit tests: linear chain (each group size 1), diamond graph, disconnected components ## References @@ -31,8 +31,11 @@ Implement `parallelGroups(graph: TaskGraph): string[][]` in `src/analysis/index. ## Notes -> To be filled by implementation agent +Implementation uses `topologicalGenerations` from `graphology-dag` which internally uses Kahn's algorithm. It naturally handles disconnected graphs by grouping source nodes (zero in-degree) from all components into the same first generation. On cyclic graphs, `topologicalGenerations` throws and we catch it to re-throw `CircularDependencyError` with cycle information (same pattern as `topologicalOrder` in queries.ts). ## Summary -> To be filled on completion \ No newline at end of file +Implemented `parallelGroups(graph: TaskGraph): string[][]` in a dedicated module. +- Created: `src/analysis/parallel-groups.ts`, `test/parallel-groups.test.ts` +- Modified: `src/analysis/index.ts` (added re-export) +- Tests: 14, all passing (full suite: 457 tests passing, lint clean) \ No newline at end of file diff --git a/test/parallel-groups.test.ts b/test/parallel-groups.test.ts new file mode 100644 index 0000000..859593f --- /dev/null +++ b/test/parallel-groups.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; +import { parallelGroups } from '../src/analysis/parallel-groups.js'; +import { TaskGraph } from '../src/graph/index.js'; +import { CircularDependencyError } from '../src/error/index.js'; +import type { TaskGraphSerialized } from '../src/schema/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a simple linear DAG: A → B → C → D */ +function makeLinearChain(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'A', attributes: { name: 'Task A' } }, + { key: 'B', attributes: { name: 'Task B' } }, + { key: 'C', attributes: { name: 'Task C' } }, + { key: 'D', attributes: { name: 'Task D' } }, + ], + edges: [ + { key: 'A->B', source: 'A', target: 'B', attributes: {} }, + { key: 'B->C', source: 'B', target: 'C', attributes: {} }, + { key: 'C->D', source: 'C', target: 'D', attributes: {} }, + ], + }; + return new TaskGraph(data); +} + +/** Create a diamond DAG: + * A + * / \ + * B C + * \ / + * D + */ +function makeDiamond(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'A', attributes: { name: 'Task A' } }, + { key: 'B', attributes: { name: 'Task B' } }, + { key: 'C', attributes: { name: 'Task C' } }, + { key: 'D', attributes: { name: 'Task D' } }, + ], + edges: [ + { key: 'A->B', source: 'A', target: 'B', attributes: {} }, + { key: 'A->C', source: 'A', target: 'C', attributes: {} }, + { key: 'B->D', source: 'B', target: 'D', attributes: {} }, + { key: 'C->D', source: 'C', target: 'D', attributes: {} }, + ], + }; + return new TaskGraph(data); +} + +/** Create a disconnected graph with two independent components: + * Component 1: X → Y + * Component 2: P → Q → R + */ +function makeDisconnected(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'X', attributes: { name: 'Task X' } }, + { key: 'Y', attributes: { name: 'Task Y' } }, + { key: 'P', attributes: { name: 'Task P' } }, + { key: 'Q', attributes: { name: 'Task Q' } }, + { key: 'R', attributes: { name: 'Task R' } }, + ], + edges: [ + { key: 'X->Y', source: 'X', target: 'Y', attributes: {} }, + { key: 'P->Q', source: 'P', target: 'Q', attributes: {} }, + { key: 'Q->R', source: 'Q', target: 'R', attributes: {} }, + ], + }; + return new TaskGraph(data); +} + +/** Create a cyclic graph: A → B → C → A */ +function makeCyclic(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'A', attributes: { name: 'Task A' } }, + { key: 'B', attributes: { name: 'Task B' } }, + { key: 'C', attributes: { name: 'Task C' } }, + ], + edges: [ + { key: 'A->B', source: 'A', target: 'B', attributes: {} }, + { key: 'B->C', source: 'B', target: 'C', attributes: {} }, + { key: 'C->A', source: 'C', target: 'A', attributes: {} }, + ], + }; + return new TaskGraph(data); +} + +/** Create an empty graph */ +function makeEmpty(): TaskGraph { + return new TaskGraph(); +} + +/** Create a single-node graph */ +function makeSingleNode(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [{ key: 'only', attributes: { name: 'Only task' } }], + edges: [], + }; + return new TaskGraph(data); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('parallelGroups', () => { + // --------------------------------------------------------------------------- + // Linear chain: each group has size 1 + // --------------------------------------------------------------------------- + + describe('linear chain (A → B → C → D)', () => { + it('returns 4 groups, each with size 1', () => { + const graph = makeLinearChain(); + const groups = parallelGroups(graph); + + expect(groups).toHaveLength(4); + for (const group of groups) { + expect(group).toHaveLength(1); + } + }); + + it('places A (no prerequisites) in the first group', () => { + const graph = makeLinearChain(); + const groups = parallelGroups(graph); + + expect(groups[0]).toEqual(['A']); + }); + + it('preserves topological order across groups', () => { + const graph = makeLinearChain(); + const groups = parallelGroups(graph); + + // Flatten groups to get topological order + const flat = groups.flat(); + expect(flat).toEqual(['A', 'B', 'C', 'D']); + }); + }); + + // --------------------------------------------------------------------------- + // Diamond graph: B and C are parallel (same depth) + // --------------------------------------------------------------------------- + + describe('diamond graph', () => { + it('returns 3 groups', () => { + const graph = makeDiamond(); + const groups = parallelGroups(graph); + + expect(groups).toHaveLength(3); + }); + + it('places A (no prerequisites) in the first group', () => { + const graph = makeDiamond(); + const groups = parallelGroups(graph); + + expect(groups[0]).toEqual(['A']); + }); + + it('places B and C in the same group (parallel)', () => { + const graph = makeDiamond(); + const groups = parallelGroups(graph); + + // B and C are at the same depth from source A + const secondGroup = groups[1]; + expect(secondGroup).toHaveLength(2); + expect(secondGroup).toContain('B'); + expect(secondGroup).toContain('C'); + }); + + it('places D (depends on B and C) in the last group', () => { + const graph = makeDiamond(); + const groups = parallelGroups(graph); + + expect(groups[2]).toEqual(['D']); + }); + }); + + // --------------------------------------------------------------------------- + // Disconnected components + // --------------------------------------------------------------------------- + + describe('disconnected components', () => { + it('handles disconnected graphs correctly', () => { + const graph = makeDisconnected(); + const groups = parallelGroups(graph); + + // X and P have no prerequisites, so they should be in the first group + const firstGroup = groups[0]; + expect(firstGroup).toContain('X'); + expect(firstGroup).toContain('P'); + + // Y and Q are at depth 1 from their respective sources + const secondGroup = groups[1]; + expect(secondGroup).toContain('Y'); + expect(secondGroup).toContain('Q'); + + // R is at depth 2 (P → Q → R) + const thirdGroup = groups[2]; + expect(thirdGroup).toContain('R'); + }); + + it('includes all nodes across all groups', () => { + const graph = makeDisconnected(); + const groups = parallelGroups(graph); + + const allNodes = groups.flat().sort(); + expect(allNodes).toEqual(['P', 'Q', 'R', 'X', 'Y']); + }); + }); + + // --------------------------------------------------------------------------- + // Cyclic graph + // --------------------------------------------------------------------------- + + describe('cyclic graph', () => { + it('throws CircularDependencyError', () => { + const graph = makeCyclic(); + + expect(() => parallelGroups(graph)).toThrow(CircularDependencyError); + }); + + it('populates cycles on the error', () => { + const graph = makeCyclic(); + + try { + parallelGroups(graph); + expect.unreachable('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CircularDependencyError); + const cde = err as CircularDependencyError; + expect(cde.cycles.length).toBeGreaterThan(0); + } + }); + }); + + // --------------------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------------------- + + describe('edge cases', () => { + it('returns empty array for empty graph', () => { + const graph = makeEmpty(); + const groups = parallelGroups(graph); + + expect(groups).toEqual([]); + }); + + it('returns single group for a single node', () => { + const graph = makeSingleNode(); + const groups = parallelGroups(graph); + + expect(groups).toEqual([['only']]); + }); + }); + + // --------------------------------------------------------------------------- + // fromTasks integration + // --------------------------------------------------------------------------- + + describe('fromTasks integration', () => { + it('works with TaskGraph.fromTasks()', () => { + const graph = 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 groups = parallelGroups(graph); + + expect(groups).toHaveLength(3); + expect(groups[0]).toEqual(['A']); + expect(groups[1]).toHaveLength(2); + expect(groups[1]).toContain('B'); + expect(groups[1]).toContain('C'); + expect(groups[2]).toEqual(['D']); + }); + }); +}); \ No newline at end of file