Merge analysis/bottlenecks: betweenness centrality bottlenecks, 20 tests

# Conflicts:
#	test/analysis.test.ts
This commit is contained in:
2026-04-27 12:46:12 +00:00
3 changed files with 561 additions and 228 deletions

View File

@@ -1 +1,64 @@
// bottlenecks (graphology betweenness)
// bottlenecks (graphology betweenness)
import { centrality } from 'graphology-metrics';
import type { TaskGraph } from '../graph/index.js';
/**
* Result of bottleneck analysis: a task ID paired with its betweenness centrality score.
*
* Higher scores indicate the task lies on more shortest paths between other
* nodes, making it a structural bottleneck — delaying or failing this task
* has outsized impact on the overall workflow.
*/
export interface BottleneckResult {
taskId: string;
score: number;
}
/**
* Compute bottleneck scores for all tasks in the graph using betweenness centrality.
*
* Betweenness centrality measures the fraction of shortest paths between all
* node pairs that pass through a given node. Nodes with high betweenness are
* structural bottlenecks: they sit on the most shortest paths and their
* delay or failure disrupts the most communication/routes in the graph.
*
* Uses `graphology-metrics` betweenness centrality with `normalized: true`,
* which produces scores in the **0.01.0** range. For disconnected graphs,
* betweenness is 0 for nodes in components with fewer than 3 nodes (no
* shortest paths can traverse through them between distinct endpoints).
*
* All tasks are included in the result, even those with score 0 (they are
* not bottlenecks). Results are sorted by score descending (most critical
* bottlenecks first).
*
* @param graph - The task graph to analyze
* @returns Array of `{ taskId, score }` sorted by score descending
*/
export function bottlenecks(graph: TaskGraph): BottleneckResult[] {
const raw = graph.raw;
// Edge case: empty graph — graphology-metrics betweenness centrality
// throws on an empty graph (mnemonist FixedStack requires positive capacity).
// Return an empty array since there are no nodes to score.
if (raw.order === 0) {
return [];
}
// Compute normalized betweenness centrality (0.01.0 range)
const centralityMap = centrality.betweenness(raw, { normalized: true });
// Map to result objects for all nodes in the graph
const results: BottleneckResult[] = [];
raw.forEachNode((node) => {
results.push({
taskId: node,
score: centralityMap[node] ?? 0,
});
});
// Sort by score descending (highest bottleneck first)
results.sort((a, b) => b.score - a.score);
return results;
}

View File

