feat: add master notebook library scaffolding and review tooling

Add CLAUDE.md defining the Palladium master notebook conventions and
Red Panda Approval criteria, plus a review-notebook slash command for
LLM-driven notebook review.

Expand .gitignore to block client/engagement documents and generated
exports, keeping masters client-clean while allowing text/image sources.

Normalize slider widget numeric values from floats to integers in
notebook JSON.
This commit is contained in:
2026-07-31 16:16:07 +00:00
parent 53c069fddb
commit a967f73d09
61 changed files with 4881 additions and 4257 deletions

View File

@@ -0,0 +1,62 @@
"""diaglib — the CX AI Advisory Diagnostic engine (Mercury Notebook Pattern).
All math and data contracts live here; the notebook only arranges and
renders. See docs/build_spec_v1.md for the instrument's specification.
"""
from .config import configs_dir, list_industries, load_config
from .export import (
CSV_COLUMNS,
engagement_json,
load_engagement,
scores_dataframe,
write_exports,
)
from .models import (
BASELINE_FIELDS,
CONFIDENCE_ICON,
Competency,
CompetencyScore,
DiagnosticConfig,
DriverValue,
Engagement,
OperationalBaseline,
Participant,
UnlockMove,
ValueAtStake,
)
from .scoring import (
build_engagement,
build_scores,
dimension_rollup,
evidence_coverage,
heatmap_grid,
make_engagement_id,
parse_participants,
)
from .staging import backstage, on_stage
from .value_math import (
MONTHS_18,
binding_constraints,
driver_value,
html_money,
money,
unlock_sequence,
value_at_stake,
weakest_foundational_score,
)
from .visuals import heatmap_fig, split_fig, unlock_fig, value_bands_fig
__all__ = [
"BASELINE_FIELDS", "CONFIDENCE_ICON", "CSV_COLUMNS", "MONTHS_18",
"Competency", "CompetencyScore", "DiagnosticConfig", "DriverValue",
"Engagement", "OperationalBaseline", "Participant", "UnlockMove",
"ValueAtStake", "backstage", "binding_constraints", "build_engagement",
"build_scores", "configs_dir", "dimension_rollup", "driver_value",
"engagement_json", "evidence_coverage", "heatmap_fig", "heatmap_grid",
"html_money", "list_industries", "load_config", "load_engagement",
"make_engagement_id", "money", "on_stage", "parse_participants",
"scores_dataframe", "split_fig", "unlock_fig", "unlock_sequence",
"value_at_stake", "value_bands_fig", "weakest_foundational_score",
"write_exports",
]

View File

