Merge analysis/critical-path: criticalPath and weightedCriticalPath, 20 tests

This commit is contained in:
2026-04-27 12:34:51 +00:00
2 changed files with 507 additions and 4 deletions

View File

@@ -1 +1,161 @@
// criticalPath, weightedCriticalPath
// criticalPath, weightedCriticalPath
//
// Find the longest path from sources to sinks in a DAG using
// topological order + dynamic programming.
//
// Algorithm:
// 1. Get topological order (throws CircularDependencyError if cyclic)
// 2. For each node in topological order, compute the longest distance
// from any source to that node
// 3. The node with the maximum distance is the end of the critical path
// 4. Backtrack from that node to reconstruct the full path
//
// For criticalPath (unweighted): each edge contributes weight 1
// → dist[v] = max(dist[u] + 1) for all u → v
// → This finds the path with the most edges (longest chain)
//
// For weightedCriticalPath: each node contributes a weight via weightFn
// → dist[v] = max(dist[u] + weightFn(v)) for all u → v
// → Sources get dist[v] = weightFn(v) (they contribute their own weight)
// → This finds the path with the highest cumulative node weight
import type { TaskGraph } from '../graph/construction.js';
import type { TaskGraphNodeAttributes } from '../schema/graph.js';
// ---------------------------------------------------------------------------
// Internal helper: compute longest path via topological order + DP
// ---------------------------------------------------------------------------
interface LongestPathResult {
/** The ordered array of node IDs on the longest path */
path: string[];
/** The total distance/weight of the longest path */
distance: number;
}
/**
* Core DP algorithm for longest path in a DAG.
*
* @param graph - The TaskGraph instance
* @param nodeWeightFn - Function that returns the weight contribution of a node.
* For unweighted criticalPath, this returns 1.
* For weightedCriticalPath, this returns weightFn(nodeId, attrs).
*/
function computeLongestPath(
graph: TaskGraph,
nodeWeightFn: (nodeId: string, attrs: TaskGraphNodeAttributes) => number,
): LongestPathResult {
const raw = graph.raw;
// Empty graph → no path
if (raw.order === 0) {
return { path: [], distance: 0 };
}
// Get topological order — throws CircularDependencyError if cyclic
const topo = graph.topologicalOrder();
// Single node → the node itself is the path
if (topo.length === 1) {
const nodeId = topo[0];
const attrs = raw.getNodeAttributes(nodeId);
return { path: [nodeId!], distance: nodeWeightFn(nodeId!, attrs) };
}
// dist[v] = longest distance from any source to v
const dist = new Map<string, number>();
// predecessor[v] = the node u that maximizes dist[u] + weight(v)
const predecessor = new Map<string, string | null>();
// Initialize: sources (nodes with no incoming edges) get their own weight
for (const v of topo) {
const inDegree = raw.inDegree(v);
if (inDegree === 0) {
const attrs = raw.getNodeAttributes(v);
dist.set(v, nodeWeightFn(v, attrs));
predecessor.set(v, null);
} else {
dist.set(v, -Infinity);
predecessor.set(v, null);
}
}
// DP: process nodes in topological order
for (const v of topo) {
// Skip sources (already initialized)
if (raw.inDegree(v) === 0) continue;
const vAttrs = raw.getNodeAttributes(v);
const vWeight = nodeWeightFn(v, vAttrs);
// For each predecessor u → v, check if dist[u] + weight(v) is better
for (const u of raw.inNeighbors(v)) {
const uDist = dist.get(u)!; // u is processed before v (topo order)
const candidate = uDist + vWeight;
if (candidate > dist.get(v)!) {
dist.set(v, candidate);
predecessor.set(v, u);
}
}
}
// Find the node with the maximum distance (end of the critical path)
let maxDist = -Infinity;
let endNode: string | null = null;
for (const [nodeId, d] of dist) {
if (d > maxDist) {
maxDist = d;
endNode = nodeId;
}
}
// Backtrack from endNode to reconstruct the path
const path: string[] = [];
let current: string | null = endNode;
while (current !== null) {
path.unshift(current);
current = current !== null ? (predecessor.get(current) ?? null) : null;
}
return { path, distance: maxDist };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Find the critical path (longest path) in a task graph.
*
* Uses unweighted edges (each edge contributes weight 1 to the path length).
* Each node on the path also contributes weight 1. This effectively finds
* the path with the most nodes from any source to any sink.
*
* @param graph - The task graph to analyze
* @returns An ordered array of task IDs representing the critical path,
* from source to sink
* @throws {CircularDependencyError} If the graph contains cycles
*/
export function criticalPath(graph: TaskGraph): string[] {
return computeLongestPath(graph, () => 1).path;
}
/**
* Find the weighted critical path in a task graph.
*
* Each node contributes a weight determined by `weightFn`. The path with
* the highest cumulative weight is returned.
*
* @param graph - The task graph to analyze
* @param weightFn - A function that returns the weight of a node given
* its ID and attributes
* @returns An ordered array of task IDs representing the weighted critical
* path, from source to sink
* @throws {CircularDependencyError} If the graph contains cycles
*/
export function weightedCriticalPath(
graph: TaskGraph,
weightFn: (taskId: string, attrs: TaskGraphNodeAttributes) => number,
): string[] {
return computeLongestPath(graph, weightFn).path;
}