Merge analysis/bottlenecks: betweenness centrality bottlenecks, 20 tests
# Conflicts: # test/analysis.test.ts
This commit is contained in:
@@ -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.0–1.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.0–1.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;
|
||||
}
|
||||
@@ -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.0–1.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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user