feat(shared): Add topological sort and pipeline column grouping for phases
Kahn's algorithm for topological phase sorting by dependency edges with createdAt tiebreaker. groupPhasesByDependencyLevel computes pipeline visualization columns. Handles cycles gracefully.
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export type { AppRouter } from './trpc.js';
|
||||
export type { Initiative, Phase, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project, Proposal } from './types.js';
|
||||
export { sortByPriorityAndQueueTime, type SortableItem } from './utils.js';
|
||||
export { sortByPriorityAndQueueTime, topologicalSortPhases, groupPhasesByDependencyLevel, type SortableItem, type PhaseForSort, type DependencyEdge, type PipelineColumn } from './utils.js';
|
||||
|
||||
@@ -34,4 +34,165 @@ export function sortByPriorityAndQueueTime<T extends SortableItem>(items: T[]):
|
||||
const bTime = typeof b.createdAt === 'string' ? new Date(b.createdAt) : b.createdAt;
|
||||
return aTime.getTime() - bTime.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topological Phase Sorting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PhaseForSort {
|
||||
id: string;
|
||||
createdAt: Date | string;
|
||||
}
|
||||
|
||||
export interface DependencyEdge {
|
||||
phaseId: string;
|
||||
dependsOnPhaseId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topologically sort phases by their dependency edges (Kahn's algorithm).
|
||||
* Phases with no dependencies come first. Deterministic tiebreaker: createdAt ascending.
|
||||
* Phases involved in cycles are appended at the end, sorted by createdAt.
|
||||
*/
|
||||
export function topologicalSortPhases<T extends PhaseForSort>(
|
||||
phases: T[],
|
||||
edges: DependencyEdge[],
|
||||
): T[] {
|
||||
if (phases.length === 0) return [];
|
||||
|
||||
const phaseMap = new Map(phases.map((p) => [p.id, p]));
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacency = new Map<string, string[]>(); // dependsOnPhaseId -> phaseIds that depend on it
|
||||
|
||||
for (const p of phases) {
|
||||
inDegree.set(p.id, 0);
|
||||
adjacency.set(p.id, []);
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!phaseMap.has(edge.phaseId) || !phaseMap.has(edge.dependsOnPhaseId)) continue;
|
||||
inDegree.set(edge.phaseId, (inDegree.get(edge.phaseId) ?? 0) + 1);
|
||||
adjacency.get(edge.dependsOnPhaseId)!.push(edge.phaseId);
|
||||
}
|
||||
|
||||
// Seed queue with zero in-degree phases, sorted by createdAt
|
||||
const queue = phases
|
||||
.filter((p) => (inDegree.get(p.id) ?? 0) === 0)
|
||||
.sort((a, b) => toTime(a.createdAt) - toTime(b.createdAt));
|
||||
|
||||
const result: T[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
// Pick the earliest-created from the queue
|
||||
queue.sort((a, b) => toTime(a.createdAt) - toTime(b.createdAt));
|
||||
const current = queue.shift()!;
|
||||
result.push(current);
|
||||
|
||||
for (const neighborId of adjacency.get(current.id) ?? []) {
|
||||
const newDeg = (inDegree.get(neighborId) ?? 1) - 1;
|
||||
inDegree.set(neighborId, newDeg);
|
||||
if (newDeg === 0) {
|
||||
queue.push(phaseMap.get(neighborId)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append any phases caught in cycles, sorted by createdAt
|
||||
if (result.length < phases.length) {
|
||||
const inResult = new Set(result.map((p) => p.id));
|
||||
const remaining = phases
|
||||
.filter((p) => !inResult.has(p.id))
|
||||
.sort((a, b) => toTime(a.createdAt) - toTime(b.createdAt));
|
||||
result.push(...remaining);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function toTime(d: Date | string): number {
|
||||
return typeof d === 'string' ? new Date(d).getTime() : d.getTime();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline Column Grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PipelineColumn<T> {
|
||||
depth: number;
|
||||
phases: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups phases into pipeline columns by dependency depth.
|
||||
* Depth 0 = no dependencies, depth N = longest path from a root is N.
|
||||
* Phases within a column are sorted by createdAt ascending.
|
||||
* Phases in cycles are placed in the last column + 1.
|
||||
*/
|
||||
export function groupPhasesByDependencyLevel<T extends PhaseForSort>(
|
||||
phases: T[],
|
||||
edges: DependencyEdge[],
|
||||
): PipelineColumn<T>[] {
|
||||
if (phases.length === 0) return [];
|
||||
|
||||
const phaseIds = new Set(phases.map((p) => p.id));
|
||||
|
||||
// Build adjacency: for each phase, what does it depend on?
|
||||
const dependsOn = new Map<string, string[]>();
|
||||
for (const p of phases) dependsOn.set(p.id, []);
|
||||
for (const edge of edges) {
|
||||
if (!phaseIds.has(edge.phaseId) || !phaseIds.has(edge.dependsOnPhaseId)) continue;
|
||||
dependsOn.get(edge.phaseId)!.push(edge.dependsOnPhaseId);
|
||||
}
|
||||
|
||||
// Compute depth via recursive longest path with cycle detection
|
||||
const depthCache = new Map<string, number>();
|
||||
const visiting = new Set<string>();
|
||||
|
||||
function computeDepth(id: string): number {
|
||||
if (depthCache.has(id)) return depthCache.get(id)!;
|
||||
if (visiting.has(id)) return -1; // cycle detected
|
||||
|
||||
visiting.add(id);
|
||||
const deps = dependsOn.get(id) ?? [];
|
||||
let maxDepth = 0;
|
||||
for (const depId of deps) {
|
||||
const d = computeDepth(depId);
|
||||
if (d === -1) {
|
||||
visiting.delete(id);
|
||||
return -1;
|
||||
}
|
||||
maxDepth = Math.max(maxDepth, d + 1);
|
||||
}
|
||||
visiting.delete(id);
|
||||
depthCache.set(id, maxDepth);
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
// Compute depths
|
||||
let maxValidDepth = 0;
|
||||
for (const p of phases) {
|
||||
const d = computeDepth(p.id);
|
||||
if (d >= 0) maxValidDepth = Math.max(maxValidDepth, d);
|
||||
}
|
||||
|
||||
// Group by depth
|
||||
const columnMap = new Map<number, T[]>();
|
||||
for (const p of phases) {
|
||||
const d = depthCache.get(p.id);
|
||||
const depth = d != null && d >= 0 ? d : maxValidDepth + 1; // cycles go last
|
||||
if (!columnMap.has(depth)) columnMap.set(depth, []);
|
||||
columnMap.get(depth)!.push(p);
|
||||
}
|
||||
|
||||
// Sort phases within each column by createdAt
|
||||
const columns: PipelineColumn<T>[] = [];
|
||||
const sortedDepths = [...columnMap.keys()].sort((a, b) => a - b);
|
||||
for (const depth of sortedDepths) {
|
||||
const phasesInCol = columnMap.get(depth)!;
|
||||
phasesInCol.sort((a, b) => toTime(a.createdAt) - toTime(b.createdAt));
|
||||
columns.push({ depth, phases: phasesInCol });
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
134
src/test/topological-sort.test.ts
Normal file
134
src/test/topological-sort.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { topologicalSortPhases, type PhaseForSort, type DependencyEdge } from '@codewalk-district/shared';
|
||||
|
||||
function mkPhase(id: string, createdAt: string | Date): PhaseForSort {
|
||||
return { id, createdAt };
|
||||
}
|
||||
|
||||
describe('topologicalSortPhases', () => {
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(topologicalSortPhases([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return phases in createdAt order when no edges', () => {
|
||||
const phases = [
|
||||
mkPhase('c', '2026-01-03'),
|
||||
mkPhase('a', '2026-01-01'),
|
||||
mkPhase('b', '2026-01-02'),
|
||||
];
|
||||
const result = topologicalSortPhases(phases, []);
|
||||
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should sort linear chain correctly', () => {
|
||||
// A -> B -> C (B depends on A, C depends on B)
|
||||
const phases = [
|
||||
mkPhase('a', '2026-01-01'),
|
||||
mkPhase('b', '2026-01-02'),
|
||||
mkPhase('c', '2026-01-03'),
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ phaseId: 'b', dependsOnPhaseId: 'a' },
|
||||
{ phaseId: 'c', dependsOnPhaseId: 'b' },
|
||||
];
|
||||
const result = topologicalSortPhases(phases, edges);
|
||||
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should handle diamond dependency', () => {
|
||||
// A
|
||||
// / \
|
||||
// B C
|
||||
// \ /
|
||||
// D
|
||||
const phases = [
|
||||
mkPhase('a', '2026-01-01'),
|
||||
mkPhase('b', '2026-01-02'),
|
||||
mkPhase('c', '2026-01-03'),
|
||||
mkPhase('d', '2026-01-04'),
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ phaseId: 'b', dependsOnPhaseId: 'a' },
|
||||
{ phaseId: 'c', dependsOnPhaseId: 'a' },
|
||||
{ phaseId: 'd', dependsOnPhaseId: 'b' },
|
||||
{ phaseId: 'd', dependsOnPhaseId: 'c' },
|
||||
];
|
||||
const result = topologicalSortPhases(phases, edges);
|
||||
// A must come first, D must come last, B before C by createdAt
|
||||
expect(result[0].id).toBe('a');
|
||||
expect(result[3].id).toBe('d');
|
||||
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('should use createdAt as deterministic tiebreaker', () => {
|
||||
// Three independent phases — should sort by createdAt
|
||||
const phases = [
|
||||
mkPhase('z', '2026-01-03'),
|
||||
mkPhase('y', '2026-01-01'),
|
||||
mkPhase('x', '2026-01-02'),
|
||||
];
|
||||
const result = topologicalSortPhases(phases, []);
|
||||
expect(result.map((p) => p.id)).toEqual(['y', 'x', 'z']);
|
||||
});
|
||||
|
||||
it('should handle cycle gracefully by appending cycled nodes', () => {
|
||||
// A -> B -> A (cycle), C is independent
|
||||
const phases = [
|
||||
mkPhase('a', '2026-01-01'),
|
||||
mkPhase('b', '2026-01-02'),
|
||||
mkPhase('c', '2026-01-03'),
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ phaseId: 'b', dependsOnPhaseId: 'a' },
|
||||
{ phaseId: 'a', dependsOnPhaseId: 'b' },
|
||||
];
|
||||
const result = topologicalSortPhases(phases, edges);
|
||||
// C has no deps so it comes first, then A and B appended (cycle)
|
||||
expect(result[0].id).toBe('c');
|
||||
expect(result.length).toBe(3);
|
||||
// A and B are appended in createdAt order
|
||||
expect(result[1].id).toBe('a');
|
||||
expect(result[2].id).toBe('b');
|
||||
});
|
||||
|
||||
it('should ignore edges referencing non-existent phases', () => {
|
||||
const phases = [
|
||||
mkPhase('a', '2026-01-01'),
|
||||
mkPhase('b', '2026-01-02'),
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ phaseId: 'b', dependsOnPhaseId: 'nonexistent' },
|
||||
];
|
||||
const result = topologicalSortPhases(phases, edges);
|
||||
// Edge is ignored, both treated as independent
|
||||
expect(result.map((p) => p.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should handle single phase with no edges', () => {
|
||||
const phases = [mkPhase('only', '2026-01-01')];
|
||||
const result = topologicalSortPhases(phases, []);
|
||||
expect(result.map((p) => p.id)).toEqual(['only']);
|
||||
});
|
||||
|
||||
it('should work with Date objects', () => {
|
||||
const phases = [
|
||||
mkPhase('b', new Date('2026-01-02')),
|
||||
mkPhase('a', new Date('2026-01-01')),
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ phaseId: 'b', dependsOnPhaseId: 'a' },
|
||||
];
|
||||
const result = topologicalSortPhases(phases, edges);
|
||||
expect(result.map((p) => p.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should preserve extra properties on phase objects', () => {
|
||||
const phases = [
|
||||
{ id: 'a', createdAt: '2026-01-01', name: 'Alpha', status: 'pending' },
|
||||
{ id: 'b', createdAt: '2026-01-02', name: 'Beta', status: 'active' },
|
||||
];
|
||||
const result = topologicalSortPhases(phases, []);
|
||||
expect(result[0].name).toBe('Alpha');
|
||||
expect(result[1].name).toBe('Beta');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user