@@ -0,0 +1,77 @@
"""Load and merge ``configs/*.yaml`` into a validated DiagnosticConfig.
``base.yaml`` holds the industry-independent competency model; every other
YAML in the directory is an industry overlay declaring ``extends: base``
plus its value drivers and unlock costs. Merging is shallow and explicit:
the overlay contributes industry identity, drivers, and costs; the base
contributes everything else. Overlays may not redefine the competency
model — one instrument, many industries.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from .models import DiagnosticConfig
#: Overlay keys an industry file may set. Anything else (competencies,
#: capping_heuristic, …) belongs in base.yaml and is rejected loudly.
_OVERLAY_KEYS = {"extends", "industry", "display_name", "value_drivers", "unlock_costs"}
def configs_dir(start: Path | None = None) -> Path:
"""The study's ``configs/`` directory, found from ``start`` (or CWD).
Walks up so it works from the study root, ``notebooks/``, or ``tests/``.
"""
here = (start or Path.cwd()).resolve()
for candidate in (here, *here.parents):
d = candidate / "configs"
if (d / "base.yaml").exists():
return d
raise FileNotFoundError("configs/base.yaml not found above " + str(here))
def list_industries(directory: Path | None = None) -> list[str]:
"""Industry config names (file stems), base excluded, sorted."""
d = directory or configs_dir()
return sorted(p.stem for p in d.glob("*.yaml") if p.stem != "base")
def _read_yaml(path: Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
raise ValueError(f"{path.name}: expected a mapping at top level")
return data
def load_config(industry: str, directory: Path | None = None) -> DiagnosticConfig:
"""Load ``base.yaml`` + the named industry overlay, validated."""
d = directory or configs_dir()
base = _read_yaml(d / "base.yaml")
overlay = _read_yaml(d / f"{industry}.yaml")
if overlay.get("extends") != "base":
raise ValueError(f"{industry}.yaml must declare 'extends: base'")
stray = set(overlay) - _OVERLAY_KEYS
if stray:
raise ValueError(
f"{industry}.yaml sets base-only keys {sorted(stray)}"
"the competency model lives in base.yaml")
merged: dict[str, Any] = {
"version": base["version"],
"dimensions": base["dimensions"],
"competencies": base["competencies"],
"capping_heuristic": base["capping_heuristic"],
"foundational_competencies": base["foundational_competencies"],
"industry": overlay["industry"],
"display_name": overlay.get("display_name", overlay["industry"]),
"value_drivers": overlay.get("value_drivers") or [],
"unlock_costs": overlay.get("unlock_costs") or {},
}
return DiagnosticConfig.model_validate(merged)

View File

@@ -0,0 +1,62 @@
"""Structured engagement exports — the JSON is the source-of-truth artifact.
``exports/{engagement_id}.json`` — the full Engagement, serialized.
``exports/{engagement_id}.csv`` — one row per competency, for
cross-engagement spreadsheet analysis (build spec §8).
"""
from __future__ import annotations
import json
from pathlib import Path
import pandas as pd
from .models import DiagnosticConfig, Engagement
CSV_COLUMNS = [
"engagement_id", "client_name", "industry", "workshop_date",
"competency_id", "dimension", "score", "evidence",
"is_foundational", "is_binding_constraint",
]
def engagement_json(engagement: Engagement) -> str:
return json.dumps(engagement.model_dump(mode="json"), indent=2,
ensure_ascii=False)
def scores_dataframe(engagement: Engagement,
config: DiagnosticConfig) -> pd.DataFrame:
binding = set(engagement.computed_value.binding_constraints
if engagement.computed_value else [])
foundational = set(config.foundational_competencies)
rows = [{
"engagement_id": engagement.engagement_id,
"client_name": engagement.client_name,
"industry": engagement.industry_config,
"workshop_date": engagement.workshop_date.isoformat(),
"competency_id": s.competency_id,
"dimension": s.dimension,
"score": s.score,
"evidence": s.evidence,
"is_foundational": s.competency_id in foundational,
"is_binding_constraint": s.competency_id in binding,
} for s in engagement.scores]
return pd.DataFrame(rows, columns=CSV_COLUMNS)
def write_exports(engagement: Engagement, config: DiagnosticConfig,
exports_dir: Path) -> tuple[Path, Path]:
"""Write both artifacts; returns ``(json_path, csv_path)``."""
exports_dir.mkdir(parents=True, exist_ok=True)
json_path = exports_dir / f"{engagement.engagement_id}.json"
csv_path = exports_dir / f"{engagement.engagement_id}.csv"
json_path.write_text(engagement_json(engagement), encoding="utf-8")
scores_dataframe(engagement, config).to_csv(csv_path, index=False)
return json_path, csv_path
def load_engagement(json_path: Path) -> Engagement:
"""Reload a saved engagement for review (acceptance §10 nice-to-have)."""
return Engagement.model_validate_json(json_path.read_text(encoding="utf-8"))

View File

@@ -0,0 +1,281 @@
"""Pydantic models — every data structure the diagnostic captures or computes.
Two families live here:
* **Engagement data** — what the workshop records (participants, baseline,
scores) and what the engine computes (:class:`ValueAtStake`). The
serialized :class:`Engagement` is the source-of-truth export artifact.
* **Config data** — the validated shape of ``configs/*.yaml``: the
competency model (base) and the industry overlay (drivers, unlock costs).
Deviations from the build spec (docs/build_spec_v1.md), both additive:
* :class:`UnlockMove` carries ``competency_ids`` (plural). When several
foundational competencies tie at the weakest level, the binding
constraint *is the set* — a single-competency move would honestly unlock
nothing. Moves are tier lifts of the whole binding set.
* :class:`ValueAtStake` also records the driver breakdown, the realization
band, and guard-rail warnings, so the export explains its own numbers.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
Function = Literal["cx", "it", "ops", "finance", "other"]
Confidence = Literal["known", "estimated", "unknown"]
CONFIDENCE_ICON: dict[str, str] = {
"known": "🟢", "estimated": "🟡", "unknown": "🔴",
}
#: The six numeric baseline fields collected in the workshop form.
BASELINE_FIELDS: tuple[str, ...] = (
"annual_contact_volume",
"blended_cost_per_contact",
"agent_headcount",
"annual_attrition_rate",
"current_containment_rate",
"average_handle_time_seconds",
)
# ── Engagement data ──────────────────────────────────────────────────
class Participant(BaseModel):
name: str
role: str = ""
function: Function = "other"
class OperationalBaseline(BaseModel):
"""Contact-center MVP fields; other industries overlay their own."""
annual_contact_volume: int = Field(ge=0)
blended_cost_per_contact: float = Field(ge=0)
agent_headcount: int = Field(ge=0)
annual_attrition_rate: float = Field(ge=0, le=1)
current_containment_rate: float = Field(ge=0, le=1)
average_handle_time_seconds: int = Field(ge=0)
csat_baseline: float | None = None
revenue_at_risk: float | None = None
#: Per-field confidence flags (🟢 known / 🟡 estimated / 🔴 unknown).
field_confidence: dict[str, Confidence] = Field(default_factory=dict)
@field_validator("field_confidence")
@classmethod
def _known_fields_only(cls, v: dict[str, Confidence]) -> dict[str, Confidence]:
unknown = set(v) - set(BASELINE_FIELDS)
if unknown:
raise ValueError(f"confidence flags for unknown fields: {sorted(unknown)}")
return v
def confidence_for(self, field: str) -> Confidence:
return self.field_confidence.get(field, "estimated")
class CompetencyScore(BaseModel):
competency_id: str
dimension: str
score: int = Field(ge=1, le=5)
evidence: str = "" # one line: why this score
scorer_role: str = "facilitator" # MVP: facilitator consensus
scored_at: datetime
class DriverValue(BaseModel):
"""One value driver's theoretical annual value, as a range."""
driver_id: str
name: str
theoretical_low: float
theoretical_high: float
class UnlockMove(BaseModel):
"""One move in the unlock sequence: lift the binding set one level.
``value_unlocked_*`` is the **annual run-rate** realizable value the
lift adds (delta of the realization band times theoretical value).
Costs are ``None`` when the config has no entry for a lift — shown as
"cost not configured", never guessed.
"""
competency_ids: list[str]
current_level: int = Field(ge=1, le=4)
target_level: int = Field(ge=2, le=5)
est_cost_low: float | None = None
est_cost_high: float | None = None
est_weeks: int | None = None
value_unlocked_low: float
value_unlocked_high: float
note: str = ""
@model_validator(mode="after")
def _one_level_lift(self) -> "UnlockMove":
if self.target_level != self.current_level + 1:
raise ValueError("unlock moves lift exactly one level")
return self
class ValueAtStake(BaseModel):
theoretical_annual_value_low: float
theoretical_annual_value_high: float
realizable_18mo_low: float
realizable_18mo_high: float
trapped_value_low: float # annual: theoretical realizable run-rate
trapped_value_high: float
binding_constraints: list[str] # competency_ids capping realization
unlock_sequence: list[UnlockMove]
# Self-explaining extras (additive to the build spec):
weakest_foundational_score: int = Field(ge=1, le=5)
realization_factor_low: float = Field(ge=0, le=1)
realization_factor_high: float = Field(ge=0, le=1)
driver_values: list[DriverValue] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
class Engagement(BaseModel):
engagement_id: str # e.g. "acme_2026-07-19"
client_name: str
industry_config: str # which config was loaded
facilitator: str
workshop_date: date
participants: list[Participant] = Field(default_factory=list)
operational_baseline: OperationalBaseline
scores: list[CompetencyScore] = Field(default_factory=list)
computed_value: ValueAtStake | None = None
notes: str = ""
# ── Config data (configs/*.yaml) ─────────────────────────────────────
class Dimension(BaseModel):
id: str
name: str
class Competency(BaseModel):
id: str
dimension: str
name: str
description: str
failure_vignette: str
level_descriptors: dict[int, str]
@field_validator("level_descriptors")
@classmethod
def _five_levels(cls, v: dict[int, str]) -> dict[int, str]:
if set(v) != {1, 2, 3, 4, 5}:
raise ValueError("level_descriptors must cover exactly levels 15")
return v
class CappingBand(BaseModel):
realized_low: float = Field(ge=0, le=1)
realized_high: float = Field(ge=0, le=1)
@model_validator(mode="after")
def _ordered(self) -> "CappingBand":
if self.realized_low > self.realized_high:
raise ValueError("realized_low > realized_high")
return self
class ValueDriver(BaseModel):
"""A configured value driver. ``kind`` selects the math in
value_math.py; ``value_formula`` documents it verbatim in exports."""
id: str
name: str
kind: Literal["containment_lift", "aht_reduction", "attrition_reduction"]
baseline_field: str
value_formula: str = ""
source: str = ""
# kind-specific parameters (validated in value_math dispatch):
lift_range_pts_low: float | None = None
lift_range_pts_high: float | None = None
reduction_pct_low: float | None = None
reduction_pct_high: float | None = None
cost_per_replacement_default: float | None = None
class LiftCost(BaseModel):
cost_low: float = Field(ge=0)
cost_high: float = Field(ge=0)
weeks: int = Field(ge=0)
@model_validator(mode="after")
def _ordered(self) -> "LiftCost":
if self.cost_low > self.cost_high:
raise ValueError("cost_low > cost_high")
return self
class DiagnosticConfig(BaseModel):
"""base.yaml merged with one industry overlay — what the engine consumes."""
version: str
industry: str
display_name: str
dimensions: list[Dimension]
competencies: list[Competency]
capping_heuristic: dict[int, CappingBand]
foundational_competencies: list[str]
value_drivers: list[ValueDriver] = Field(default_factory=list)
#: unlock_costs[competency_id]["lift_2_to_3"] -> LiftCost
unlock_costs: dict[str, dict[str, LiftCost]] = Field(default_factory=dict)
@model_validator(mode="after")
def _consistent(self) -> "DiagnosticConfig":
comp_ids = [c.id for c in self.competencies]
if len(comp_ids) != len(set(comp_ids)):
raise ValueError("duplicate competency ids")
dim_ids = {d.id for d in self.dimensions}
for c in self.competencies:
if c.dimension not in dim_ids:
raise ValueError(f"competency {c.id}: unknown dimension {c.dimension}")
missing = set(self.foundational_competencies) - set(comp_ids)
if missing:
raise ValueError(f"foundational competencies not defined: {sorted(missing)}")
if set(self.capping_heuristic) != {1, 2, 3, 4, 5}:
raise ValueError("capping_heuristic must cover exactly scores 15")
for lo, hi in zip(sorted(self.capping_heuristic), sorted(self.capping_heuristic)[1:]):
a, b = self.capping_heuristic[lo], self.capping_heuristic[hi]
if a.realized_low > b.realized_low or a.realized_high > b.realized_high:
raise ValueError("capping_heuristic must be non-decreasing in score")
for cid, lifts in self.unlock_costs.items():
if cid not in comp_ids:
raise ValueError(f"unlock_costs for unknown competency {cid}")
for key in lifts:
if not _valid_lift_key(key):
raise ValueError(f"unlock_costs[{cid}]: bad lift key {key!r}")
return self
def competency(self, competency_id: str) -> Competency:
for c in self.competencies:
if c.id == competency_id:
return c
raise KeyError(competency_id)
def dimension_name(self, dimension_id: str) -> str:
for d in self.dimensions:
if d.id == dimension_id:
return d.name
raise KeyError(dimension_id)
def lift_cost(self, competency_id: str, from_level: int) -> LiftCost | None:
return self.unlock_costs.get(competency_id, {}).get(
f"lift_{from_level}_to_{from_level + 1}")
def _valid_lift_key(key: str) -> bool:
parts = key.split("_") # "lift", a, "to", b — one-level lifts only
return (len(parts) == 4 and parts[0] == "lift" and parts[2] == "to"
and parts[1].isdigit() and parts[3].isdigit()
and int(parts[3]) == int(parts[1]) + 1 and 1 <= int(parts[1]) <= 4)

View File

@@ -0,0 +1,133 @@
"""Score aggregation, gap analysis, and engagement assembly.
Everything the notebook needs between raw widget values and the engine's
value math lives here — the notebook itself computes nothing.
"""
from __future__ import annotations
import re
from datetime import date, datetime
from typing import get_args
from .models import (
CompetencyScore,
DiagnosticConfig,
Engagement,
Function,
OperationalBaseline,
Participant,
ValueAtStake,
)
FUNCTIONS: tuple[str, ...] = get_args(Function)
# ── Engagement identity ──────────────────────────────────────────────
def make_engagement_id(client_name: str, workshop_date: date) -> str:
"""``"Acme Corp!" + 2026-07-19 -> "acme_corp_2026-07-19"``."""
slug = re.sub(r"[^a-z0-9]+", "_", client_name.lower()).strip("_") or "client"
return f"{slug}_{workshop_date.isoformat()}"
def parse_participants(text: str) -> list[Participant]:
"""Parse ``"Name | Role | function; Name | Role | function"``.
Forgiving by design — the facilitator types this live. Missing parts
default (role empty, function ``other``); unknown functions map to
``other`` rather than erroring mid-workshop.
"""
participants: list[Participant] = []
for entry in text.split(";"):
parts = [p.strip() for p in entry.split("|")]
if not parts or not parts[0]:
continue
function = parts[2].lower() if len(parts) > 2 else "other"
participants.append(Participant(
name=parts[0],
role=parts[1] if len(parts) > 1 else "",
function=function if function in FUNCTIONS else "other",
))
return participants
# ── Scores ───────────────────────────────────────────────────────────
def build_scores(config: DiagnosticConfig, raw: dict[str, tuple[int, str]],
scored_at: datetime,
scorer_role: str = "facilitator") -> list[CompetencyScore]:
"""``raw[competency_id] = (score, evidence)`` → validated scores, config order."""
missing = [c.id for c in config.competencies if c.id not in raw]
if missing:
raise ValueError(f"unscored competencies: {missing}")
return [
CompetencyScore(
competency_id=c.id, dimension=c.dimension,
score=raw[c.id][0], evidence=raw[c.id][1].strip(),
scorer_role=scorer_role, scored_at=scored_at,
)
for c in config.competencies
]
def dimension_rollup(config: DiagnosticConfig,
scores: list[CompetencyScore]) -> list[tuple[str, str, float]]:
"""``(dimension_id, dimension_name, mean score)`` per dimension, config order."""
by_dim: dict[str, list[int]] = {d.id: [] for d in config.dimensions}
for s in scores:
by_dim[s.dimension].append(s.score)
return [(d.id, d.name, sum(v) / len(v))
for d in config.dimensions if (v := by_dim[d.id])]
def evidence_coverage(scores: list[CompetencyScore]) -> tuple[int, int]:
"""``(scores with evidence captured, total scores)``."""
return sum(1 for s in scores if s.evidence), len(scores)
def heatmap_grid(config: DiagnosticConfig, scores: list[CompetencyScore]) -> dict:
"""Pure data for the 4×3 heatmap — rows are dimensions, three
competencies per row in config order. Returned as plain lists so the
visuals layer holds no logic."""
by_id = {s.competency_id: s for s in scores}
rows, z, text, hover = [], [], [], []
for d in config.dimensions:
comps = [c for c in config.competencies if c.dimension == d.id]
rows.append(d.name)
z.append([by_id[c.id].score for c in comps])
text.append([f"{by_id[c.id].score}<br>{c.name}" for c in comps])
hover.append([
f"<b>{c.name}</b> — level {by_id[c.id].score}<br>"
f"{c.level_descriptors[by_id[c.id].score]}<br>"
f"<i>{by_id[c.id].evidence or 'no evidence captured'}</i>"
for c in comps
])
return {"rows": rows, "z": z, "text": text, "hover": hover,
"cols": ["", "", ""]}
# ── Assembly ─────────────────────────────────────────────────────────
def build_engagement(*, config: DiagnosticConfig, client_name: str,
facilitator: str, workshop_date: date,
participants: list[Participant],
baseline: OperationalBaseline,
scores: list[CompetencyScore],
computed_value: ValueAtStake | None,
notes: str = "") -> Engagement:
return Engagement(
engagement_id=make_engagement_id(client_name, workshop_date),
client_name=client_name.strip() or "Unnamed client",
industry_config=config.industry,
facilitator=facilitator.strip(),
workshop_date=workshop_date,
participants=participants,
operational_baseline=baseline,
scores=scores,
computed_value=computed_value,
notes=notes,
)

View File

@@ -0,0 +1,29 @@
"""
Stage vs backstage — is this notebook render stakeholder-facing?
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
into the server process so the widget library can locate ``config.toml``
(see ``mercury/config.py``); every kernel that server spawns inherits it.
JupyterLab and nbconvert kernels don't have it. That makes the variable a
reliable signal for "the audience is looking" (the stage) versus an
analyst session or a headless export run (backstage).
Diagnostics routed through :func:`backstage` stay visible in JupyterLab
and land in the nbconvert exports (where the machine-readable appendix
must appear for LLM consumption) but never render in the Mercury app.
"""
from __future__ import annotations
import os
def on_stage() -> bool:
"""True when running under the Mercury app (stakeholder-facing)."""
return os.getenv("MERCURY_CONFIG_DIR") is not None
def backstage(*args, **kwargs) -> None:
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage():
print(*args, **kwargs)

View File

@@ -0,0 +1,228 @@
"""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,
)

View File

@@ -0,0 +1,208 @@
"""Plotly figure builders — presentation only, consuming engine outputs.
Chart chrome follows the house dataviz rules (see the repo dataviz
reference and docs/brand.md): recessive grid and axes, ink text tokens,
fixed entity→color assignments so a color means one thing across every
figure, 2px surface gaps between adjacent fills, selective direct labels,
one axis per chart. Room-facing: sized and typed to hold attention on a
shared screen, not for print.
"""
from __future__ import annotations
import plotly.graph_objects as go
from .models import DiagnosticConfig, ValueAtStake
from .value_math import html_money
# ── Chrome (dataviz reference palette, light surface) ────────────────
INK, INK2, MUTED = "#0b0b0b", "#52514e", "#898781"
SURFACE, GRID, BASELINE = "#fcfcfb", "#e1e0d9", "#c3c2b7"
FONT_STACK = 'system-ui, -apple-system, "Segoe UI", sans-serif'
# Fixed entity colors — color follows the entity across every figure.
THEORETICAL = "#9ec5f4" # light blue: the outer envelope
REALIZABLE = "#2a78d6" # blue: what capability can actually capture
TRAPPED = "#eda100" # amber: value the capability gap strands
COST = "#e34948" # red: unlock investment
UNLOCKED = "#1baf7a" # aqua-green: unlock payoff
# Diverging maturity scale, centered on level 3 — soft poles so ink text
# stays readable in every cell (two hues + neutral midpoint, never rainbow).
SCORE_SCALE = [
(0.0, "#ef8a76"), (0.5, "#f0efe9"), (1.0, "#57c993"),
]
def diag_layout(fig: go.Figure, title: str, subtitle: str | None = None,
height: int = 440) -> go.Figure:
t = f"<b>{title}</b>"
if subtitle:
t += f"<br><span style='font-size:12px;color:{MUTED}'>{subtitle}</span>"
fig.update_layout(
title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor="left"),
paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,
font=dict(family=FONT_STACK, size=13, color=INK2),
legend=dict(orientation="h", yanchor="top", y=-0.12, x=0,
font=dict(size=11, color=INK2)),
height=height, margin=dict(t=70, r=30, b=60, l=70),
)
return fig
# ── 1 · Capability heatmap (4 dimensions × 3 competencies) ───────────
def heatmap_fig(grid: dict) -> go.Figure:
"""``grid`` comes from scoring.heatmap_grid — pure data in."""
n_rows = len(grid["rows"])
fig = go.Figure(go.Heatmap(
z=grid["z"], text=grid["text"], customdata=grid["hover"],
x=list(range(len(grid["cols"]))), y=grid["rows"],
zmin=1, zmax=5, colorscale=SCORE_SCALE, showscale=False,
texttemplate="%{text}", textfont=dict(size=13, color=INK),
hovertemplate="%{customdata}<extra></extra>",
xgap=3, ygap=3,
))
fig.update_xaxes(visible=False)
fig.update_yaxes(autorange="reversed", tickfont=dict(size=13, color=INK2),
showgrid=False)
diag_layout(fig, "Capability heatmap",
"12 competencies, levels 15 · hover a cell for the evidence",
height=90 * n_rows + 120)
return fig
# ── 2 · Value at stake (theoretical vs realizable ranges) ────────────
def value_bands_fig(vas: ValueAtStake) -> go.Figure:
rows = [
("Theoretical value (annual)", vas.theoretical_annual_value_low,
vas.theoretical_annual_value_high, THEORETICAL),
("Realizable over 18 months", vas.realizable_18mo_low,
vas.realizable_18mo_high, REALIZABLE),
]
fig = go.Figure()
for label, low, high, color in rows:
fig.add_trace(go.Bar(
y=[label], x=[max(high - low, 1)], base=[low], orientation="h",
marker=dict(color=color, line=dict(width=2, color=SURFACE)),
showlegend=False,
hovertemplate=(f"{label}: {html_money(low)} {html_money(high)}"
"<extra></extra>"),
))
fig.add_annotation(x=high, y=label, xanchor="left", xshift=6,
text=f"{html_money(low)} {html_money(high)}",
showarrow=False, font=dict(size=12, color=INK))
fig.add_annotation(
xref="paper", yref="paper", x=0.02, y=-0.32, xanchor="left",
showarrow=False, align="left",
text=(f"Trapped by the capability gap: "
f"<b>{html_money(vas.trapped_value_low)} "
f"{html_money(vas.trapped_value_high)}</b> per year"),
font=dict(size=13, color=INK))
fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False,
tickfont=dict(color=MUTED), rangemode="tozero")
fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False,
autorange="reversed") # theoretical on top, then realizable
diag_layout(fig, "Value at stake",
"ranges, never points · realization capped by the weakest foundation",
height=300)
fig.update_layout(margin=dict(b=90), bargap=0.5)
return fig
# ── 3 · Realizable vs trapped split (scenario-consistent) ────────────
def split_fig(vas: ValueAtStake) -> go.Figure:
"""Each scenario bar splits its own theoretical total: realizable
run-rate vs trapped, at that scenario's realization factor."""
scenarios = [
("Conservative", vas.theoretical_annual_value_low, vas.realization_factor_low),
("Optimistic", vas.theoretical_annual_value_high, vas.realization_factor_high),
]
labels = [s[0] for s in scenarios]
realizable = [th * r for _, th, r in scenarios]
trapped = [th * (1 - r) for _, th, r in scenarios]
fig = go.Figure([
go.Bar(name="Realizable (annual run-rate)", y=labels, x=realizable,
orientation="h",
marker=dict(color=REALIZABLE, line=dict(width=2, color=SURFACE)),
text=[html_money(v) for v in realizable],
textposition="inside", insidetextfont=dict(color="#ffffff"),
hovertemplate="Realizable: %{x:$,.0f}<extra>%{y}</extra>"),
go.Bar(name="Trapped by capability gap", y=labels, x=trapped,
orientation="h",
marker=dict(color=TRAPPED, line=dict(width=2, color=SURFACE)),
text=[html_money(v) for v in trapped],
textposition="inside", insidetextfont=dict(color=INK),
hovertemplate="Trapped: %{x:$,.0f}<extra>%{y}</extra>"),
])
fig.update_layout(barmode="stack", bargap=0.5)
fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False,
tickfont=dict(color=MUTED))
fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False,
autorange="reversed") # conservative on top
diag_layout(fig, "Where the annual value goes",
"each scenario splits its own theoretical total", height=300)
fig.update_layout(legend=dict(traceorder="normal"))
return fig
# ── 4 · Unlock sequence (cost vs value per move) ─────────────────────
def _move_label(vas_move, config: DiagnosticConfig, idx: int) -> str:
"""Compact tick label — full competency names live in the hover and
in the on-stage moves table (long joint-lift names don't fit ticks)."""
if len(vas_move.competency_ids) == 1:
what = config.competency(vas_move.competency_ids[0]).name
else:
what = f"joint lift ×{len(vas_move.competency_ids)}"
return (f"<b>{idx} · {what}</b><br>"
f"level {vas_move.current_level}{vas_move.target_level}")
def unlock_fig(vas: ValueAtStake, config: DiagnosticConfig) -> go.Figure:
moves = vas.unlock_sequence
labels = [_move_label(m, config, i + 1) for i, m in enumerate(moves)]
names = [" + ".join(config.competency(c).name for c in m.competency_ids)
for m in moves]
fig = go.Figure()
fig.add_trace(go.Bar(
name="Investment (range)", x=labels,
y=[(m.est_cost_high - m.est_cost_low) if m.est_cost_low is not None else 0
for m in moves],
base=[m.est_cost_low if m.est_cost_low is not None else 0 for m in moves],
customdata=[[n, html_money(m.est_cost_low) + " " + html_money(m.est_cost_high)
if m.est_cost_low is not None else "not configured"]
for n, m in zip(names, moves)],
marker=dict(color=COST, line=dict(width=2, color=SURFACE)),
hovertemplate="%{customdata[0]}<br>Investment: %{customdata[1]}<extra></extra>",
))
fig.add_trace(go.Bar(
name="Annual value unlocked (range)", x=labels,
y=[m.value_unlocked_high - m.value_unlocked_low for m in moves],
base=[m.value_unlocked_low for m in moves],
customdata=[[n, html_money(m.value_unlocked_low) + " "
+ html_money(m.value_unlocked_high)]
for n, m in zip(names, moves)],
marker=dict(color=UNLOCKED, line=dict(width=2, color=SURFACE)),
hovertemplate="%{customdata[0]}<br>Unlocked: %{customdata[1]}<extra></extra>",
))
for i, m in enumerate(moves):
if m.est_cost_low is None:
fig.add_annotation(x=labels[i], y=0, yanchor="bottom",
text="cost not<br>configured", showarrow=False,
font=dict(size=11, color=MUTED))
fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.12)
fig.update_xaxes(tickfont=dict(size=12, color=INK2), showgrid=False,
tickangle=0)
fig.update_yaxes(tickformat="$~s", gridcolor=GRID, zerolinecolor=BASELINE,
tickfont=dict(color=MUTED))
diag_layout(fig, "Unlock sequence",
"sequential moves — each is the prerequisite of the next",
height=420)
return fig