146 lines
6.5 KiB
Python
146 lines
6.5 KiB
Python
"""Value-math pins — every number hand-checked before pinning.
|
||
|
||
Mock scenario (the notebook's widget seeds use the same values):
|
||
|
||
volume 1,200,000 · $6.50/contact · 450 agents · 30% attrition ·
|
||
20% containment · 420s AHT · weakest foundation = data_readiness @ 2
|
||
|
||
Hand arithmetic:
|
||
|
||
deflection low 1.2M × 0.15 × 6.50 = 1,170,000 high ×0.35 = 2,730,000
|
||
AHT low 1.2M × 6.50 × 0.15 = 1,170,000 high ×0.25 = 1,950,000
|
||
attrition low 450 × 0.30 × 0.10 × 15,000 = 202,500 high ×0.20 = 405,000
|
||
theoretical low 2,542,500 high 5,085,000
|
||
band @2 = (0.25, 0.40)
|
||
realizable 18mo low 2,542,500 × 0.25 × 1.5 = 953,437.50
|
||
high 5,085,000 × 0.40 × 1.5 = 3,051,000
|
||
trapped (annual) low 2,542,500 × (1−0.40) = 1,525,500
|
||
high 5,085,000 × (1−0.25) = 3,813,750
|
||
"""
|
||
|
||
import pytest
|
||
|
||
from diaglib import (
|
||
binding_constraints,
|
||
driver_value,
|
||
money,
|
||
value_at_stake,
|
||
weakest_foundational_score,
|
||
)
|
||
|
||
approx = pytest.approx
|
||
|
||
|
||
def test_driver_pins(config, baseline):
|
||
by_id = {d.id: driver_value(d, baseline) for d in config.value_drivers}
|
||
assert by_id["deflection_lift"].theoretical_low == approx(1_170_000)
|
||
assert by_id["deflection_lift"].theoretical_high == approx(2_730_000)
|
||
assert by_id["aht_reduction"].theoretical_low == approx(1_170_000)
|
||
assert by_id["aht_reduction"].theoretical_high == approx(1_950_000)
|
||
assert by_id["attrition_reduction"].theoretical_low == approx(202_500)
|
||
assert by_id["attrition_reduction"].theoretical_high == approx(405_000)
|
||
|
||
|
||
def test_value_at_stake_pins(config, baseline, scores):
|
||
vas = value_at_stake(config, baseline, scores)
|
||
assert vas.theoretical_annual_value_low == approx(2_542_500)
|
||
assert vas.theoretical_annual_value_high == approx(5_085_000)
|
||
assert vas.weakest_foundational_score == 2
|
||
assert (vas.realization_factor_low, vas.realization_factor_high) == (0.25, 0.40)
|
||
assert vas.realizable_18mo_low == approx(953_437.50)
|
||
assert vas.realizable_18mo_high == approx(3_051_000)
|
||
assert vas.trapped_value_low == approx(1_525_500)
|
||
assert vas.trapped_value_high == approx(3_813_750)
|
||
assert vas.binding_constraints == ["data_readiness"]
|
||
assert vas.warnings == [] # nothing flagged unknown in the fixture
|
||
|
||
|
||
def test_unlock_sequence_pins(config, baseline, scores):
|
||
vas = value_at_stake(config, baseline, scores)
|
||
m1, m2, m3 = vas.unlock_sequence
|
||
|
||
# Move 1 — the unique weakest foundation lifts alone.
|
||
assert m1.competency_ids == ["data_readiness"]
|
||
assert (m1.current_level, m1.target_level) == (2, 3)
|
||
assert (m1.est_cost_low, m1.est_cost_high, m1.est_weeks) == (300_000, 600_000, 12)
|
||
assert m1.value_unlocked_low == approx(2_542_500 * 0.25) # 635,625
|
||
assert m1.value_unlocked_high == approx(5_085_000 * 0.25) # 1,271,250
|
||
|
||
# Move 2 — all three foundations now tie at 3: joint lift.
|
||
assert m2.competency_ids == [
|
||
"process_discovery", "data_readiness", "technical_architecture"]
|
||
assert (m2.current_level, m2.target_level) == (3, 4)
|
||
assert m2.est_cost_low == approx(150_000 + 400_000 + 250_000) # 800,000
|
||
assert m2.est_cost_high == approx(300_000 + 800_000 + 500_000) # 1,600,000
|
||
assert m2.est_weeks == 16 # longest workstream
|
||
assert m2.value_unlocked_low == approx(2_542_500 * 0.15) # 381,375
|
||
assert m2.value_unlocked_high == approx(5_085_000 * 0.20) # 1,017,000
|
||
assert "joint lift" in m2.note
|
||
|
||
# Move 3 — the trio lifts again, 4 → 5.
|
||
assert (m3.current_level, m3.target_level) == (4, 5)
|
||
assert m3.est_cost_low == approx(200_000 + 500_000 + 350_000) # 1,050,000
|
||
assert m3.est_cost_high == approx(400_000 + 1_000_000 + 700_000) # 2,100,000
|
||
assert m3.est_weeks == 20
|
||
assert m3.value_unlocked_low == approx(2_542_500 * 0.15)
|
||
assert m3.value_unlocked_high == approx(5_085_000 * 0.15) # 762,750
|
||
|
||
# The ratio walk declines — the first unlock is the cheapest value.
|
||
ratios = [((m.value_unlocked_low + m.value_unlocked_high) / 2)
|
||
/ ((m.est_cost_low + m.est_cost_high) / 2)
|
||
for m in (m1, m2, m3)]
|
||
assert ratios[0] > ratios[1] > ratios[2]
|
||
|
||
|
||
def test_structural_ties_hold_at_any_scores(config, baseline, scores):
|
||
vas = value_at_stake(config, baseline, scores)
|
||
assert vas.theoretical_annual_value_low <= vas.theoretical_annual_value_high
|
||
assert vas.realizable_18mo_low <= vas.realizable_18mo_high
|
||
assert vas.trapped_value_low <= vas.trapped_value_high
|
||
assert sum(d.theoretical_low for d in vas.driver_values) == approx(
|
||
vas.theoretical_annual_value_low)
|
||
assert sum(d.theoretical_high for d in vas.driver_values) == approx(
|
||
vas.theoretical_annual_value_high)
|
||
assert set(vas.binding_constraints) <= set(config.foundational_competencies)
|
||
assert len(vas.unlock_sequence) <= 3
|
||
|
||
|
||
def test_weakest_and_binding_with_ties(config, baseline, scores):
|
||
assert weakest_foundational_score(config, scores) == 2
|
||
# Drag process_discovery down to 2 as well — binding set becomes a pair.
|
||
tied = [s.model_copy(update={"score": 2})
|
||
if s.competency_id == "process_discovery" else s for s in scores]
|
||
assert binding_constraints(config, tied) == ["process_discovery", "data_readiness"]
|
||
vas = value_at_stake(config, baseline, tied)
|
||
m1 = vas.unlock_sequence[0]
|
||
assert m1.competency_ids == ["process_discovery", "data_readiness"]
|
||
assert "joint lift" in m1.note
|
||
assert m1.est_cost_low == approx(120_000 + 300_000)
|
||
|
||
|
||
def test_unknown_inputs_raise_warnings(config, baseline, scores):
|
||
flagged = baseline.model_copy(update={"field_confidence": {
|
||
**baseline.field_confidence, "annual_contact_volume": "unknown"}})
|
||
vas = value_at_stake(config, flagged, scores)
|
||
assert any("annual_contact_volume" in w and "unknown" in w for w in vas.warnings)
|
||
|
||
|
||
def test_stub_config_yields_empty_value(stub_config, baseline, scores):
|
||
vas = value_at_stake(stub_config, baseline, scores)
|
||
assert vas.theoretical_annual_value_low == 0
|
||
assert vas.theoretical_annual_value_high == 0
|
||
assert vas.unlock_sequence == []
|
||
assert any("no value drivers" in w for w in vas.warnings)
|
||
|
||
|
||
def test_money_two_significant_figures():
|
||
assert money(953_437.50) == "$950K"
|
||
assert money(2_542_500) == "$2.5M"
|
||
assert money(1_271_250) == "$1.3M"
|
||
assert money(5_085_000) == "$5.1M"
|
||
assert money(15_000_000) == "$15M"
|
||
assert money(202_500) == "$200K"
|
||
assert money(-450_000) == "-$450K"
|
||
assert money(85) == "$85"
|
||
assert money(0) == "$0"
|