feat(cost-benefit/risk-analysis): implement riskPath, riskDistribution, shouldDecomposeTask
This commit is contained in:
@@ -1 +1,70 @@
|
||||
// shouldDecomposeTask
|
||||
// shouldDecomposeTask
|
||||
//
|
||||
// Pure function — takes node attributes (not a graph) and determines
|
||||
// whether a task should be decomposed based on risk and scope thresholds.
|
||||
//
|
||||
// Decomposition threshold:
|
||||
// - risk >= "high" OR scope >= "broad"
|
||||
//
|
||||
// Unassessed tasks (null/undefined risk or scope) are never flagged —
|
||||
// resolveDefaults fills them with "medium" risk (below threshold) and
|
||||
// "narrow" scope (below threshold).
|
||||
|
||||
import type { TaskGraphNodeAttributes } from "../schema/graph.js";
|
||||
import type { TaskRisk, TaskScope } from "../schema/enums.js";
|
||||
import type { DecomposeResult } from "../schema/results.js";
|
||||
import {
|
||||
resolveDefaults,
|
||||
scopeCostEstimate,
|
||||
riskSuccessProbability,
|
||||
} from "./defaults.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decomposition thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Risk levels at or above this threshold trigger decomposition. */
|
||||
const RISK_DECOMPOSE_THRESHOLD: TaskRisk[] = ["high", "critical"];
|
||||
|
||||
/** Scope levels at or above this threshold trigger decomposition. */
|
||||
const SCOPE_DECOMPOSE_THRESHOLD: TaskScope[] = ["broad", "system"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldDecomposeTask
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determine whether a task should be decomposed based on its risk and scope.
|
||||
*
|
||||
* Internally calls `resolveDefaults` to handle nullable `risk` and `scope`
|
||||
* fields. Unassessed fields use defaults that are below the decomposition
|
||||
* threshold, so only explicitly-assessed high-risk or broad-scope tasks are
|
||||
* flagged.
|
||||
*
|
||||
* @param attrs - Task node attributes (nullable categorical fields accepted)
|
||||
* @returns DecomposeResult with shouldDecompose flag and specific reasons
|
||||
*/
|
||||
export function shouldDecomposeTask(
|
||||
attrs: Partial<TaskGraphNodeAttributes> & Pick<TaskGraphNodeAttributes, "name">,
|
||||
): DecomposeResult {
|
||||
const resolved = resolveDefaults(attrs);
|
||||
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Check risk threshold
|
||||
if (RISK_DECOMPOSE_THRESHOLD.includes(resolved.risk)) {
|
||||
const failureProb = (1 - riskSuccessProbability(resolved.risk)).toFixed(2);
|
||||
reasons.push(`risk: ${resolved.risk} — failure probability ${failureProb}`);
|
||||
}
|
||||
|
||||
// Check scope threshold
|
||||
if (SCOPE_DECOMPOSE_THRESHOLD.includes(resolved.scope)) {
|
||||
const costEst = scopeCostEstimate(resolved.scope).toFixed(1);
|
||||
reasons.push(`scope: ${resolved.scope} — cost estimate ${costEst}`);
|
||||
}
|
||||
|
||||
return {
|
||||
shouldDecompose: reasons.length > 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
@@ -1 +1,96 @@
|
||||
// riskPath, riskDistribution
|
||||
// riskPath, riskDistribution
|
||||
//
|
||||
// Risk analysis functions:
|
||||
// - riskPath: finds the highest-risk path through the graph
|
||||
// - riskDistribution: groups all tasks by their risk category
|
||||
|
||||
import type { TaskGraph } from "../graph/construction.js";
|
||||
import type { TaskRisk } from "../schema/enums.js";
|
||||
import type { RiskPathResult, RiskDistributionResult } from "../schema/results.js";
|
||||
import { weightedCriticalPath } from "./critical-path.js";
|
||||
import { riskWeight, impactWeight } from "./defaults.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// riskPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the highest-risk path through the graph and its total risk score.
|
||||
*
|
||||
* Calls `weightedCriticalPath` with a weight function of
|
||||
* `riskWeight(risk) * impactWeight(impact)`, then sums the weight values
|
||||
* along the resulting path to produce `totalRisk`.
|
||||
*
|
||||
* @param graph - The task graph to analyze
|
||||
* @returns RiskPathResult with the path (ordered task IDs) and totalRisk
|
||||
* @throws {CircularDependencyError} If the graph contains cycles
|
||||
*/
|
||||
export function riskPath(graph: TaskGraph): RiskPathResult {
|
||||
const raw = graph.raw;
|
||||
|
||||
// Define the risk weight function: riskWeight * impactWeight
|
||||
const riskWeightFn = (
|
||||
_taskId: string,
|
||||
attrs: { risk?: TaskRisk; impact?: string },
|
||||
): number => {
|
||||
const risk = attrs.risk ?? "medium";
|
||||
const impact = (attrs.impact ?? "isolated") as Parameters<typeof impactWeight>[0];
|
||||
return riskWeight(risk) * impactWeight(impact);
|
||||
};
|
||||
|
||||
const path = weightedCriticalPath(graph, riskWeightFn);
|
||||
|
||||
// Compute totalRisk as the sum of weight values on the path
|
||||
let totalRisk = 0;
|
||||
for (const taskId of path) {
|
||||
const attrs = raw.getNodeAttributes(taskId);
|
||||
const risk = attrs.risk ?? "medium";
|
||||
const impact = (attrs.impact ?? "isolated") as Parameters<typeof impactWeight>[0];
|
||||
totalRisk += riskWeight(risk) * impactWeight(impact);
|
||||
}
|
||||
|
||||
return { path, totalRisk };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// riskDistribution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RISK_BUCKETS: TaskRisk[] = ["trivial", "low", "medium", "high", "critical"];
|
||||
|
||||
/**
|
||||
* Group all tasks in the graph by their risk category.
|
||||
*
|
||||
* Tasks with `risk: undefined` (not assessed) go into the `unspecified` bucket.
|
||||
* Each task appears in exactly one bucket.
|
||||
*
|
||||
* @param graph - The task graph to analyze
|
||||
* @returns RiskDistributionResult with task ID arrays per risk bucket
|
||||
*/
|
||||
export function riskDistribution(graph: TaskGraph): RiskDistributionResult {
|
||||
const result: RiskDistributionResult = {
|
||||
trivial: [],
|
||||
low: [],
|
||||
medium: [],
|
||||
high: [],
|
||||
critical: [],
|
||||
unspecified: [],
|
||||
};
|
||||
|
||||
const raw = graph.raw;
|
||||
for (const nodeId of raw.nodes()) {
|
||||
const attrs = raw.getNodeAttributes(nodeId);
|
||||
const risk = attrs.risk;
|
||||
|
||||
if (risk === undefined || risk === null) {
|
||||
result.unspecified.push(nodeId);
|
||||
} else if (RISK_BUCKETS.includes(risk as TaskRisk)) {
|
||||
result[risk as TaskRisk].push(nodeId);
|
||||
} else {
|
||||
// Unknown risk value — treat as unspecified
|
||||
result.unspecified.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user