Files
palladium/studies/202607_CX_AI_Diagnostic/diaglib/value_math.py
2026-07-23 12:04:37 -04:00

229 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Value-at-stake math: driver values, capability capping, unlock sequence.
The pipeline (build spec §6):
1. Each configured value driver yields a theoretical annual value range
from the operational baseline (dispatch on ``driver.kind``).
2. Theoretical annual value = sum of drivers.
3. The **weakest foundational competency score** selects a realization
band from the capping heuristic.
4. ``realizable_18mo = theoretical × realization_factor × 1.5``
(18 months of annual run-rate).
5. Trapped value (annual) = theoretical realizable run-rate. Range
pairing is conservative-consistent: the low trapped estimate assumes
the low theoretical *and* the high realization factor, and vice versa.
6. Binding constraints = every foundational competency sitting at the
weakest score.
7. Unlock sequence = up to three **tier lifts**: raise the whole binding
set one level, recompute the band, attribute the delta. When several
competencies tie at the weakest level a single-competency lift would
honestly unlock nothing — the set is the move (see UnlockMove docs).
Guard rails: every output is a range; 🔴-unknown inputs raise warnings on
the result; money *display* is capped at two significant figures
(:func:`money`) while raw floats stay exact in exports.
"""
from __future__ import annotations
from .models import (
CompetencyScore,
DiagnosticConfig,
DriverValue,
OperationalBaseline,
UnlockMove,
ValueAtStake,
ValueDriver,
)
#: 18 months expressed in years of annual run-rate.
MONTHS_18 = 1.5
#: How many unlock moves the sequence proposes.
MAX_UNLOCK_MOVES = 3
# ── Money display (guard rail: ≤ 2 significant figures) ──────────────
def _round_2sf(v: float) -> float:
if v == 0:
return 0.0
from math import floor, log10
exp = floor(log10(abs(v)))
return round(v, -exp + 1)
def money(v: float) -> str:
"""House money format, capped at two significant figures: $2.5M, $950K."""
sign, a = ("-" if v < 0 else ""), _round_2sf(abs(v))
if a >= 1e6:
m = a / 1e6
return f"{sign}${m:,.1f}M" if m < 10 else f"{sign}${m:,.0f}M"
if a >= 1e3:
return f"{sign}${a / 1e3:,.0f}K"
return f"{sign}${a:,.0f}"
def html_money(v: float) -> str:
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
annotations holding several amounts must use the HTML entity instead."""
return money(v).replace("$", "&#36;")
# ── Driver math (dispatch on kind) ───────────────────────────────────
def driver_value(driver: ValueDriver, baseline: OperationalBaseline) -> DriverValue:
"""Theoretical annual value range for one configured driver."""
if driver.kind == "containment_lift":
if driver.lift_range_pts_low is None or driver.lift_range_pts_high is None:
raise ValueError(f"driver {driver.id}: containment_lift needs lift_range_pts_low/high")
low = baseline.annual_contact_volume * driver.lift_range_pts_low \
* baseline.blended_cost_per_contact
high = baseline.annual_contact_volume * driver.lift_range_pts_high \
* baseline.blended_cost_per_contact
elif driver.kind == "aht_reduction":
# volume × (AHT × pct) seconds saved × ($/contact ÷ AHT) per second
# — the baseline AHT cancels: volume × $/contact × pct.
if driver.reduction_pct_low is None or driver.reduction_pct_high is None:
raise ValueError(f"driver {driver.id}: aht_reduction needs reduction_pct_low/high")
low = baseline.annual_contact_volume * baseline.blended_cost_per_contact \
* driver.reduction_pct_low
high = baseline.annual_contact_volume * baseline.blended_cost_per_contact \
* driver.reduction_pct_high
elif driver.kind == "attrition_reduction":
if driver.reduction_pct_low is None or driver.reduction_pct_high is None:
raise ValueError(f"driver {driver.id}: attrition_reduction needs reduction_pct_low/high")
cost_per_replacement = driver.cost_per_replacement_default or 0.0
low = baseline.agent_headcount * baseline.annual_attrition_rate \
* driver.reduction_pct_low * cost_per_replacement
high = baseline.agent_headcount * baseline.annual_attrition_rate \
* driver.reduction_pct_high * cost_per_replacement
else: # pragma: no cover — Literal already restricts kinds
raise ValueError(f"driver {driver.id}: unknown kind {driver.kind}")
return DriverValue(driver_id=driver.id, name=driver.name,
theoretical_low=low, theoretical_high=high)
# ── Capping ──────────────────────────────────────────────────────────
def weakest_foundational_score(config: DiagnosticConfig,
scores: list[CompetencyScore]) -> int:
by_id = {s.competency_id: s.score for s in scores}
missing = [c for c in config.foundational_competencies if c not in by_id]
if missing:
raise ValueError(f"foundational competencies unscored: {missing}")
return min(by_id[c] for c in config.foundational_competencies)
def binding_constraints(config: DiagnosticConfig,
scores: list[CompetencyScore]) -> list[str]:
"""Foundational competencies sitting at the weakest score, config order."""
weakest = weakest_foundational_score(config, scores)
by_id = {s.competency_id: s.score for s in scores}
return [c for c in config.foundational_competencies if by_id[c] == weakest]
# ── Unlock sequence (tier lifts of the binding set) ──────────────────
def _tier_move(config: DiagnosticConfig, level: int, members: list[str],
th_low: float, th_high: float) -> UnlockMove:
band_now = config.capping_heuristic[level]
band_next = config.capping_heuristic[level + 1]
costs = {m: config.lift_cost(m, level) for m in members}
missing = [m for m, c in costs.items() if c is None]
note = ""
if len(members) > 1:
note = "joint lift — the tied competencies must move together to shift the cap"
if missing:
note = (note + "; " if note else "") + \
f"cost not configured for: {', '.join(missing)}"
have_all = not missing
return UnlockMove(
competency_ids=members,
current_level=level,
target_level=level + 1,
est_cost_low=sum(c.cost_low for c in costs.values() if c) if have_all else None,
est_cost_high=sum(c.cost_high for c in costs.values() if c) if have_all else None,
est_weeks=max((c.weeks for c in costs.values() if c), default=None) if have_all else None,
value_unlocked_low=th_low * (band_next.realized_low - band_now.realized_low),
value_unlocked_high=th_high * (band_next.realized_high - band_now.realized_high),
note=note,
)
def unlock_sequence(config: DiagnosticConfig, scores: list[CompetencyScore],
th_low: float, th_high: float,
max_moves: int = MAX_UNLOCK_MOVES) -> list[UnlockMove]:
"""Up to ``max_moves`` sequential tier lifts of the binding set.
Each move lifts every foundational competency at the current weakest
level by one level (weeks = the longest workstream, run in parallel;
costs summed). Value unlocked is the annual realizable delta from the
capping-band shift. Moves stay in sequence order — each one is the
prerequisite of the next, so ranking them against each other would be
meaningless; the ratio walk (value/cost declining) is the story.
"""
if th_low == 0 and th_high == 0:
return []
current = {s.competency_id: s.score for s in scores
if s.competency_id in config.foundational_competencies}
moves: list[UnlockMove] = []
for _ in range(max_moves):
level = min(current.values())
if level >= 5:
break
members = [c for c in config.foundational_competencies
if current[c] == level]
moves.append(_tier_move(config, level, members, th_low, th_high))
for m in members:
current[m] = level + 1
return moves
# ── The full computation ─────────────────────────────────────────────
def value_at_stake(config: DiagnosticConfig, baseline: OperationalBaseline,
scores: list[CompetencyScore]) -> ValueAtStake:
"""Steps 18 of the build spec, as one call. See module docstring."""
drivers = [driver_value(d, baseline) for d in config.value_drivers]
th_low = sum(d.theoretical_low for d in drivers)
th_high = sum(d.theoretical_high for d in drivers)
weakest = weakest_foundational_score(config, scores)
band = config.capping_heuristic[weakest]
warnings: list[str] = []
if not config.value_drivers:
warnings.append(
f"config '{config.industry}' has no value drivers — "
"value-at-stake is zero (stub config)")
used_fields = sorted({d.baseline_field for d in config.value_drivers}
| ({"annual_contact_volume", "blended_cost_per_contact"}
if config.value_drivers else set()))
for f in used_fields:
if baseline.confidence_for(f) == "unknown":
warnings.append(
f"baseline input '{f}' is flagged 🔴 unknown — "
"the ranges below inherit that uncertainty")
return ValueAtStake(
theoretical_annual_value_low=th_low,
theoretical_annual_value_high=th_high,
realizable_18mo_low=th_low * band.realized_low * MONTHS_18,
realizable_18mo_high=th_high * band.realized_high * MONTHS_18,
trapped_value_low=th_low * (1 - band.realized_high),
trapped_value_high=th_high * (1 - band.realized_low),
binding_constraints=binding_constraints(config, scores),
unlock_sequence=unlock_sequence(config, scores, th_low, th_high),
weakest_foundational_score=weakest,
realization_factor_low=band.realized_low,
realization_factor_high=band.realized_high,
driver_values=drivers,
warnings=warnings,
)