From 9d2d1811ca4b52e352baa4ddcc6c2b65c7480e88 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Mon, 27 Apr 2026 13:39:31 +0000 Subject: [PATCH] feat(cost-benefit/risk-analysis): implement riskPath, riskDistribution, shouldDecomposeTask --- src/analysis/decompose.ts | 71 +++- src/analysis/risk.ts | 97 ++++- .../cost-benefit/risk-analysis.md | 18 +- test/risk-analysis.test.ts | 393 ++++++++++++++++++ 4 files changed, 570 insertions(+), 9 deletions(-) create mode 100644 test/risk-analysis.test.ts diff --git a/src/analysis/decompose.ts b/src/analysis/decompose.ts index 6fff8be..838bdf7 100644 --- a/src/analysis/decompose.ts +++ b/src/analysis/decompose.ts @@ -1 +1,70 @@ -// shouldDecomposeTask \ No newline at end of file +// 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 & Pick, +): 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, + }; +} \ No newline at end of file diff --git a/src/analysis/risk.ts b/src/analysis/risk.ts index fe187e7..8b617be 100644 --- a/src/analysis/risk.ts +++ b/src/analysis/risk.ts @@ -1 +1,96 @@ -// riskPath, riskDistribution \ No newline at end of file +// 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[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[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; +} \ No newline at end of file diff --git a/tasks/implementation/cost-benefit/risk-analysis.md b/tasks/implementation/cost-benefit/risk-analysis.md index f820c27..4e52bd7 100644 --- a/tasks/implementation/cost-benefit/risk-analysis.md +++ b/tasks/implementation/cost-benefit/risk-analysis.md @@ -1,7 +1,7 @@ --- id: cost-benefit/risk-analysis name: Implement riskPath, riskDistribution, and shouldDecomposeTask functions -status: pending +status: completed depends_on: - cost-benefit/ev-calculation - analysis/critical-path @@ -18,23 +18,23 @@ Implement the three risk analysis functions: `riskPath`, `riskDistribution`, and ## Acceptance Criteria -- [ ] `riskPath(graph: TaskGraph): RiskPathResult`: +- [x] `riskPath(graph: TaskGraph): RiskPathResult`: - Calls `weightedCriticalPath` with weight function `riskWeight * impactWeight` - Returns `{ path: string[], totalRisk: number }` - `totalRisk` is the sum of weight values along the path -- [ ] `riskDistribution(graph: TaskGraph): RiskDistributionResult`: +- [x] `riskDistribution(graph: TaskGraph): RiskDistributionResult`: - Groups all tasks by their `risk` attribute - Returns `{ trivial: string[], low: string[], medium: string[], high: string[], critical: string[], unspecified: string[] }` - Tasks with `risk: undefined` (not assessed) go in `unspecified` - No duplicate task IDs in any bucket -- [ ] `shouldDecomposeTask(attrs: TaskGraphNodeAttributes): DecomposeResult`: +- [x] `shouldDecomposeTask(attrs: TaskGraphNodeAttributes): DecomposeResult`: - Pure function — takes node attributes (not a graph) - Internally calls `resolveDefaults` for `risk` and `scope` (nullable fields) - Flags decomposition when: risk >= "high" OR scope >= "broad" - Returns `{ shouldDecompose: boolean, reasons: string[] }` - Unassessed tasks (null/undefined risk or scope) are never flagged — default values are below threshold - Provides specific reasons: e.g., `"risk: high — failure probability 0.35"`, `"scope: broad — cost estimate 4.0"` -- [ ] Unit tests for all three functions with known inputs/outputs +- [x] Unit tests for all three functions with known inputs/outputs ## References @@ -43,8 +43,12 @@ Implement the three risk analysis functions: `riskPath`, `riskDistribution`, and ## Notes -> To be filled by implementation agent +All three functions implemented following architecture docs. `riskPath` delegates to `weightedCriticalPath` with riskWeight*impactWeight weight function and sums weights for totalRisk. `riskDistribution` iterates all nodes and groups by risk attribute (undefined/null → unspecified). `shouldDecomposeTask` is a pure function using `resolveDefaults` to fill nullable fields before checking thresholds (risk >= high, scope >= broad), with specific reason strings including numeric values. ## Summary -> To be filled on completion \ No newline at end of file +Implemented riskPath, riskDistribution, and shouldDecomposeTask functions. +- Modified: `src/analysis/risk.ts` (riskPath + riskDistribution) +- Modified: `src/analysis/decompose.ts` (shouldDecomposeTask) +- Created: `test/risk-analysis.test.ts` +- Tests: 29, all passing (590 total suite) \ No newline at end of file diff --git a/test/risk-analysis.test.ts b/test/risk-analysis.test.ts new file mode 100644 index 0000000..37154cb --- /dev/null +++ b/test/risk-analysis.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect } from "vitest"; +import { riskPath, riskDistribution } from "../src/analysis/risk.js"; +import { shouldDecomposeTask } from "../src/analysis/decompose.js"; +import { TaskGraph } from "../src/graph/construction.js"; +import { riskWeight, impactWeight } from "../src/analysis/defaults.js"; +import { CircularDependencyError } from "../src/error/index.js"; + +// --------------------------------------------------------------------------- +// riskPath +// --------------------------------------------------------------------------- + +describe("riskPath", () => { + it("returns empty path and zero totalRisk for empty graph", () => { + const graph = new TaskGraph(); + const result = riskPath(graph); + + expect(result.path).toEqual([]); + expect(result.totalRisk).toBe(0); + }); + + it("returns single node for graph with one task", () => { + const graph = TaskGraph.fromTasks([ + { id: "A", name: "Task A", dependsOn: [], risk: "high", impact: "phase" }, + ]); + + const result = riskPath(graph); + + expect(result.path).toEqual(["A"]); + // totalRisk = riskWeight("high") * impactWeight("phase") = 0.35 * 2.0 = 0.70 + expect(result.totalRisk).toBeCloseTo(0.35 * 2.0); + }); + + it("finds the highest-risk path in a simple chain", () => { + // A (medium, isolated) → B (high, phase) → C (critical, project) + const graph = TaskGraph.fromTasks([ + { id: "A", name: "Task A", dependsOn: [], risk: "medium", impact: "isolated" }, + { id: "B", name: "Task B", dependsOn: ["A"], risk: "high", impact: "phase" }, + { id: "C", name: "Task C", dependsOn: ["B"], risk: "critical", impact: "project" }, + ]); + + const result = riskPath(graph); + + // Only one chain: A → B → C + expect(result.path).toEqual(["A", "B", "C"]); + + // totalRisk = sum of riskWeight * impactWeight for each node + const wA = riskWeight("medium") * impactWeight("isolated"); // 0.20 * 1.0 = 0.20 + const wB = riskWeight("high") * impactWeight("phase"); // 0.35 * 2.0 = 0.70 + const wC = riskWeight("critical") * impactWeight("project"); // 0.50 * 3.0 = 1.50 + expect(result.totalRisk).toBeCloseTo(wA + wB + wC); + }); + + it("picks the path with highest cumulative risk in a diamond graph", () => { + // A (critical, isolated) + // / \ + // B C + // (low, (high, project) + // isolated) + // \ / + // D (medium, component) + const graph = TaskGraph.fromTasks([ + { id: "A", name: "Task A", dependsOn: [], risk: "critical", impact: "isolated" }, + { id: "B", name: "Task B", dependsOn: ["A"], risk: "low", impact: "isolated" }, + { id: "C", name: "Task C", dependsOn: ["A"], risk: "high", impact: "project" }, + { id: "D", name: "Task D", dependsOn: ["B", "C"], risk: "medium", impact: "component" }, + ]); + + const result = riskPath(graph); + + // Path through C should have higher risk: + // A → C → D: 0.50*1.0 + 0.35*3.0 + 0.20*1.5 = 0.50 + 1.05 + 0.30 = 1.85 + // A → B → D: 0.50*1.0 + 0.10*1.0 + 0.20*1.5 = 0.50 + 0.10 + 0.30 = 0.90 + // The weightedCriticalPath should pick A → C → D + expect(result.path).toEqual(["A", "C", "D"]); + + const wA = riskWeight("critical") * impactWeight("isolated"); // 0.50 * 1.0 = 0.50 + const wC = riskWeight("high") * impactWeight("project"); // 0.35 * 3.0 = 1.05 + const wD = riskWeight("medium") * impactWeight("component"); // 0.20 * 1.5 = 0.30 + expect(result.totalRisk).toBeCloseTo(wA + wC + wD); + }); + + it("uses default risk/impact when not specified on nodes", () => { + // Nodes without risk/impact should use defaults: medium risk, isolated impact + const graph = TaskGraph.fromTasks([ + { id: "A", name: "Task A", dependsOn: [] }, + { id: "B", name: "Task B", dependsOn: ["A"] }, + ]); + + const result = riskPath(graph); + + expect(result.path).toEqual(["A", "B"]); + // Both use defaults: riskWeight("medium") * impactWeight("isolated") = 0.20 * 1.0 = 0.20 each + expect(result.totalRisk).toBeCloseTo(0.20 + 0.20); + }); + + it("throws CircularDependencyError for cyclic graph", () => { + const tg = new TaskGraph(); + tg.addTask("A", { name: "Task A" }); + tg.addTask("B", { name: "Task B" }); + tg.addTask("C", { name: "Task C" }); + tg.addDependency("A", "B"); + tg.addDependency("B", "C"); + tg.addDependency("C", "A"); + + expect(() => riskPath(tg)).toThrow(CircularDependencyError); + }); +}); + +// --------------------------------------------------------------------------- +// riskDistribution +// --------------------------------------------------------------------------- + +describe("riskDistribution", () => { + it("returns all empty buckets for empty graph", () => { + const graph = new TaskGraph(); + const result = riskDistribution(graph); + + expect(result).toEqual({ + trivial: [], + low: [], + medium: [], + high: [], + critical: [], + unspecified: [], + }); + }); + + it("groups tasks by their risk attribute", () => { + const graph = TaskGraph.fromTasks([ + { id: "A", name: "Task A", dependsOn: [], risk: "trivial" }, + { id: "B", name: "Task B", dependsOn: [], risk: "low" }, + { id: "C", name: "Task C", dependsOn: [], risk: "medium" }, + { id: "D", name: "Task D", dependsOn: [], risk: "high" }, + { id: "E", name: "Task E", dependsOn: [], risk: "critical" }, + ]); + + const result = riskDistribution(graph); + + expect(result.trivial).toEqual(["A"]); + expect(result.low).toEqual(["B"]); + expect(result.medium).toEqual(["C"]); + expect(result.high).toEqual(["D"]); + expect(result.critical).toEqual(["E"]); + expect(result.unspecified).toEqual([]); + }); + + it("puts tasks without risk attribute in unspecified bucket", () => { + const graph = TaskGraph.fromTasks([ + { id: "A", name: "Task A", dependsOn: [], risk: "high" }, + { id: "B", name: "Task B", dependsOn: [] }, // no risk + { id: "C", name: "Task C", dependsOn: [], risk: "low" }, + ]); + + const result = riskDistribution(graph); + + expect(result.high).toEqual(["A"]); + expect(result.unspecified).toEqual(["B"]); + expect(result.low).toEqual(["C"]); + }); + + it("puts all unspecified tasks in one bucket when no tasks have risk", () => { + const graph = TaskGraph.fromTasks([ + { id: "A", name: "Task A", dependsOn: [] }, + { id: "B", name: "Task B", dependsOn: ["A"] }, + ]); + + const result = riskDistribution(graph); + + expect(result.unspecified).toEqual(["A", "B"]); + expect(result.trivial).toEqual([]); + expect(result.low).toEqual([]); + expect(result.medium).toEqual([]); + expect(result.high).toEqual([]); + expect(result.critical).toEqual([]); + }); + + it("no duplicate task IDs in any bucket", () => { + // Even if a task appears once in the graph, it should only appear once + const graph = TaskGraph.fromTasks([ + { id: "auth", name: "Auth module", dependsOn: [], risk: "high" }, + { id: "db", name: "Database setup", dependsOn: [], risk: "medium" }, + { id: "api", name: "API layer", dependsOn: ["auth", "db"] }, // no risk + { id: "tests", name: "Test suite", dependsOn: ["api"], risk: "low" }, + { id: "deploy", name: "Deploy pipeline", dependsOn: ["tests"], risk: "critical" }, + ]); + + const result = riskDistribution(graph); + + // Each bucket has unique entries + const allIds = [ + ...result.trivial, + ...result.low, + ...result.medium, + ...result.high, + ...result.critical, + ...result.unspecified, + ]; + expect(new Set(allIds).size).toBe(allIds.length); // no duplicates + }); + + it("handles mixed category fixture graph", () => { + // auth (high), db (medium), api (unspecified), tests (low), deploy (critical) + const graph = TaskGraph.fromTasks([ + { id: "auth", name: "Auth module", dependsOn: [], risk: "high", scope: "broad", impact: "phase", status: "pending" }, + { id: "db", name: "Database setup", dependsOn: [], risk: "medium", scope: "moderate", impact: null, status: "completed" }, + { id: "api", name: "API layer", dependsOn: ["auth", "db"], risk: null, scope: null, impact: "component", status: null }, + { id: "tests", name: "Test suite", dependsOn: ["api"], risk: "low", scope: null, impact: null, status: null }, + { id: "deploy", name: "Deploy pipeline", dependsOn: ["tests"], risk: "critical", scope: "system", impact: "project", status: "blocked" }, + ]); + + const result = riskDistribution(graph); + + expect(result.high).toEqual(["auth"]); + expect(result.medium).toEqual(["db"]); + expect(result.low).toEqual(["tests"]); + expect(result.critical).toEqual(["deploy"]); + expect(result.unspecified).toEqual(["api"]); + }); +}); + +// --------------------------------------------------------------------------- +// shouldDecomposeTask +// --------------------------------------------------------------------------- + +describe("shouldDecomposeTask", () => { + it("flags high risk tasks for decomposition", () => { + const result = shouldDecomposeTask({ name: "Auth module", risk: "high" as const }); + + expect(result.shouldDecompose).toBe(true); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain("risk: high"); + expect(result.reasons[0]).toContain("failure probability"); + // high risk: p=0.65, failure probability = 1 - 0.65 = 0.35 + expect(result.reasons[0]).toContain("0.35"); + }); + + it("flags critical risk tasks for decomposition", () => { + const result = shouldDecomposeTask({ name: "Deploy pipeline", risk: "critical" as const }); + + expect(result.shouldDecompose).toBe(true); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain("risk: critical"); + // critical risk: p=0.50, failure probability = 0.50 + expect(result.reasons[0]).toContain("0.50"); + }); + + it("flags broad scope tasks for decomposition", () => { + const result = shouldDecomposeTask({ name: "Refactor", scope: "broad" as const }); + + expect(result.shouldDecompose).toBe(true); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain("scope: broad"); + // broad scope: costEstimate = 4.0 + expect(result.reasons[0]).toContain("4.0"); + }); + + it("flags system scope tasks for decomposition", () => { + const result = shouldDecomposeTask({ name: "Migration", scope: "system" as const }); + + expect(result.shouldDecompose).toBe(true); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain("scope: system"); + // system scope: costEstimate = 5.0 + expect(result.reasons[0]).toContain("5.0"); + }); + + it("flags both high risk AND broad scope with two reasons", () => { + const result = shouldDecomposeTask({ + name: "Auth implementation", + risk: "high" as const, + scope: "broad" as const, + }); + + expect(result.shouldDecompose).toBe(true); + expect(result.reasons).toHaveLength(2); + expect(result.reasons[0]).toContain("risk: high"); + expect(result.reasons[0]).toContain("failure probability 0.35"); + expect(result.reasons[1]).toContain("scope: broad"); + expect(result.reasons[1]).toContain("cost estimate 4.0"); + }); + + it("does NOT flag medium risk tasks", () => { + const result = shouldDecomposeTask({ name: "Normal task", risk: "medium" as const }); + + expect(result.shouldDecompose).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("does NOT flag low risk tasks", () => { + const result = shouldDecomposeTask({ name: "Safe task", risk: "low" as const }); + + expect(result.shouldDecompose).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("does NOT flag trivial risk tasks", () => { + const result = shouldDecomposeTask({ name: "Trivial task", risk: "trivial" as const }); + + expect(result.shouldDecompose).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("does NOT flag narrow scope tasks", () => { + const result = shouldDecomposeTask({ name: "Narrow task", scope: "narrow" as const }); + + expect(result.shouldDecompose).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("does NOT flag moderate scope tasks", () => { + const result = shouldDecomposeTask({ name: "Moderate task", scope: "moderate" as const }); + + expect(result.shouldDecompose).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("does NOT flag single scope tasks", () => { + const result = shouldDecomposeTask({ name: "Single task", scope: "single" as const }); + + expect(result.shouldDecompose).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("does NOT flag unassessed tasks (null/undefined risk and scope)", () => { + // Unassessed tasks use defaults: risk=medium, scope=narrow + // Both are below threshold, so not flagged + const result = shouldDecomposeTask({ name: "Unassessed task" }); + + expect(result.shouldDecompose).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("does NOT flag tasks with only medium risk and narrow scope (defaults)", () => { + const result = shouldDecomposeTask({ + name: "Default-ish task", + risk: "medium" as const, + scope: "narrow" as const, + }); + + expect(result.shouldDecompose).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it("flags high risk even when scope is narrow", () => { + const result = shouldDecomposeTask({ + name: "Risky narrow task", + risk: "high" as const, + scope: "narrow" as const, + }); + + expect(result.shouldDecompose).toBe(true); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain("risk: high"); + }); + + it("flags broad scope even when risk is low", () => { + const result = shouldDecomposeTask({ + name: "Low-risk broad task", + risk: "low" as const, + scope: "broad" as const, + }); + + expect(result.shouldDecompose).toBe(true); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain("scope: broad"); + }); + + it("reasons include specific numeric values from risk and scope", () => { + const result = shouldDecomposeTask({ + name: "Complex task", + risk: "critical" as const, + scope: "system" as const, + impact: "project" as const, + }); + + expect(result.shouldDecompose).toBe(true); + expect(result.reasons).toHaveLength(2); + + // critical: failure probability = 1 - 0.50 = 0.50 + expect(result.reasons[0]).toBe("risk: critical — failure probability 0.50"); + // system: cost estimate = 5.0 + expect(result.reasons[1]).toBe("scope: system — cost estimate 5.0"); + }); + + it("is a pure function — does not depend on a graph", () => { + // shouldDecomposeTask takes attributes only, not a graph + const attrs = { name: "Standalone task", risk: "high" as const }; + const result = shouldDecomposeTask(attrs); + + expect(result.shouldDecompose).toBe(true); + // No graph needed — pure function on attributes + }); +}); \ No newline at end of file