@@ -1,4 +1,12 @@
import type { EvConfig, EvResult } from "../schema/results.js";
import type {
EvConfig,
EvResult,
WorkflowCostOptions,
WorkflowCostResult,
} from "../schema/results.js";
import type { TaskGraphInner } from "../graph/construction.js";
import { topologicalOrder } from "../graph/queries.js";
import { resolveDefaults } from "./defaults.js";
/**
* Calculate the expected value (EV) of a task.
@@ -55,6 +63,191 @@ export function calculateTaskEv(
return { ev, pSuccess: p, expectedRetries };
}
// Placeholder for future implementation
// export function workflowCost(...) { ... }
// export function computeEffectiveP(...) { ... }
/**
* Compute the effective probability of a task given upstream propagation.
*
* Internal helper — not exported on the public API surface but used by
* `workflowCost` to compute `pEffective` for each task.
*
* Algorithm (dag-propagate mode):
* 1. Start with the task's intrinsic probability
* 2. For each prerequisite, compute inherited quality:
* `parentP + (1 - parentP) × qualityRetention`
* 3. Multiply all inherited quality factors together with intrinsic probability:
* `pEffective = pIntrinsic × ∏(inheritedQualityFactors)`
*
* In `independent` mode: `pEffective = pIntrinsic` (no propagation).
*
* @param taskId - The task ID to compute effective probability for
* @param graph - The graphology graph instance
* @param upstreamSuccessProbs - Map of task IDs → their actual success probabilities (for propagation)
* @param defaultQualityRetention - Default quality retention per edge (0.01.0), default 0.9
* @param propagationMode - 'dag-propagate' or 'independent'
* @param pIntrinsic - The task's intrinsic success probability
* @returns The effective probability after upstream propagation
*/
export function computeEffectiveP(
taskId: string,
graph: TaskGraphInner,
upstreamSuccessProbs: Map<string, number>,
defaultQualityRetention: number,
propagationMode: "independent" | "dag-propagate",
pIntrinsic: number,
): number {
// Independent mode: no propagation at all
if (propagationMode === "independent") {
return pIntrinsic;
}
// dag-propagate mode: compute inherited quality from each prerequisite
const prereqs = graph.inNeighbors(taskId);
// No prerequisites → pEffective = pIntrinsic
if (prereqs.length === 0) {
return pIntrinsic;
}
// Compute inherited quality factor for each prerequisite
let inheritedProduct = 1.0;
for (const parentId of prereqs) {
const parentP = upstreamSuccessProbs.get(parentId);
// Parent should always be in upstreamSuccessProbs since we process
// in topological order, but guard against missing entries
if (parentP === undefined) {
continue;
}
// Get per-edge qualityRetention: check edge attributes first, fall back to default
const edgeKey = `${parentId}->${taskId}`;
let qualityRetention = defaultQualityRetention;
if (graph.hasEdge(edgeKey)) {
const edgeAttrs = graph.getEdgeAttributes(edgeKey);
if (edgeAttrs.qualityRetention !== undefined) {
qualityRetention = edgeAttrs.qualityRetention;
}
}
// Inherited quality: parentP + (1 - parentP) × qualityRetention
// - qualityRetention=0.0 → no retention → inheritedQuality = parentP (full propagation)
// - qualityRetention=1.0 → full retention → inheritedQuality = 1.0 (independent)
const inheritedQuality = parentP + (1 - parentP) * qualityRetention;
inheritedProduct *= inheritedQuality;
}
return pIntrinsic * inheritedProduct;
}
/**
* Compute the total workflow cost using DAG-propagation probability model.
*
* Processes tasks in topological order, computing effective probability for each
* task by combining its intrinsic probability with upstream propagation quality
* factors. Each task's EV is computed using `calculateTaskEv`.
*
* **Completed task semantics**: When `includeCompleted: false`, tasks with
* `status: "completed"` are excluded from the result's task list, but they
* **remain in the propagation chain** with p=1.0. Removing completed tasks from
* propagation would worsen downstream probability estimates.
*
* @param graph - The graphology graph instance
* @param options - Optional configuration for the analysis
* @returns WorkflowCostResult with per-task entries and aggregate totals
* @throws {CircularDependencyError} If the graph contains cycles
*/
export function workflowCost(
graph: TaskGraphInner,
options?: WorkflowCostOptions,
): WorkflowCostResult {
const propagationMode = options?.propagationMode ?? "dag-propagate";
const defaultQualityRetention = options?.defaultQualityRetention ?? 0.9;
const includeCompleted = options?.includeCompleted ?? true;
// Get topological order — throws CircularDependencyError if cyclic
const topoOrder = topologicalOrder(graph);
// Map of task IDs → their actual success probability for downstream propagation
const upstreamSuccessProbs = new Map<string, number>();
// Per-task results
const taskEntries: WorkflowCostResult["tasks"] = [];
for (const taskId of topoOrder) {
const nodeAttrs = graph.getNodeAttributes(taskId);
const resolved = resolveDefaults(nodeAttrs);
const pIntrinsic = resolved.successProbability;
// Determine the probability to propagate downstream for this task
let propagationP: number;
let pEffective: number;
// Completed tasks propagate with p=1.0 when includeCompleted is false
const isCompleted = nodeAttrs.status === "completed";
if (isCompleted && !includeCompleted) {
// Completed + excluded: propagate p=1.0, compute pEffective normally but
// for propagation purposes the task is a guaranteed success
pEffective = computeEffectiveP(
taskId,
graph,
upstreamSuccessProbs,
defaultQualityRetention,
propagationMode,
pIntrinsic,
);
propagationP = 1.0;
} else {
// Normal task: compute pEffective and use it for downstream propagation
pEffective = computeEffectiveP(
taskId,
graph,
upstreamSuccessProbs,
defaultQualityRetention,
propagationMode,
pIntrinsic,
);
propagationP = pEffective;
}
// Store for downstream propagation
upstreamSuccessProbs.set(taskId, propagationP);
// Skip completed tasks from the result when includeCompleted is false
if (isCompleted && !includeCompleted) {
continue;
}
// Calculate EV using pEffective
const evResult = calculateTaskEv(
pEffective,
resolved.costEstimate,
resolved.impactWeight,
);
taskEntries.push({
taskId,
name: resolved.name,
ev: evResult.ev,
pIntrinsic,
pEffective,
probability: pEffective,
scopeCost: resolved.costEstimate,
impactWeight: resolved.impactWeight,
});
}
// Apply limit if specified
const limitedEntries = options?.limit !== undefined
? taskEntries.slice(0, options.limit)
: taskEntries;
// Compute totals
const totalEv = limitedEntries.reduce((sum, entry) => sum + entry.ev, 0);
const averageEv = limitedEntries.length > 0 ? totalEv / limitedEntries.length : 0;
return {
tasks: limitedEntries,
totalEv,
averageEv,
propagationMode,
};
}