From 47c31625814ef00a960ae39e93a92cf4f84bd005 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Mon, 9 Feb 2026 22:33:34 +0100 Subject: [PATCH] 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. --- packages/shared/src/index.ts | 2 +- packages/shared/src/utils.ts | 161 ++++++++++++++++++++++++++++++ src/test/topological-sort.test.ts | 134 +++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/test/topological-sort.test.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1d930e8..b6720a2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index a6b14ce..f9be460 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -34,4 +34,165 @@ export function sortByPriorityAndQueueTime(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( + phases: T[], + edges: DependencyEdge[], +): T[] { + if (phases.length === 0) return []; + + const phaseMap = new Map(phases.map((p) => [p.id, p])); + const inDegree = new Map(); + const adjacency = new Map(); // 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 { + 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( + phases: T[], + edges: DependencyEdge[], +): PipelineColumn[] { + 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(); + 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(); + const visiting = new Set(); + + 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(); + 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[] = []; + 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; } \ No newline at end of file diff --git a/src/test/topological-sort.test.ts b/src/test/topological-sort.test.ts new file mode 100644 index 0000000..e24d370 --- /dev/null +++ b/src/test/topological-sort.test.ts @@ -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'); + }); +});