diff --git a/src/analysis/cost-benefit.ts b/src/analysis/cost-benefit.ts index 3397003..2a2420f 100644 --- a/src/analysis/cost-benefit.ts +++ b/src/analysis/cost-benefit.ts @@ -1 +1,60 @@ -// calculateTaskEv, workflowCost, computeEffectiveP \ No newline at end of file +import type { EvConfig, EvResult } from "../schema/results.js"; + +/** + * Calculate the expected value (EV) of a task. + * + * Pure math function — takes numeric inputs, returns EV result. + * No graph dependency. + * + * Formula: + * expectedRetries = (1 - p) / p when p > 0, else 0 (geometric series) + * C_success = scopeCost * impactWeight + * C_fail = scopeCost * impactWeight + fallbackCost + timeLost * expectedRetries + * EV = p * C_success + (1 - p) * C_fail + * + * When `config.retries` is provided and > 0, `expectedRetries` is capped at `retries`. + * When `config.valueRate` is non-zero, the final EV is multiplied by `valueRate`. + * + * @param p - Probability of success (0 to 1) + * @param scopeCost - Cost estimate from scope (1.0–5.0) + * @param impactWeight - Impact weight (1.0–3.0) + * @param config - Optional configuration: retries, fallbackCost, timeLost, valueRate + * @returns EvResult with ev, pSuccess, and expectedRetries + */ +export function calculateTaskEv( + p: number, + scopeCost: number, + impactWeight: number, + config?: EvConfig, +): EvResult { + const retries = config?.retries ?? 0; + const fallbackCost = config?.fallbackCost ?? 0; + const timeLost = config?.timeLost ?? 0; + const valueRate = config?.valueRate ?? 0; + + // Expected retries: geometric series (1-p)/p when p > 0 + let expectedRetries = p > 0 ? (1 - p) / p : 0; + + // Cap at configured max retries when retries > 0 + if (retries > 0 && expectedRetries > retries) { + expectedRetries = retries; + } + + // C_success and C_fail: impactWeight scales scopeCost + const cSuccess = scopeCost * impactWeight; + const cFail = scopeCost * impactWeight + fallbackCost + timeLost * expectedRetries; + + // EV = P_success * C_success + (1 - P_success) * C_fail + let ev = p * cSuccess + (1 - p) * cFail; + + // Apply value rate conversion when configured + if (valueRate !== 0) { + ev = ev * valueRate; + } + + return { ev, pSuccess: p, expectedRetries }; +} + +// Placeholder for future implementation +// export function workflowCost(...) { ... } +// export function computeEffectiveP(...) { ... } \ No newline at end of file diff --git a/tasks/implementation/cost-benefit/ev-calculation.md b/tasks/implementation/cost-benefit/ev-calculation.md index 41e5ed1..bf91909 100644 --- a/tasks/implementation/cost-benefit/ev-calculation.md +++ b/tasks/implementation/cost-benefit/ev-calculation.md @@ -1,7 +1,7 @@ --- id: cost-benefit/ev-calculation name: Implement calculateTaskEv pure function -status: pending +status: completed depends_on: - schema/numeric-methods-and-defaults - schema/result-types @@ -41,8 +41,12 @@ Where `C_fail = scopeCost + fallbackCost + timeLost × expectedRetries`. ## Notes -> To be filled by implementation agent +All acceptance criteria verified via 30 unit tests covering formula correctness, edge cases, config variations, and known Python research model values. ## Summary -> To be filled on completion \ No newline at end of file +Implemented `calculateTaskEv` pure function in `src/analysis/cost-benefit.ts`. +- Modified: `src/analysis/cost-benefit.ts` — full implementation of calculateTaskEv +- Modified: `test/cost-benefit.test.ts` — 30 comprehensive unit tests +- Tests: 30, all passing (286 total across suite) +- Lint: clean (tsc --noEmit passes) \ No newline at end of file diff --git a/test/cost-benefit.test.ts b/test/cost-benefit.test.ts index 1b3e22d..a2ee656 100644 --- a/test/cost-benefit.test.ts +++ b/test/cost-benefit.test.ts @@ -1,7 +1,333 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; +import { calculateTaskEv } from "../src/analysis/cost-benefit.js"; -describe('Cost-Benefit', () => { - it('placeholder — EV and workflow cost calculations', () => { - expect(true).toBe(true); +// --------------------------------------------------------------------------- +// calculateTaskEv — pure function tests +// --------------------------------------------------------------------------- + +describe("calculateTaskEv", () => { + // --- Basic formula verification --- + + it("computes EV with default config (all zeros): C_fail = C_success", () => { + // When fallbackCost=0, timeLost=0, retries=0 → expectedRetries=0 + // C_success = scopeCost * impactWeight + // C_fail = scopeCost * impactWeight + 0 + 0 = C_success + // EV = p * C_success + (1-p) * C_success = C_success + const result = calculateTaskEv(0.8, 3.0, 1.5); + + expect(result.pSuccess).toBe(0.8); + expect(result.expectedRetries).toBeCloseTo(0.25); // (1-0.8)/0.8 = 0.25 + expect(result.ev).toBeCloseTo(3.0 * 1.5); // 4.5 — both terms equal C_success + }); + + it("computes EV = C_success when p = 1 (guaranteed success)", () => { + const result = calculateTaskEv(1.0, 4.0, 2.0); + + expect(result.pSuccess).toBe(1.0); + expect(result.expectedRetries).toBeCloseTo(0); // (1-1)/1 = 0 + expect(result.ev).toBeCloseTo(4.0 * 2.0); // 8.0 — only success term + }); + + it("computes EV = C_fail when p = 0 (guaranteed failure)", () => { + // When p=0, expectedRetries=0, so with default config: + // C_fail = scopeCost * impactWeight + 0 + 0 = scopeCost * impactWeight + // EV = 0 * C_success + 1 * C_fail = C_fail + const result = calculateTaskEv(0, 3.0, 1.5); + + expect(result.pSuccess).toBe(0); + expect(result.expectedRetries).toBe(0); // p=0 → 0 + expect(result.ev).toBeCloseTo(3.0 * 1.5); // 4.5 — only failure term + }); + + it("computes EV = C_fail when p = 0 with fallback cost", () => { + const result = calculateTaskEv(0, 3.0, 1.5, { fallbackCost: 10, timeLost: 5 }); + + expect(result.pSuccess).toBe(0); + expect(result.expectedRetries).toBe(0); // p=0 → 0, no retries + // C_fail = 3.0*1.5 + 10 + 5*0 = 4.5 + 10 + 0 = 14.5 + expect(result.ev).toBeCloseTo(14.5); + }); + + // --- Geometric series for expectedRetries --- + + it("computes expectedRetries as (1-p)/p for various p values", () => { + const cases: [number, number][] = [ + [0.98, 0.02 / 0.98], // trivial risk + [0.90, 0.10 / 0.90], // low risk + [0.80, 0.20 / 0.80], // medium risk → 0.25 + [0.65, 0.35 / 0.65], // high risk + [0.50, 0.50 / 0.50], // critical risk → 1.0 + ]; + + for (const [p, expectedRetries] of cases) { + const result = calculateTaskEv(p, 1.0, 1.0); + expect(result.expectedRetries).toBeCloseTo(expectedRetries); + } + }); + + // --- Impact weight scaling --- + + it("impactWeight scales scopeCost in both C_success and C_fail (default config)", () => { + // With default config (no fallback/retry costs), both terms = scopeCost * impactWeight + const result1 = calculateTaskEv(0.8, 3.0, 1.0); // isolated + const result2 = calculateTaskEv(0.8, 3.0, 1.5); // component + const result3 = calculateTaskEv(0.8, 3.0, 3.0); // project + + // All EVs should be scopeCost * impactWeight (since C_fail = C_success with default config) + expect(result1.ev).toBeCloseTo(3.0 * 1.0); // 3.0 + expect(result2.ev).toBeCloseTo(3.0 * 1.5); // 4.5 + expect(result3.ev).toBeCloseTo(3.0 * 3.0); // 9.0 + }); + + // --- Fallback cost --- + + it("adds fallbackCost to the failure term", () => { + const fallbackCost = 20; + const result = calculateTaskEv(0.5, 2.0, 1.0, { fallbackCost }); + + // C_success = 2.0 * 1.0 = 2.0 + // expectedRetries = (1-0.5)/0.5 = 1.0 + // C_fail = 2.0 + 20 + 0 * 1.0 = 22.0 + // EV = 0.5 * 2.0 + 0.5 * 22.0 = 1.0 + 11.0 = 12.0 + expect(result.ev).toBeCloseTo(12.0); + }); + + // --- Time lost per retry --- + + it("adds timeLost * expectedRetries to the failure term", () => { + const result = calculateTaskEv(0.5, 2.0, 1.0, { timeLost: 5 }); + + // expectedRetries = 1.0 + // C_fail = 2.0 + 0 + 5 * 1.0 = 7.0 + // EV = 0.5 * 2.0 + 0.5 * 7.0 = 1.0 + 3.5 = 4.5 + expect(result.ev).toBeCloseTo(4.5); + }); + + it("combines fallbackCost and timeLost in C_fail", () => { + const result = calculateTaskEv(0.8, 3.0, 1.5, { + fallbackCost: 10, + timeLost: 5, + }); + + // expectedRetries = 0.25 + // C_success = 3.0 * 1.5 = 4.5 + // C_fail = 4.5 + 10 + 5 * 0.25 = 15.75 + // EV = 0.8 * 4.5 + 0.2 * 15.75 = 3.6 + 3.15 = 6.75 + expect(result.ev).toBeCloseTo(6.75); + }); + + // --- Retries cap --- + + it("caps expectedRetries at config.retries when retries > 0", () => { + // p=0.5 → expectedRetries = 1.0, but config.retries = 3 → capped at 1.0 (below cap) + const result1 = calculateTaskEv(0.5, 2.0, 1.0, { retries: 3, timeLost: 5 }); + expect(result1.expectedRetries).toBeCloseTo(1.0); // not capped + + // p=0.2 → expectedRetries = 4.0, config.retries = 2 → capped at 2 + const result2 = calculateTaskEv(0.2, 2.0, 1.0, { retries: 2, timeLost: 5 }); + expect(result2.expectedRetries).toBe(2); // capped + + // C_fail with cap: 2.0 + 0 + 5 * 2 = 12.0 + // EV = 0.2 * 2.0 + 0.8 * 12.0 = 0.4 + 9.6 = 10.0 + expect(result2.ev).toBeCloseTo(10.0); + }); + + it("does not cap expectedRetries when retries = 0 (default)", () => { + // p=0.2 → expectedRetries = 4.0 + const result = calculateTaskEv(0.2, 2.0, 1.0); + expect(result.expectedRetries).toBeCloseTo(4.0); + }); + + it("respects retries cap with low probability and large expectedRetries", () => { + // p=0.1 → expectedRetries = 9.0, retries=1 → capped at 1 + const result = calculateTaskEv(0.1, 2.0, 1.0, { retries: 1, timeLost: 3 }); + + expect(result.expectedRetries).toBe(1); // capped + // C_fail = 2.0 + 0 + 3 * 1 = 5.0 + // EV = 0.1 * 2.0 + 0.9 * 5.0 = 0.2 + 4.5 = 4.7 + expect(result.ev).toBeCloseTo(4.7); + }); + + // --- Value rate conversion --- + + it("multiplies final EV by valueRate when non-zero", () => { + const result = calculateTaskEv(0.8, 3.0, 1.0, { valueRate: 100 }); + + // Without valueRate: EV = 3.0 (default config, C_fail = C_success) + // With valueRate: EV = 3.0 * 100 = 300 + expect(result.ev).toBeCloseTo(300); + }); + + it("does not multiply by valueRate when valueRate is 0 (default)", () => { + const result = calculateTaskEv(0.8, 3.0, 1.0); + + // EV = scopeCost * impactWeight * 1.0 = 3.0 + expect(result.ev).toBeCloseTo(3.0); + }); + + it("applies valueRate after full EV calculation including fallback and time costs", () => { + const result = calculateTaskEv(0.5, 2.0, 1.0, { + fallbackCost: 10, + timeLost: 5, + valueRate: 10, + }); + + // expectedRetries = 1.0 + // C_success = 2.0 + // C_fail = 2.0 + 10 + 5 * 1.0 = 17.0 + // EV_raw = 0.5 * 2.0 + 0.5 * 17.0 = 1.0 + 8.5 = 9.5 + // EV = 9.5 * 10 = 95 + expect(result.ev).toBeCloseTo(95); + }); + + // --- Known calculations from Python research model --- + + it("matches Python research model: medium risk + narrow scope + isolated impact (default task)", () => { + // p=0.80, scopeCost=2.0 (narrow), impactWeight=1.0 (isolated) + // No retry costs in simplest case → EV = scopeCost * impactWeight = 2.0 + const result = calculateTaskEv(0.80, 2.0, 1.0); + + expect(result.pSuccess).toBeCloseTo(0.80); + expect(result.expectedRetries).toBeCloseTo(0.25); + expect(result.ev).toBeCloseTo(2.0); + }); + + it("matches Python research model: high risk + broad scope + component impact with retries", () => { + // p=0.65, scopeCost=4.0 (broad), impactWeight=1.5 (component) + // With retries=3, timeLost=2, fallbackCost=8 + const result = calculateTaskEv(0.65, 4.0, 1.5, { + retries: 3, + timeLost: 2, + fallbackCost: 8, + }); + + // expectedRetries = (1-0.65)/0.65 = 0.538... → not capped (below 3) + expect(result.expectedRetries).toBeCloseTo(0.35 / 0.65); + + const p = 0.65; + const cSuccess = 4.0 * 1.5; // = 6.0 + const cFail = 6.0 + 8 + 2 * (0.35 / 0.65); // = 14 + 1.0769... = 15.0769... + const expectedEv = p * cSuccess + (1 - p) * cFail; + + expect(result.ev).toBeCloseTo(expectedEv); + }); + + it("matches Python research model: critical risk + system scope + project impact", () => { + // p=0.50, scopeCost=5.0 (system), impactWeight=3.0 (project) + const result = calculateTaskEv(0.50, 5.0, 3.0); + + expect(result.pSuccess).toBeCloseTo(0.50); + expect(result.expectedRetries).toBeCloseTo(1.0); + // Default config: C_fail = C_success = 5.0 * 3.0 = 15.0 + expect(result.ev).toBeCloseTo(15.0); + }); + + it("matches Python research model: trivial risk + single scope + isolated impact", () => { + // p=0.98, scopeCost=1.0 (single), impactWeight=1.0 (isolated) + const result = calculateTaskEv(0.98, 1.0, 1.0); + + expect(result.pSuccess).toBeCloseTo(0.98); + expect(result.expectedRetries).toBeCloseTo(0.02 / 0.98); + expect(result.ev).toBeCloseTo(1.0); + }); + + // --- Boundary values --- + + it("handles very small p > 0", () => { + const result = calculateTaskEv(0.01, 1.0, 1.0); + + expect(result.pSuccess).toBe(0.01); + expect(result.expectedRetries).toBeCloseTo(0.99 / 0.01); // = 99 + // Default config: C_fail = C_success = 1.0 + expect(result.ev).toBeCloseTo(1.0); + }); + + it("handles p very close to 1", () => { + const result = calculateTaskEv(0.999, 1.0, 1.0); + + expect(result.expectedRetries).toBeCloseTo(0.001 / 0.999); + expect(result.ev).toBeCloseTo(1.0); + }); + + it("returns zero EV when scopeCost is 0", () => { + const result = calculateTaskEv(0.5, 0, 1.0); + + // C_success = 0, C_fail = 0 + 0 + 0 = 0 + expect(result.ev).toBe(0); + }); + + it("returns zero EV when impactWeight is 0 and no fallback costs", () => { + const result = calculateTaskEv(0.5, 5.0, 0); + + // C_success = 0, C_fail = 0 + 0 + 0 = 0 + expect(result.ev).toBe(0); + }); + + it("preserves p as pSuccess in the result without modification", () => { + expect(calculateTaskEv(0.75, 2.0, 1.0).pSuccess).toBe(0.75); + expect(calculateTaskEv(0, 2.0, 1.0).pSuccess).toBe(0); + expect(calculateTaskEv(1, 2.0, 1.0).pSuccess).toBe(1); + }); + + // --- Config variations --- + + it("undefined config defaults all fields to 0", () => { + const result = calculateTaskEv(0.5, 2.0, 1.0, undefined); + + expect(result.expectedRetries).toBeCloseTo(1.0); + expect(result.ev).toBeCloseTo(2.0); + }); + + it("empty config object defaults all fields to 0", () => { + const result = calculateTaskEv(0.5, 2.0, 1.0, {}); + + expect(result.expectedRetries).toBeCloseTo(1.0); + expect(result.ev).toBeCloseTo(2.0); + }); + + it("partial config: only retries set", () => { + const result = calculateTaskEv(0.2, 2.0, 1.0, { retries: 1 }); + + // expectedRetries = (1-0.2)/0.2 = 4, capped at 1 + expect(result.expectedRetries).toBe(1); + // C_success = 2.0, C_fail = 2.0 + 0 + 0 * 1 = 2.0 + // EV = 0.2 * 2.0 + 0.8 * 2.0 = 0.4 + 1.6 = 2.0 + expect(result.ev).toBeCloseTo(2.0); + }); + + it("partial config: only fallbackCost set", () => { + const result = calculateTaskEv(0.5, 2.0, 1.0, { fallbackCost: 5 }); + + // expectedRetries = 1.0 but timeLost=0 → retry cost = 0 + // C_fail = 2.0 + 5 + 0 = 7.0 + // EV = 0.5 * 2.0 + 0.5 * 7.0 = 1.0 + 3.5 = 4.5 + expect(result.ev).toBeCloseTo(4.5); + }); + + it("partial config: only timeLost set (no effect without retries generating cost)", () => { + const result = calculateTaskEv(0.5, 2.0, 1.0, { timeLost: 10 }); + + // expectedRetries = 1.0 + // C_fail = 2.0 + 0 + 10 * 1.0 = 12.0 + // EV = 0.5 * 2.0 + 0.5 * 12.0 = 1.0 + 6.0 = 7.0 + expect(result.ev).toBeCloseTo(7.0); + }); + + it("full config with all parameters", () => { + const result = calculateTaskEv(0.5, 2.0, 1.5, { + retries: 2, + fallbackCost: 10, + timeLost: 3, + valueRate: 50, + }); + + // expectedRetries = (1-0.5)/0.5 = 1.0, not capped (below 2) + expect(result.expectedRetries).toBeCloseTo(1.0); + + // C_success = 2.0 * 1.5 = 3.0 + // C_fail = 3.0 + 10 + 3 * 1.0 = 16.0 + // EV_raw = 0.5 * 3.0 + 0.5 * 16.0 = 1.5 + 8.0 = 9.5 + // EV = 9.5 * 50 = 475.0 + expect(result.ev).toBeCloseTo(475.0); }); }); \ No newline at end of file