Migrate Genesys CX Cloud TEI study to the pattern; retire Streamlit app
studies/202512_GenesysCX -> studies/202512_TEI_Genesys_CX_Cloud, rebuilt as pattern Variant 4 (TEI composite reproduction): - teicalc/ self-contained engine: Forrester's tables as the never-edited verbatim anchor (incl. the p.14 typo note and the $0 AI-token line), generic model/scenarios/staging carried over from the Amazon Connect study, ClientDrivers overlay (agents / weekly interactions / revenue, flat composite so no growth re-base) with ai_tokens_annual as a direct input for the token line the published study models at $0 - one deliverable notebook (business_case.ipynb): widget-pair sidebar drivers incl. the AI-token price, published-vs-overlay KPI columns, cash-flow/waterfall/scenario charts, verification gate, backstage JSON data appendix - gate + tests reproduce the published totals within $2: NPV $10.8M / ROI 266% (engine $10,783,466 / 265.79%; payback 3.3 months, not headlined in the PDF); 29 study tests, headless nbconvert green, stage simulation leak-free, exports carry the appendix - old Athena workflow (00_provision..04_export, config.py, seed_data.py, PALLADIUM_GENESYSCX_* keys, ATHENA_EXPECTED reconciliation) deleted; git history preserves it With the last legacy study migrated, the retirement lands too: - app/ (Streamlit UI) and core/notebook_helpers deleted; nothing else imported them - streamlit stripped from pyproject extras, requirements.txt, Makefile; .env.example reduced to the Athena keys; 00_setup.ipynb and core/bootstrap.py repointed at the pattern studies - root README reworked: self-contained studies + slim core/ Athena toolkit (tei_client, calculations, export, cli) All suites green: Genesys 29, Amazon Connect 27, CTM 55, template 7, root 58. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
56
studies/202512_TEI_Genesys_CX_Cloud/teicalc/__init__.py
Normal file
56
studies/202512_TEI_Genesys_CX_Cloud/teicalc/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
teicalc — self-contained engine for the Genesys CX Cloud TEI study
|
||||
(Forrester, December 2025). Mercury Notebook Pattern, Variant 4:
|
||||
verbatim composite anchor → published-totals gate → client overlay
|
||||
(including the AI-token line the published study left at $0).
|
||||
"""
|
||||
|
||||
from .anchor import ASSUMPTIONS, BENEFITS_VERBATIM, COSTS_VERBATIM, PUBLISHED
|
||||
from .model import (
|
||||
X_LABELS,
|
||||
YEAR_INDEX,
|
||||
YEARS,
|
||||
benefits_by_year,
|
||||
by_calendar,
|
||||
compute_summary,
|
||||
costs_by_year,
|
||||
discount_factor,
|
||||
html_money,
|
||||
initial_costs,
|
||||
money,
|
||||
month_label,
|
||||
npv,
|
||||
payback_label,
|
||||
payback_months,
|
||||
payback_years,
|
||||
present_value,
|
||||
risk_adjust_benefit,
|
||||
risk_adjust_cost,
|
||||
risk_adjusted_rows,
|
||||
roi_pct,
|
||||
)
|
||||
from .overlay import (
|
||||
BENEFIT_DRIVERS,
|
||||
COMPOSITE,
|
||||
COST_DRIVERS,
|
||||
ClientDrivers,
|
||||
overlay_rows,
|
||||
scale_factor,
|
||||
)
|
||||
from .scenarios import SCENARIOS, apply_scenario
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"ASSUMPTIONS", "BENEFITS_VERBATIM", "COSTS_VERBATIM", "PUBLISHED",
|
||||
"YEARS", "YEAR_INDEX", "X_LABELS",
|
||||
"by_calendar", "month_label",
|
||||
"discount_factor", "present_value", "npv", "roi_pct",
|
||||
"payback_years", "payback_months", "payback_label",
|
||||
"risk_adjust_benefit", "risk_adjust_cost", "risk_adjusted_rows",
|
||||
"benefits_by_year", "costs_by_year", "initial_costs",
|
||||
"compute_summary", "money", "html_money",
|
||||
"ClientDrivers", "COMPOSITE", "BENEFIT_DRIVERS", "COST_DRIVERS",
|
||||
"scale_factor", "overlay_rows",
|
||||
"SCENARIOS", "apply_scenario",
|
||||
]
|
||||
197
studies/202512_TEI_Genesys_CX_Cloud/teicalc/anchor.py
Normal file
197
studies/202512_TEI_Genesys_CX_Cloud/teicalc/anchor.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
The verbatim anchor — Forrester *The Total Economic Impact™ Of CX Cloud —
|
||||
Cost Savings And Business Benefits Enabled By Genesys And Salesforce*
|
||||
(December 2025, commissioned by Genesys and Salesforce).
|
||||
|
||||
VERBATIM, do not edit. These are Forrester's published composite-organization
|
||||
tables and financial summary, transplanted unchanged from the study PDF
|
||||
(``docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf``). Client personalization
|
||||
lives in :mod:`teicalc.overlay`; scenario stress lives in
|
||||
:mod:`teicalc.scenarios` — both deep-copy, neither mutates this record.
|
||||
|
||||
Rows keep Forrester's own year-index keys (``"1"``/``"2"``/``"3"``);
|
||||
:mod:`teicalc.model` maps them to calendar years (2026–2028). Values are
|
||||
*nominal* (pre-risk-adjustment); the risk factor is stored per row and
|
||||
applied by the model (benefits ×(1−rf), costs ×(1+rf), per the TEI
|
||||
methodology).
|
||||
|
||||
Two study-specific footnotes, preserved from the source review:
|
||||
|
||||
* The published Total Costs table (p.14) prints the implementation initial
|
||||
as $1,304,600, but the detail table, the cash-flow analysis, and the math
|
||||
(1,190,000 × 1.10) all give **$1,309,000** — the p.14 figure is a typo in
|
||||
the study.
|
||||
* ``genesys_ai_tokens`` is **not in the published study** — Forrester
|
||||
modeled $0 AI consumption even though benefits B (self-service uplift),
|
||||
C (agent efficiency), and D (agent assist upsell) all depend on AI
|
||||
capabilities that Genesys bills via AI Experience tokens. The row is
|
||||
anchored at $0 so the reproduction matches the published totals; client
|
||||
cases price it via the overlay's ``ai_tokens_annual`` driver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: 3-year nominal benefit cashflows — 🟢 published.
|
||||
BENEFITS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "legacy_retirement",
|
||||
"table": "benefits",
|
||||
"label": "Retirement of legacy systems with CX Cloud adoption",
|
||||
"category": "Cost Savings",
|
||||
"year_values": {"1": 680_000, "2": 930_000, "3": 930_000},
|
||||
"risk_adjustment": 0.05,
|
||||
"notes": (
|
||||
"PDF A1–A4. Telephony $250k Y1 ramping to $500k (legacy "
|
||||
"sunset completes mid-Y1) + WFM/recording/transcription apps "
|
||||
"$100k + reduced dev effort $230k (2,400 hrs @ $94) + reduced "
|
||||
"platform mgmt $100k (1,500 hrs @ $65). Risk adj 5%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "self_service_savings",
|
||||
"table": "benefits",
|
||||
"label": (
|
||||
"Cost savings from reallocated workers and avoided seasonal "
|
||||
"hires with increased customer self-service"
|
||||
),
|
||||
"category": "Productivity",
|
||||
"year_values": {"1": 2_329_600, "2": 2_329_600, "3": 2_329_600},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF B1–B8. Self-service completion 15%→25% on 80k weekly "
|
||||
"interactions → 8,000 deflected/week → 40 FTEs @ $58,240 "
|
||||
"fully burdened. Risk adj 15%. (PDF B7 formula cites B2 where "
|
||||
"the 12-min interaction length is meant; 40 FTEs is correct.)"
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "agent_efficiency",
|
||||
"table": "benefits",
|
||||
"label": "CX agent efficiency gains",
|
||||
"category": "Productivity",
|
||||
"year_values": {"1": 2_912_000, "2": 2_912_000, "3": 2_912_000},
|
||||
"risk_adjustment": 0.10,
|
||||
"notes": (
|
||||
"PDF C1–C6. MTTR 12→10 min on 60k agent-handled interactions "
|
||||
"per week → 104,000 hrs/yr @ $28 fully burdened. Risk adj 10%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "agent_assist_sales",
|
||||
"table": "benefits",
|
||||
"label": "Incremental sales from agent assist capabilities",
|
||||
"category": "Revenue",
|
||||
"year_values": {"1": 600_000, "2": 600_000, "3": 600_000},
|
||||
"risk_adjustment": 0.05,
|
||||
"notes": (
|
||||
"PDF D1–D3. $500M revenue impacted (20% of $2.5B) × 1.5% lift "
|
||||
"× 8% gross margin. Risk adj 5%."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
#: Costs include an ``initial`` (year-0, undiscounted) component for
|
||||
#: implementation. Cost risk adjustments are applied *upward*. 🟢 published
|
||||
#: (except the ``genesys_ai_tokens`` line — see the module docstring).
|
||||
COSTS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "cx_cloud_licenses",
|
||||
"table": "costs",
|
||||
"label": "CX Cloud solution costs (licenses)",
|
||||
"category": "Subscription",
|
||||
"initial": 0,
|
||||
"year_values": {"1": 840_000, "2": 840_000, "3": 840_000},
|
||||
"risk_adjustment": 0.05,
|
||||
"notes": (
|
||||
"PDF E1–E3. Genesys Cloud CX 2 $170/user/mo + Salesforce "
|
||||
"Voice $25/user/mo + connector $25/user/mo, 400 concurrent "
|
||||
"users, 20% contractual discount → $650k + $95k + $95k. "
|
||||
"Risk adj +5%. Seat licenses ONLY — AI consumption is a "
|
||||
"separate line (genesys_ai_tokens)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "implementation",
|
||||
"table": "costs",
|
||||
"label": "Implementation and deployment cost",
|
||||
"category": "Implementation",
|
||||
"initial": 1_190_000,
|
||||
"year_values": {"1": 0, "2": 0, "3": 0},
|
||||
"risk_adjustment": 0.10,
|
||||
"notes": (
|
||||
"PDF F1–F5. 10-week implementation: 20 FTEs @ $80/hr fully "
|
||||
"burdened ($640k) + $550k professional services. Risk adj "
|
||||
"+10% → $1,309,000 (the p.14 Total Costs table's $1,304,600 "
|
||||
"is a typo in the study)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "ongoing_management",
|
||||
"table": "costs",
|
||||
"label": "Ongoing management costs",
|
||||
"category": "Operations",
|
||||
"initial": 0,
|
||||
"year_values": {"1": 202_800, "2": 202_800, "3": 202_800},
|
||||
"risk_adjustment": 0.10,
|
||||
"notes": (
|
||||
"PDF G1–G3. 5 people @ 30% time (12 hrs/wk) @ $65/hr. "
|
||||
"Risk adj +10%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "genesys_ai_tokens",
|
||||
"table": "costs",
|
||||
"label": "Genesys AI Experience token consumption",
|
||||
"category": "Subscription",
|
||||
"initial": 0,
|
||||
"year_values": {"1": 0, "2": 0, "3": 0},
|
||||
"risk_adjustment": 0.0,
|
||||
"notes": (
|
||||
"NOT in the published study — Forrester modeled $0 AI "
|
||||
"consumption even though benefits B (self-service uplift), "
|
||||
"C (AI coaching/assist), and D (agent assist upsell) all "
|
||||
"depend on AI capabilities that Genesys bills via AI "
|
||||
"Experience tokens. Anchored at $0 to reproduce the published "
|
||||
"totals. For client cases, enter the negotiated annual token "
|
||||
"cost from the Genesys quote (the overlay's ai_tokens_annual "
|
||||
"driver) and document the quote details (token volume, unit "
|
||||
"price, tier)."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
#: Composite-organization drivers — 🟢 published (PDF "Composite Organization").
|
||||
ASSUMPTIONS: dict = {
|
||||
"annual_revenue": 2_500_000_000,
|
||||
"employees": 10_000,
|
||||
"agents_fte": 600,
|
||||
"concurrent_licenses": 400,
|
||||
"weekly_interactions": 80_000,
|
||||
"interaction_minutes": 12,
|
||||
"self_service_rate_before": 0.15,
|
||||
"self_service_rate_after": 0.25,
|
||||
"mttr_saved_minutes": 2,
|
||||
"agent_hourly_rate": 28,
|
||||
"agent_annual_salary": 58_240,
|
||||
"revenue_impacted": 500_000_000,
|
||||
"revenue_lift": 0.015,
|
||||
"gross_margin": 0.08,
|
||||
"discount_rate": 0.10,
|
||||
"analysis_years": 3,
|
||||
}
|
||||
|
||||
|
||||
#: The PDF's Financial Summary — the gate's reproduction target. 🟢 published.
|
||||
#: The engine reproduces these to within $2 (Forrester's own rounding).
|
||||
#: Forrester does not headline a payback for this study; the engine computes
|
||||
#: 3.3 months from the cash-flow table.
|
||||
PUBLISHED: dict = {
|
||||
"benefits_pv": 14_840_638,
|
||||
"costs_pv": 4_057_170,
|
||||
"npv": 10_783_468,
|
||||
"roi_pct": 266,
|
||||
"discount_rate": 0.10,
|
||||
"analysis_years": 3,
|
||||
}
|
||||
267
studies/202512_TEI_Genesys_CX_Cloud/teicalc/model.py
Normal file
267
studies/202512_TEI_Genesys_CX_Cloud/teicalc/model.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Finance engine — the single source of truth for every number in the notebook.
|
||||
|
||||
Transplanted from the retired shared ``core/calculations`` and
|
||||
``core/export/report_data.py`` so the study is self-contained (Mercury
|
||||
Notebook Pattern, Required §2/§7). Conventions match the Forrester TEI
|
||||
methodology:
|
||||
|
||||
* The *Initial* investment is **not** discounted — it occurs at time zero.
|
||||
* Year-N cash flows are discounted at the end of the year:
|
||||
``PV = CF_n / (1 + r) ** n``.
|
||||
* Benefits are risk-adjusted *down* (``×(1−rf)``), costs *up* (``×(1+rf)``).
|
||||
* Payback runs on risk-adjusted **undiscounted** flows (the PDF's
|
||||
"<6 months" uses the Cash Flow Analysis table's nominal RA rows).
|
||||
|
||||
Everything this module returns for display is keyed by **calendar year**
|
||||
(Forrester Year 1/2/3 → 2026/2027/2028); ``initial`` stays a Year-0 scalar
|
||||
and never appears inside a ``*_by_year`` dict.
|
||||
|
||||
This module is stdlib-only on purpose — the repo-root test suite imports it
|
||||
without the study's venv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable, Sequence
|
||||
from copy import deepcopy
|
||||
|
||||
# ── Timeline ─────────────────────────────────────────────────────────
|
||||
|
||||
YEARS: list[int] = [2026, 2027, 2028] # Forrester Year 1/2/3; window opens Jan 2026
|
||||
YEAR_INDEX: dict[int, int] = {y: i for i, y in enumerate(YEARS, start=1)}
|
||||
X_LABELS: list[str] = ["Initial"] + [str(y) for y in YEARS]
|
||||
|
||||
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
|
||||
|
||||
def month_label(m: int) -> str:
|
||||
"""Calendar label for a 1-indexed month from Jan of YEARS[0]."""
|
||||
return f"{_MONTHS[(m - 1) % 12]} {YEARS[0] + (m - 1) // 12}"
|
||||
|
||||
|
||||
def by_calendar(year_values: dict[str, float]) -> dict[int, float]:
|
||||
"""Map Forrester's ``{"1": v, …}`` year-index keys to calendar years."""
|
||||
return {YEARS[int(k) - 1]: float(v or 0) for k, v in year_values.items()}
|
||||
|
||||
|
||||
# ── Discounting primitives ───────────────────────────────────────────
|
||||
|
||||
|
||||
def discount_factor(year_index: int, discount_rate: float) -> float:
|
||||
"""``1 / (1 + r) ** n``. Year 0 → 1.0 (no discount)."""
|
||||
if year_index < 0:
|
||||
raise ValueError("year_index must be >= 0")
|
||||
return 1.0 / ((1.0 + discount_rate) ** year_index)
|
||||
|
||||
|
||||
def present_value(amount: float, year_index: int, discount_rate: float) -> float:
|
||||
"""Discount ``amount`` from end-of-year ``year_index`` to present."""
|
||||
return amount * discount_factor(year_index, discount_rate)
|
||||
|
||||
|
||||
def npv(cashflows: Iterable[float], discount_rate: float,
|
||||
initial: float = 0.0) -> float:
|
||||
"""``initial + Σ CF_n / (1 + r)^n`` — initial undiscounted (TEI)."""
|
||||
return initial + sum(
|
||||
present_value(float(cf), n, discount_rate)
|
||||
for n, cf in enumerate(cashflows, start=1)
|
||||
)
|
||||
|
||||
|
||||
def roi_pct(benefits_pv: float, costs_pv: float) -> float:
|
||||
"""``(Benefits − Costs) / Costs`` as a percentage; 0 when costs ≤ 0."""
|
||||
if costs_pv <= 0:
|
||||
return 0.0
|
||||
return (benefits_pv - costs_pv) / costs_pv * 100.0
|
||||
|
||||
|
||||
# ── Payback ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def payback_years(initial_cost: float,
|
||||
yearly_net: Sequence[float]) -> float | None:
|
||||
"""
|
||||
Years until cumulative net benefits cover the initial cost, with linear
|
||||
interpolation inside the crossing year. ``None`` if never reached.
|
||||
"""
|
||||
remaining = float(initial_cost)
|
||||
if remaining <= 0:
|
||||
return 0.0
|
||||
for i, cf in enumerate(yearly_net):
|
||||
cf = float(cf)
|
||||
if cf <= 0:
|
||||
remaining += -cf # a net-loss year widens the gap
|
||||
continue
|
||||
if cf >= remaining:
|
||||
return i + remaining / cf
|
||||
remaining -= cf
|
||||
return None
|
||||
|
||||
|
||||
def payback_months(initial_cost: float,
|
||||
yearly_net: Sequence[float]) -> float | None:
|
||||
"""Same as :func:`payback_years`, in months."""
|
||||
yrs = payback_years(initial_cost, yearly_net)
|
||||
return yrs * 12.0 if yrs is not None else None
|
||||
|
||||
|
||||
def payback_label(months: float | None) -> str:
|
||||
"""Human label: ``"0.7 months (~Jan 2026)"`` / ``"immediate"`` / ``"beyond 2028"``."""
|
||||
if months is None:
|
||||
return f"beyond {YEARS[-1]}"
|
||||
if months <= 0:
|
||||
return "immediate"
|
||||
return f"{months:.1f} months (~{month_label(max(1, math.ceil(months)))})"
|
||||
|
||||
|
||||
# ── Risk adjustment (TEI: benefits down, costs up) ───────────────────
|
||||
|
||||
|
||||
def risk_adjust_benefit(amount: float, risk_factor: float) -> float:
|
||||
"""``amount × (1 − rf)``, rf clamped to [0, 1]."""
|
||||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||||
return amount * (1.0 - rf)
|
||||
|
||||
|
||||
def risk_adjust_cost(amount: float, risk_factor: float) -> float:
|
||||
"""``amount × (1 + rf)``, rf clamped to [0, 1]."""
|
||||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||||
return amount * (1.0 + rf)
|
||||
|
||||
|
||||
def risk_adjusted_rows(rows: list[dict], table: str) -> list[dict]:
|
||||
"""Deep-copied rows with the per-row risk factor applied to every value."""
|
||||
adjust = risk_adjust_benefit if table == "benefits" else risk_adjust_cost
|
||||
out: list[dict] = []
|
||||
for raw in rows:
|
||||
row = deepcopy(raw)
|
||||
rf = float(row.get("risk_adjustment") or 0.0)
|
||||
row["year_values"] = {
|
||||
k: adjust(float(v or 0), rf) for k, v in row["year_values"].items()
|
||||
}
|
||||
if row.get("initial"):
|
||||
# Only costs carry an initial; TEI adjusts it upward like the years.
|
||||
row["initial"] = risk_adjust_cost(float(row["initial"]), rf) \
|
||||
if table == "costs" else float(row["initial"])
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
# ── Aggregation (calendar-keyed) ─────────────────────────────────────
|
||||
|
||||
|
||||
def _totals_by_year(ra_rows: list[dict]) -> dict[int, float]:
|
||||
totals = {y: 0.0 for y in YEARS}
|
||||
for row in ra_rows:
|
||||
for y, v in by_calendar(row["year_values"]).items():
|
||||
totals[y] += v
|
||||
return totals
|
||||
|
||||
|
||||
def benefits_by_year(rows: list[dict]) -> dict[int, float]:
|
||||
"""Risk-adjusted benefit totals per calendar year."""
|
||||
return _totals_by_year(risk_adjusted_rows(rows, "benefits"))
|
||||
|
||||
|
||||
def costs_by_year(rows: list[dict]) -> dict[int, float]:
|
||||
"""Risk-adjusted cost totals per calendar year (excludes ``initial``)."""
|
||||
return _totals_by_year(risk_adjusted_rows(rows, "costs"))
|
||||
|
||||
|
||||
def initial_costs(rows: list[dict]) -> float:
|
||||
"""Risk-adjusted Year-0 outlay (undiscounted)."""
|
||||
return sum(
|
||||
float(row.get("initial") or 0)
|
||||
for row in risk_adjusted_rows(rows, "costs")
|
||||
)
|
||||
|
||||
|
||||
# ── Composite summary ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_summary(benefits: list[dict], costs: list[dict],
|
||||
discount_rate: float = 0.10) -> dict:
|
||||
"""
|
||||
The full business-case readout for one set of value rows.
|
||||
|
||||
Returns KPIs (``benefits_pv``/``costs_pv``/``npv``/``roi_pct``/
|
||||
``payback_months``/``payback_label``/``initial_costs``/nominal totals),
|
||||
calendar-keyed schedules (``benefits_by_year``/``costs_by_year``/
|
||||
``net_by_year``/``cumulative_net_by_year`` — cumulative subtracts the
|
||||
initial outlay), and a per-row breakdown under ``rows``.
|
||||
"""
|
||||
ben_ra = risk_adjusted_rows(benefits, "benefits")
|
||||
cost_ra = risk_adjusted_rows(costs, "costs")
|
||||
|
||||
ben_by = _totals_by_year(ben_ra)
|
||||
cost_by = _totals_by_year(cost_ra)
|
||||
initial = sum(float(r.get("initial") or 0) for r in cost_ra)
|
||||
|
||||
benefits_pv = npv([ben_by[y] for y in YEARS], discount_rate)
|
||||
costs_pv = npv([cost_by[y] for y in YEARS], discount_rate, initial=initial)
|
||||
|
||||
net_by = {y: ben_by[y] - cost_by[y] for y in YEARS}
|
||||
cum, cum_by = -initial, {}
|
||||
for y in YEARS:
|
||||
cum += net_by[y]
|
||||
cum_by[y] = cum
|
||||
|
||||
pb_months = payback_months(initial, [net_by[y] for y in YEARS])
|
||||
|
||||
def _row_breakdown(ra_rows: list[dict], table: str) -> list[dict]:
|
||||
out = []
|
||||
for row in ra_rows:
|
||||
ra_by = by_calendar(row["year_values"])
|
||||
init_ra = float(row.get("initial") or 0)
|
||||
entry = {
|
||||
"field_key": row["field_key"],
|
||||
"label": row["label"],
|
||||
"category": row["category"],
|
||||
"risk_adjustment": row["risk_adjustment"],
|
||||
"ra_by_year": ra_by,
|
||||
"three_yr_ra": sum(ra_by.values()),
|
||||
"pv": npv([ra_by[y] for y in YEARS], discount_rate,
|
||||
initial=init_ra if table == "costs" else 0.0),
|
||||
}
|
||||
if table == "costs":
|
||||
entry["initial_ra"] = init_ra
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
return {
|
||||
"discount_rate": discount_rate,
|
||||
"benefits_pv": benefits_pv,
|
||||
"costs_pv": costs_pv,
|
||||
"npv": benefits_pv - costs_pv,
|
||||
"roi_pct": roi_pct(benefits_pv, costs_pv),
|
||||
"payback_months": pb_months,
|
||||
"payback_label": payback_label(pb_months),
|
||||
"initial_costs": initial,
|
||||
"benefits_nominal": sum(ben_by.values()),
|
||||
"costs_nominal": sum(cost_by.values()) + initial,
|
||||
"benefits_by_year": ben_by,
|
||||
"costs_by_year": cost_by,
|
||||
"net_by_year": net_by,
|
||||
"cumulative_net_by_year": cum_by,
|
||||
"rows": {
|
||||
"benefits": _row_breakdown(ben_ra, "benefits"),
|
||||
"costs": _row_breakdown(cost_ra, "costs"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Display helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def money(v: float) -> str:
|
||||
sign, a = ("-" if v < 0 else ""), abs(v)
|
||||
return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
|
||||
|
||||
|
||||
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("$", "$")
|
||||
107
studies/202512_TEI_Genesys_CX_Cloud/teicalc/overlay.py
Normal file
107
studies/202512_TEI_Genesys_CX_Cloud/teicalc/overlay.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Client overlay — Variant 4's personalization layer.
|
||||
|
||||
The verbatim anchor is Forrester's *composite organization* ($2.5B revenue,
|
||||
600 CX agents, 80k weekly interactions). This module rescales that composite
|
||||
to a client's size: a 🟡 **first-order linear rescale**, answering "what does
|
||||
the composite look like at your scale?", not "what is your TEI?".
|
||||
|
||||
Each verbatim row is tied to the driver that dominates its derivation in the
|
||||
PDF (see ``BENEFIT_DRIVERS``/``COST_DRIVERS``); rows scale linearly with
|
||||
their driver, project-based costs stay fixed. This composite's trajectory is
|
||||
flat (Y2 = Y3), so there is no growth re-base; linear scaling preserves the
|
||||
legacy-retirement ramp shape.
|
||||
|
||||
The one non-ratio driver is ``ai_tokens_annual``: the published study models
|
||||
**$0** Genesys AI Experience token consumption (see the anchor's footnote),
|
||||
so a client case prices that line directly — the negotiated annual figure
|
||||
from the Genesys quote replaces the row's year values outright.
|
||||
|
||||
``overlay_rows(COMPOSITE)`` is the identity — it reproduces the verbatim
|
||||
numbers exactly (tokens included, at $0), so headless widget defaults form
|
||||
the published-study reproduction the gate expects. The anchor is never
|
||||
mutated: every function deep-copies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .anchor import ASSUMPTIONS, BENEFITS_VERBATIM, COSTS_VERBATIM
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientDrivers:
|
||||
"""Client inputs; defaults are the Forrester composite (identity overlay)."""
|
||||
|
||||
agents_fte: int = ASSUMPTIONS["agents_fte"] # 600 (400 concurrent licenses)
|
||||
weekly_interactions: int = ASSUMPTIONS["weekly_interactions"] # 80,000 @ 12 min
|
||||
annual_revenue: float = ASSUMPTIONS["annual_revenue"] # $2.5B
|
||||
ai_tokens_annual: float = 0.0 # 🔴 published study models $0 AI consumption
|
||||
discount_rate: float = ASSUMPTIONS["discount_rate"] # 0.10
|
||||
|
||||
|
||||
COMPOSITE = ClientDrivers()
|
||||
|
||||
|
||||
#: 🟡 Which driver each verbatim row scales with, per its PDF derivation.
|
||||
BENEFIT_DRIVERS: dict[str, str] = {
|
||||
"legacy_retirement": "agents", # seat-scoped legacy platform costs
|
||||
"self_service_savings": "interactions", # deflected volume → FTEs
|
||||
"agent_efficiency": "interactions", # MTTR saving × handled volume
|
||||
"agent_assist_sales": "revenue", # 20% of revenue × lift × margin
|
||||
}
|
||||
COST_DRIVERS: dict[str, str] = {
|
||||
"cx_cloud_licenses": "agents", # 400 concurrent of 600 agents
|
||||
"implementation": "fixed", # 10-week project — does not scale
|
||||
"ongoing_management": "fixed", # small fixed team
|
||||
"genesys_ai_tokens": "ai_tokens", # 🔴 direct annual input, not a ratio
|
||||
}
|
||||
|
||||
|
||||
def scale_factor(driver: str, d: ClientDrivers) -> float:
|
||||
"""Linear size ratio vs the composite for one ratio-driver kind."""
|
||||
if driver == "agents":
|
||||
return d.agents_fte / ASSUMPTIONS["agents_fte"]
|
||||
if driver == "interactions":
|
||||
return d.weekly_interactions / ASSUMPTIONS["weekly_interactions"]
|
||||
if driver == "revenue":
|
||||
return d.annual_revenue / ASSUMPTIONS["annual_revenue"]
|
||||
if driver == "fixed":
|
||||
return 1.0
|
||||
raise KeyError(f"Unknown driver: {driver!r}")
|
||||
|
||||
|
||||
def overlay_rows(d: ClientDrivers = COMPOSITE) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Deep-copied (benefits, costs) rows rescaled to the client's drivers.
|
||||
|
||||
Ratio-driven rows: ``year_values[n] ×= scale_factor(driver)`` (and
|
||||
``initial`` likewise). Fixed rows are untouched. The ``ai_tokens`` row
|
||||
takes ``d.ai_tokens_annual`` as each year's value directly — the
|
||||
negotiated quote figure, not a rescale of the anchor's $0.
|
||||
Risk factors, labels, and notes are unchanged everywhere.
|
||||
"""
|
||||
|
||||
def _apply(rows: list[dict], drivers: dict[str, str]) -> list[dict]:
|
||||
out = []
|
||||
for raw in rows:
|
||||
row = deepcopy(raw)
|
||||
driver = drivers[row["field_key"]]
|
||||
if driver == "ai_tokens":
|
||||
row["year_values"] = {
|
||||
k: float(d.ai_tokens_annual) for k in row["year_values"]
|
||||
}
|
||||
elif driver != "fixed":
|
||||
s = scale_factor(driver, d)
|
||||
row["year_values"] = {
|
||||
k: float(v) * s for k, v in row["year_values"].items()
|
||||
}
|
||||
if row.get("initial"):
|
||||
row["initial"] = float(row["initial"]) * s
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
return (_apply(BENEFITS_VERBATIM, BENEFIT_DRIVERS),
|
||||
_apply(COSTS_VERBATIM, COST_DRIVERS))
|
||||
67
studies/202512_TEI_Genesys_CX_Cloud/teicalc/scenarios.py
Normal file
67
studies/202512_TEI_Genesys_CX_Cloud/teicalc/scenarios.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Scenario stress — transplanted from the retired shared ``core/calculations/scenarios.py``
|
||||
with identical semantics.
|
||||
|
||||
Forrester TEI risk-adjusts benefits *down* and costs *up*; scenarios stress
|
||||
both levers:
|
||||
|
||||
* ``adoption`` scales nominal values (``year_values`` and ``initial``).
|
||||
* ``risk_delta`` is *added* to a benefit's risk factor and *subtracted*
|
||||
from a cost's (conservative = more uncertainty on benefits, less padding
|
||||
on costs), then clamped to [0, 1].
|
||||
|
||||
``"moderate"`` is the identity — the headless default reproduces the
|
||||
published study. Note the counterintuitive corollary: the conservative
|
||||
scenario *lowers* costs PV, because 80% adoption shrinks consumption-priced
|
||||
usage and the clamp caps cost padding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
SCENARIOS: dict[str, dict[str, float]] = {
|
||||
"conservative": {"adoption": 0.80, "risk_delta": 0.10},
|
||||
"moderate": {"adoption": 1.00, "risk_delta": 0.00},
|
||||
"aggressive": {"adoption": 1.15, "risk_delta": -0.05},
|
||||
}
|
||||
|
||||
|
||||
def apply_scenario(
|
||||
items: list[dict],
|
||||
scenario: str = "moderate",
|
||||
*,
|
||||
multipliers: dict[str, dict[str, float]] | None = None,
|
||||
table: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Deep-copied value rows with the scenario applied; inputs are not mutated.
|
||||
|
||||
Each row needs ``year_values`` (year-string → float), optionally
|
||||
``initial`` and ``risk_adjustment``, and a ``table`` of ``"benefits"``
|
||||
or ``"costs"`` (or pass ``table=`` to force one) — the table decides the
|
||||
sign of ``risk_delta``.
|
||||
"""
|
||||
cfg = (multipliers or SCENARIOS).get(scenario)
|
||||
if cfg is None:
|
||||
raise KeyError(f"Unknown scenario: {scenario!r}")
|
||||
adoption = float(cfg.get("adoption", 1.0))
|
||||
risk_delta = float(cfg.get("risk_delta", 0.0))
|
||||
|
||||
out: list[dict] = []
|
||||
for raw in items:
|
||||
item = deepcopy(raw)
|
||||
item_table = item.get("table") or table or "benefits"
|
||||
item["table"] = item_table
|
||||
|
||||
item["year_values"] = {
|
||||
k: float(v) * adoption for k, v in item["year_values"].items()
|
||||
}
|
||||
if item.get("initial") is not None:
|
||||
item["initial"] = float(item["initial"]) * adoption
|
||||
|
||||
ra = float(item.get("risk_adjustment") or 0.0)
|
||||
new_ra = ra + risk_delta if item_table == "benefits" else ra - risk_delta
|
||||
item["risk_adjustment"] = max(0.0, min(1.0, new_ra))
|
||||
out.append(item)
|
||||
return out
|
||||
29
studies/202512_TEI_Genesys_CX_Cloud/teicalc/staging.py
Normal file
29
studies/202512_TEI_Genesys_CX_Cloud/teicalc/staging.py
Normal 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)
|
||||
Reference in New Issue
Block a user