feat(analysis/parallel-groups): implement parallelGroups function with tests
This commit is contained in:
@@ -5,4 +5,5 @@ export * from './bottleneck.js';
|
|||||||
export * from './risk.js';
|
export * from './risk.js';
|
||||||
export * from './cost-benefit.js';
|
export * from './cost-benefit.js';
|
||||||
export * from './decompose.js';
|
export * from './decompose.js';
|
||||||
export * from './defaults.js';
|
export * from './defaults.js';
|
||||||
|
export * from './parallel-groups.js';
|
||||||
32
src/analysis/parallel-groups.ts
Normal file
32
src/analysis/parallel-groups.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: analysis/parallel-groups
|
id: analysis/parallel-groups
|
||||||
name: Implement parallelGroups analysis function
|
name: Implement parallelGroups analysis function
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- graph/construction
|
- graph/construction
|
||||||
- graph/queries
|
- graph/queries
|
||||||
@@ -17,12 +17,12 @@ Implement `parallelGroups(graph: TaskGraph): string[][]` in `src/analysis/index.
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `parallelGroups` returns `string[][]` where each inner array is a generation of tasks at the same depth from sources
|
- [x] `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
|
- [x] Uses `graphology-dag.topologicalGenerations()` for the generation computation
|
||||||
- [ ] Tasks with zero prerequisites are in the first group
|
- [x] Tasks with zero prerequisites are in the first group
|
||||||
- [ ] Throws `CircularDependencyError` if the graph is cyclic (delegated to `topologicalGenerations` behavior)
|
- [x] Throws `CircularDependencyError` if the graph is cyclic (delegated to `topologicalGenerations` behavior)
|
||||||
- [ ] Works on disconnected graphs (each connected component sorted independently, then merged by depth)
|
- [x] 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] Unit tests: linear chain (each group size 1), diamond graph, disconnected components
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -31,8 +31,11 @@ Implement `parallelGroups(graph: TaskGraph): string[][]` in `src/analysis/index.
|
|||||||
|
|
||||||
## Notes
|
## 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
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
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)
|
||||||
293
test/parallel-groups.test.ts
Normal file
293
test/parallel-groups.test.ts
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user