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:
115
studies/202512_TEI_Genesys_CX_Cloud/README.md
Normal file
115
studies/202512_TEI_Genesys_CX_Cloud/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 202512 — Genesys CX Cloud TEI
|
||||
|
||||
Self-contained reproduction of Forrester's *The Total Economic Impact™ Of
|
||||
CX Cloud — Cost Savings And Business Benefits Enabled By Genesys And
|
||||
Salesforce* (December 2025, commissioned by Genesys and Salesforce), built
|
||||
on the [Mercury Notebook Deliverable Pattern](../../docs/Mercury_Notebook_Pattern_V1-00.md)
|
||||
as **Variant 4 — TEI composite reproduction**: Forrester's composite
|
||||
organization is the never-edited verbatim anchor, the in-notebook gate
|
||||
proves the engine reproduces the published totals, and 🟡 client drivers
|
||||
rescale the composite live.
|
||||
|
||||
## Source
|
||||
|
||||
The full Forrester study is at
|
||||
[`docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf`](docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf);
|
||||
[`docs/Genesys-Token-Metering.md`](docs/Genesys-Token-Metering.md) covers
|
||||
the AI Experience token pricing the study omits.
|
||||
|
||||
Published composite totals (3-yr risk-adjusted PV @ 10%), reproduced by
|
||||
`teicalc` to within $2:
|
||||
|
||||
| Metric | Published | Engine |
|
||||
|---|---|---|
|
||||
| Benefits PV | **$14,840,638** | $14,840,637 |
|
||||
| Costs PV | **$4,057,170** | $4,057,170 |
|
||||
| NPV | **$10,783,468** | $10,783,466 |
|
||||
| ROI | **266%** | 265.79% |
|
||||
| Payback | *not headlined* | 3.3 months |
|
||||
|
||||
## Composite organization (the verbatim anchor 🟢)
|
||||
|
||||
* Global supply company, $2.5B revenue, 10,000 employees
|
||||
* 600 CX agents (400 concurrent licenses)
|
||||
* 80,000 weekly interactions @ 12 minutes
|
||||
* Self-service completion 15% → 25%
|
||||
|
||||
## The $0 AI line (🔴)
|
||||
|
||||
The published study models **zero Genesys AI Experience token
|
||||
consumption**, even though the self-service (B), agent-efficiency (C), and
|
||||
agent-assist (D) benefits all depend on token-billed AI capabilities. The
|
||||
anchor keeps the $0 verbatim so the reproduction matches the PDF; the
|
||||
notebook exposes `ai_tokens_annual` as a direct 🔴 sidebar input — price it
|
||||
from the Genesys quote and the case re-derives live. (This critique is what
|
||||
grew into the CTM token-calculator engagement, `../202607_CTM_GenesysCX/`.)
|
||||
|
||||
## Client overlay (🟡)
|
||||
|
||||
A first-order linear rescale — "the composite at your size", not "your
|
||||
TEI". The composite's trajectory is flat (Y2 = Y3), so there is no growth
|
||||
re-base:
|
||||
|
||||
| Row | Driver | Confidence |
|
||||
|---|---|---|
|
||||
| Legacy retirement · CX Cloud licenses | agents | 🟡 |
|
||||
| Self-service savings · agent efficiency | interactions | 🟡 |
|
||||
| Agent-assist sales | revenue | 🟡 |
|
||||
| Implementation · ongoing management | fixed | 🟡 project-based |
|
||||
| Genesys AI tokens | direct $/yr input | 🔴 $0 until quoted |
|
||||
|
||||
## Study quirks (documented in the anchor, verbatim)
|
||||
|
||||
- p.14 prints the implementation initial as $1,304,600; the correct figure
|
||||
is $1,309,000 (= 1,190,000 × 1.10) per the detail table and cash-flow
|
||||
analysis.
|
||||
- B7's printed formula cites B2 (15%) where the 12-minute interaction
|
||||
length is meant; the result (40 FTEs) is correct.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
202512_TEI_Genesys_CX_Cloud/
|
||||
├── teicalc/ ← ALL math (stdlib-only) — notebooks hold none
|
||||
│ ├── anchor.py ← Forrester's tables, VERBATIM, never edited
|
||||
│ ├── model.py ← NPV/ROI/payback, risk adjustment, compute_summary
|
||||
│ ├── overlay.py ← ClientDrivers + driver map + the AI-token input
|
||||
│ ├── scenarios.py ← conservative / moderate / aggressive
|
||||
│ └── staging.py ← on_stage()/backstage() (Mercury vs nbconvert)
|
||||
├── notebooks/business_case.ipynb ← THE deliverable
|
||||
├── scripts/export_report.py
|
||||
├── tests/ ← hand-checked pinned acceptance numbers
|
||||
├── config.toml ← Mercury theme (NTT DATA brand)
|
||||
├── pyproject.toml ← full toolchain as core deps — no requirements.txt
|
||||
├── docs/ ← the Forrester PDF + token-metering notes
|
||||
└── exports/ ← generated .html/.md; gitignored
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| Tests | `pytest` |
|
||||
| Serve (the stage) | `mercury --working-dir notebooks/` (run from this project root so `config.toml` loads) |
|
||||
| Analyst view (backstage) | `jupyter lab` |
|
||||
| Headless check | `jupyter nbconvert --to notebook --execute --inplace notebooks/business_case.ipynb` |
|
||||
| Export for LLMs | `python scripts/export_report.py` |
|
||||
|
||||
The data appendix (markdown tables + JSON model state) rides inside
|
||||
`exports/business_case.md` — the payload for the Athena study-repository
|
||||
roadmap.
|
||||
|
||||
## History
|
||||
|
||||
This study previously ran on the shared `core/` package with an
|
||||
Athena-workflow notebook chain (provision → push → calculate) and
|
||||
study-scoped `PALLADIUM_GENESYSCX_*` env keys. That workflow — including
|
||||
the `ATHENA_EXPECTED` reconciliation for Athena's discount-initial-as-
|
||||
Year-1 convention — was retired when the study migrated to the pattern
|
||||
(git history preserves it); the engine reproduces the published totals
|
||||
locally under Forrester's own conventions, pinned in `tests/`.
|
||||
81
studies/202512_TEI_Genesys_CX_Cloud/config.toml
Normal file
81
studies/202512_TEI_Genesys_CX_Cloud/config.toml
Normal file
@@ -0,0 +1,81 @@
|
||||
# Mercury app-shell theme — NTT DATA brand (light), modern surfaces.
|
||||
# See docs/brand.md for the source palette.
|
||||
#
|
||||
# Loaded from the directory where you launch `mercury` (this project root);
|
||||
# restart the server to apply changes. Only keys in mercury/config.py
|
||||
# CSS_VARIABLE_MAP emit a CSS variable — anything else in DEFAULT_THEME is
|
||||
# either derived or component-baked (e.g. success/warning/danger, slider
|
||||
# track, widget bg) and silently no-ops here. Omitted keys are derived
|
||||
# from the ones below.
|
||||
|
||||
[main]
|
||||
title = "Genesys CX Cloud TEI — Business Case"
|
||||
favicon_emoji = "📊"
|
||||
footer = "Genesys CX Cloud TEI study (Forrester, Dec 2025)"
|
||||
notebooks_button_label = "Analyses"
|
||||
|
||||
[welcome]
|
||||
header = "Genesys CX Cloud TEI"
|
||||
message = """
|
||||
Interactive reproduction of Forrester's *Total Economic Impact™ Of CX
|
||||
Cloud* composite ($10.8M NPV · 266% ROI). The published study is the
|
||||
verbatim anchor — including the AI-token line it models at $0; tune the
|
||||
🟡 client drivers live (and price the tokens), then export the
|
||||
personalized report source with `python scripts/export_report.py`.
|
||||
"""
|
||||
|
||||
[theme]
|
||||
# ── Type — Georgia headings, Arial body. Both web-safe system fonts,
|
||||
# so no font_url / network fetch. Georgia ships only normal+bold, so
|
||||
# heading weight is 700 (the default 800 would render as faux-bold). ──
|
||||
font_family = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
|
||||
heading_font_family = "Georgia, 'Times New Roman', Times, serif"
|
||||
font_size = "15px"
|
||||
font_weight = "normal"
|
||||
heading_font_weight = "700"
|
||||
|
||||
# ── Text — NTT ink scale ──
|
||||
text_color = "#2e404d" # body
|
||||
muted_text_color = "#586671" # captions / secondary
|
||||
|
||||
# ── Surfaces — white content floating on a soft neutral canvas (depth).
|
||||
# For a strictly-white page instead, set background_color = "#ffffff". ──
|
||||
background_color = "#f4f5f6" # outer page
|
||||
content_background_color = "#ffffff" # notebook column
|
||||
surface_color = "#ffffff"
|
||||
card_background_color = "#f8f8f8" # brand card
|
||||
border_color = "#d5d9db" # brand border
|
||||
border_radius = "10px" # modern rounding
|
||||
|
||||
# ── Accents — Future Blue. primary_color also drives the Run button + focus. ──
|
||||
primary_color = "#0072bc"
|
||||
accent_color = "#0072bc"
|
||||
focus_border_color = "#0072bc"
|
||||
hover_background_color = "#eef5fb" # light blue tint
|
||||
selected_background_color = "#dcecfa"
|
||||
|
||||
# ── Sidebar — clean white, hairline divider ──
|
||||
sidebar_background_color = "#ffffff"
|
||||
sidebar_text_color = "#2e404d"
|
||||
sidebar_title_color = "#151d2c"
|
||||
sidebar_shadow = "1px 0 0 #d5d9db"
|
||||
|
||||
# ── Top bar — deep NTT navy (brand heading-primary) ──
|
||||
topbar_background_color = "#151d2c"
|
||||
topbar_text_color = "#ffffff"
|
||||
topbar_border_color = "rgba(255,255,255,0.08)"
|
||||
|
||||
# ── Footer ──
|
||||
footer_background_color = "#ffffff"
|
||||
footer_text_color = "#586671"
|
||||
footer_border_color = "#d5d9db"
|
||||
|
||||
# ── Run button — subtle brand-blue gradient (else derives from primary) ──
|
||||
run_button_background = "linear-gradient(180deg, #0087dc 0%, #0072bc 100%)"
|
||||
run_button_background_hover = "linear-gradient(180deg, #1a93e6 0%, #0079c8 100%)"
|
||||
run_button_text_color = "#ffffff"
|
||||
|
||||
# ── Depth — soft, navy-tinted shadows (modern) ──
|
||||
shadow_sm = "0 1px 2px rgba(21,29,44,0.05)"
|
||||
shadow_md = "0 6px 18px rgba(21,29,44,0.08)"
|
||||
shadow_lg = "0 16px 40px rgba(21,29,44,0.10)"
|
||||
@@ -0,0 +1,56 @@
|
||||
# Genesys Cloud AI Experience metering details
|
||||
2026-06-07
|
||||
https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
|
||||
This table describes the Genesys Cloud AI product’s raw meter and how many tokens are consumed.
|
||||
Note: Direct Messaging (DM) channels include inbound and outbound messages. Social Responses messages include outbound messages only.
|
||||
|
||||
Genesys Cloud product Units per token
|
||||
Bots (Voice) 17 minutes per token. For more information, see the AI section of the Genesys Cloud pricing hub.
|
||||
Bots (Digital) 51 sessions per token. For more information, see the AI section of the Genesys Cloud pricing hub.
|
||||
Virtual Agent 0.5 tokens per Virtual Agent interaction - An interaction is defined in accordance with existing Genesys Cloud token-based pricing.
|
||||
Agentic Virtual Agent 1.2 tokens per interaction - An interaction is defined in accordance with existing Genesys Cloud token-based pricing.
|
||||
Agent Copilot [concurrent] One user requires 60 tokens
|
||||
Agent Copilot [named] One user requires 40 tokens
|
||||
AI Scoring 20 evaluations scored with AI scoring per token
|
||||
AI Translate Two translations per token
|
||||
AI Summary and Insights
|
||||
50 AI summaries/insights per token. However, if you enable Agent Copilot simultaneously, then Supervisor Copilot summaries and insights do not consume tokens and instead rely on Agent Copilot functionality.
|
||||
Apple Messages for Business 400 inbound or outbound messages per token.
|
||||
Facebook Messenger† 400 inbound or outbound messages per token.
|
||||
Instagram Direct Messaging† 400 inbound or outbound messages per token.
|
||||
WhatsApp Messaging† 400 inbound or outbound messages per token.
|
||||
|
||||
Other charges apply for WhatsApp. For more information, see the Messaging section of the Genesys Cloud pricing hub.
|
||||
|
||||
X (formerly Twitter) Direct Messaging 400 inbound or outbound messages per token.
|
||||
|
||||
Other charges apply for X integrations. For more information, see the Messaging section of the Genesys Cloud pricing hub.
|
||||
|
||||
Genesys Cloud Social 400 social post ingestions per channel per token. For more information, see the Messaging section of the Genesys Cloud pricing hub.
|
||||
|
||||
Social Post Responses 400 outbound messages per channel per token.
|
||||
|
||||
Predictive Engagement No charge for token usage. For more information, see Can I use digital user tracking at no additional cost? and Predictive Engagement and digital user tracking.
|
||||
Predictive routing 17 routes per token. One token is consumed for every 17 interactions routed with predictive routing. For more information, see Predictive routing overview.
|
||||
Speech and Text Analytics [named] One user requires 30 tokens
|
||||
Speech and Text Analytics [concurrent] One user requires 45 tokens
|
||||
Genesys Cloud Copilot 20 AI actions per token, no charge for Genesys Cloud knowledge queries. For more information, see Genesys Cloud Copilot AI actions overview.
|
||||
|
||||
† For Facebook, Instagram, and WhatsApp: If the organization has not moved to the Genesys Cloud AI Experience token pricing, then legacy, conversation-based pricing applies. Other charges apply for X integrations. For more information, see Messaging in the Genesys Cloud pricing hub.
|
||||
|
||||
|
||||
## Virtual Agent interactions explained
|
||||
A single interaction is contained by a single billingID. A billingID represents a single interaction on any channel of any length. A single interaction is delimited by end interaction events. A single billingID can contain multiple end interaction events.
|
||||
|
||||
The following actions trigger end interaction events:
|
||||
|
||||
Exit action in flow (return calling flow)
|
||||
Disconnect action in the flow
|
||||
Disconnect when the participant hangs up the phone
|
||||
Disconnect via a Transfer to ACD action
|
||||
Exit or disconnect handling for an unexpected error
|
||||
Exit or disconnect handing for a recognition failure (continuous no matches or no inputs)
|
||||
Exit or disconnect handling for Max No Input override (if set, overrides recognition failure settings)
|
||||
Exit handling for agent escalation
|
||||
Digital expiry after inactivity (72-hour async timeout)
|
||||
When Genesys Cloud transfers interactions between inbound flows and Virtual Agent flows, the same billingID remains. When an action triggers an end interaction event and transfers no longer occur, then Genesys Cloud closes the billingID.
|
||||
Binary file not shown.
7133
studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb
Normal file
7133
studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb
Normal file
File diff suppressed because one or more lines are too long
36
studies/202512_TEI_Genesys_CX_Cloud/pyproject.toml
Normal file
36
studies/202512_TEI_Genesys_CX_Cloud/pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "teicalc"
|
||||
version = "0.1.0"
|
||||
description = "Genesys CX Cloud TEI (Forrester, Dec 2025) — composite reproduction + client overlay incl. the AI-token line"
|
||||
requires-python = ">=3.10"
|
||||
# The notebook is the deliverable (served with Mercury, exported via
|
||||
# nbconvert, tables via tabulate) — the whole toolchain is a required
|
||||
# runtime dependency, not an extra. `pip install -e .` must be enough.
|
||||
dependencies = [
|
||||
"pandas>=2.0",
|
||||
"plotly>=5.18",
|
||||
"openpyxl>=3.1",
|
||||
"mercury>=3.2",
|
||||
"jupyterlab>=4.0",
|
||||
"ipywidgets>=8.0",
|
||||
"nbconvert>=7",
|
||||
"tabulate>=0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7.4", "mypy>=1.8"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["teicalc*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
packages = ["teicalc"]
|
||||
47
studies/202512_TEI_Genesys_CX_Cloud/scripts/export_report.py
Normal file
47
studies/202512_TEI_Genesys_CX_Cloud/scripts/export_report.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Export the deliverable notebooks as LLM-readable report sources.
|
||||
|
||||
Executes each notebook fresh (widget defaults — or whatever defaults you edit in),
|
||||
then writes both formats to exports/:
|
||||
|
||||
exports/<notebook>.html — human-reviewable, tables render
|
||||
exports/<notebook>.md — leanest LLM input
|
||||
|
||||
Plotly figures export as JavaScript an LLM cannot read; each notebook's
|
||||
machine-readable appendix section carries every number behind them.
|
||||
|
||||
Run from the project root: python scripts/export_report.py [name-filter]
|
||||
An optional argument exports only notebooks whose filename contains it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NOTEBOOKS = [
|
||||
ROOT / "notebooks" / "business_case.ipynb",
|
||||
]
|
||||
EXPORTS = ROOT / "exports"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
picked = [nb for nb in NOTEBOOKS
|
||||
if len(sys.argv) < 2 or sys.argv[1] in nb.name]
|
||||
if not picked:
|
||||
sys.exit(f"no notebook matches {sys.argv[1]!r}")
|
||||
EXPORTS.mkdir(exist_ok=True)
|
||||
for nb in picked:
|
||||
for fmt in ("html", "markdown"):
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "nbconvert", "--execute",
|
||||
"--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
|
||||
check=True, cwd=ROOT,
|
||||
)
|
||||
for p in sorted(EXPORTS.iterdir()):
|
||||
if p.suffix in (".html", ".md"):
|
||||
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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)
|
||||
7
studies/202512_TEI_Genesys_CX_Cloud/tests/conftest.py
Normal file
7
studies/202512_TEI_Genesys_CX_Cloud/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Make teicalc importable even without the study venv active (the normal
|
||||
setup is ``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||
104
studies/202512_TEI_Genesys_CX_Cloud/tests/test_anchor.py
Normal file
104
studies/202512_TEI_Genesys_CX_Cloud/tests/test_anchor.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""The verbatim anchor is Forrester's published record — pinned value by
|
||||
value, and proven immutable under every engine code path."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from teicalc import (
|
||||
ASSUMPTIONS,
|
||||
BENEFITS_VERBATIM,
|
||||
COMPOSITE,
|
||||
COSTS_VERBATIM,
|
||||
PUBLISHED,
|
||||
ClientDrivers,
|
||||
apply_scenario,
|
||||
compute_summary,
|
||||
overlay_rows,
|
||||
)
|
||||
|
||||
|
||||
def _row(rows, key):
|
||||
return next(r for r in rows if r["field_key"] == key)
|
||||
|
||||
|
||||
def test_benefit_rows_verbatim():
|
||||
assert [r["field_key"] for r in BENEFITS_VERBATIM] == [
|
||||
"legacy_retirement",
|
||||
"self_service_savings",
|
||||
"agent_efficiency",
|
||||
"agent_assist_sales",
|
||||
]
|
||||
expected = {
|
||||
"legacy_retirement": ({"1": 680_000, "2": 930_000, "3": 930_000}, 0.05),
|
||||
"self_service_savings": ({"1": 2_329_600, "2": 2_329_600, "3": 2_329_600}, 0.15),
|
||||
"agent_efficiency": ({"1": 2_912_000, "2": 2_912_000, "3": 2_912_000}, 0.10),
|
||||
"agent_assist_sales": ({"1": 600_000, "2": 600_000, "3": 600_000}, 0.05),
|
||||
}
|
||||
for key, (years, rf) in expected.items():
|
||||
row = _row(BENEFITS_VERBATIM, key)
|
||||
assert row["year_values"] == years
|
||||
assert row["risk_adjustment"] == rf
|
||||
assert row["table"] == "benefits"
|
||||
|
||||
|
||||
def test_cost_rows_verbatim():
|
||||
expected = {
|
||||
"cx_cloud_licenses": ({"1": 840_000, "2": 840_000, "3": 840_000}, 0.05, 0),
|
||||
"implementation": ({"1": 0, "2": 0, "3": 0}, 0.10, 1_190_000),
|
||||
"ongoing_management": ({"1": 202_800, "2": 202_800, "3": 202_800}, 0.10, 0),
|
||||
"genesys_ai_tokens": ({"1": 0, "2": 0, "3": 0}, 0.0, 0),
|
||||
}
|
||||
for key, (years, rf, initial) in expected.items():
|
||||
row = _row(COSTS_VERBATIM, key)
|
||||
assert row["year_values"] == years
|
||||
assert row["risk_adjustment"] == rf
|
||||
assert row["initial"] == initial
|
||||
assert row["table"] == "costs"
|
||||
|
||||
|
||||
def test_ai_token_line_is_anchored_at_zero():
|
||||
"""The published study models $0 AI consumption — the study's blind spot,
|
||||
preserved verbatim so the reproduction matches the published totals."""
|
||||
row = _row(COSTS_VERBATIM, "genesys_ai_tokens")
|
||||
assert all(v == 0 for v in row["year_values"].values())
|
||||
assert row["initial"] == 0 and row["risk_adjustment"] == 0.0
|
||||
assert "NOT in the published study" in row["notes"]
|
||||
|
||||
|
||||
def test_assumptions_and_published():
|
||||
assert ASSUMPTIONS["annual_revenue"] == 2_500_000_000
|
||||
assert ASSUMPTIONS["agents_fte"] == 600
|
||||
assert ASSUMPTIONS["concurrent_licenses"] == 400
|
||||
assert ASSUMPTIONS["weekly_interactions"] == 80_000
|
||||
assert ASSUMPTIONS["discount_rate"] == 0.10
|
||||
assert ASSUMPTIONS["analysis_years"] == 3
|
||||
|
||||
assert PUBLISHED["benefits_pv"] == 14_840_638
|
||||
assert PUBLISHED["costs_pv"] == 4_057_170
|
||||
assert PUBLISHED["npv"] == 10_783_468
|
||||
assert PUBLISHED["roi_pct"] == 266
|
||||
assert "payback" not in str(sorted(PUBLISHED)) # study doesn't headline one
|
||||
|
||||
# The composite drivers ARE the anchor assumptions (tokens at $0).
|
||||
assert COMPOSITE.agents_fte == ASSUMPTIONS["agents_fte"]
|
||||
assert COMPOSITE.weekly_interactions == ASSUMPTIONS["weekly_interactions"]
|
||||
assert COMPOSITE.annual_revenue == ASSUMPTIONS["annual_revenue"]
|
||||
assert COMPOSITE.ai_tokens_annual == 0.0
|
||||
assert COMPOSITE.discount_rate == ASSUMPTIONS["discount_rate"]
|
||||
|
||||
|
||||
def test_anchor_is_never_mutated():
|
||||
"""Exercise every engine code path, then prove the record unchanged."""
|
||||
ben_snap = deepcopy(BENEFITS_VERBATIM)
|
||||
cost_snap = deepcopy(COSTS_VERBATIM)
|
||||
|
||||
overlay_rows()
|
||||
overlay_rows(ClientDrivers(agents_fte=137, weekly_interactions=5_000,
|
||||
annual_revenue=9e9, ai_tokens_annual=450_000))
|
||||
for scenario in ("conservative", "moderate", "aggressive"):
|
||||
apply_scenario(BENEFITS_VERBATIM, scenario)
|
||||
apply_scenario(COSTS_VERBATIM, scenario)
|
||||
compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.08)
|
||||
|
||||
assert BENEFITS_VERBATIM == ben_snap
|
||||
assert COSTS_VERBATIM == cost_snap
|
||||
122
studies/202512_TEI_Genesys_CX_Cloud/tests/test_model.py
Normal file
122
studies/202512_TEI_Genesys_CX_Cloud/tests/test_model.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Engine pins — every number hand-checked before pinning.
|
||||
|
||||
RA_benefit = v×(1−rf), RA_cost = v×(1+rf), PV = Σ RA_n/(1.1)^n, initial
|
||||
undiscounted. The composite reproduction lands within $2 of the published
|
||||
Financial Summary (benefits PV $1.19 low, costs PV $0.40 high) — pinned
|
||||
both engine-exact (±$1) and against PUBLISHED (±$5). Forrester does not
|
||||
headline a payback for this study; the engine computes 3.3 months.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFITS_VERBATIM,
|
||||
COSTS_VERBATIM,
|
||||
PUBLISHED,
|
||||
X_LABELS,
|
||||
YEAR_INDEX,
|
||||
YEARS,
|
||||
by_calendar,
|
||||
compute_summary,
|
||||
discount_factor,
|
||||
money,
|
||||
npv,
|
||||
payback_label,
|
||||
payback_months,
|
||||
payback_years,
|
||||
roi_pct,
|
||||
)
|
||||
|
||||
# Hand-checked risk-adjusted PVs per row (see module docstring).
|
||||
ROW_PVS = {
|
||||
"legacy_retirement": 1_981_224.64,
|
||||
"self_service_savings": 4_924_364.84,
|
||||
"agent_efficiency": 6_517_541.70,
|
||||
"agent_assist_sales": 1_417_505.63,
|
||||
"cx_cloud_licenses": 2_193_403.46,
|
||||
"implementation": 1_309_000.00,
|
||||
"ongoing_management": 554_766.94,
|
||||
"genesys_ai_tokens": 0.00,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def composite():
|
||||
return compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
|
||||
|
||||
def test_calendar_mapping():
|
||||
assert YEARS == [2026, 2027, 2028]
|
||||
assert YEAR_INDEX == {2026: 1, 2027: 2, 2028: 3}
|
||||
assert X_LABELS == ["Initial", "2026", "2027", "2028"]
|
||||
assert by_calendar({"1": 10, "2": 20, "3": 30}) == {2026: 10, 2027: 20, 2028: 30}
|
||||
|
||||
|
||||
def test_primitives():
|
||||
assert discount_factor(0, 0.10) == 1.0
|
||||
assert discount_factor(1, 0.10) == pytest.approx(1 / 1.1)
|
||||
assert npv([110], 0.10) == pytest.approx(100)
|
||||
assert npv([110], 0.10, initial=-50) == pytest.approx(50)
|
||||
assert roi_pct(14_840_638, 4_057_170) == pytest.approx(265.79, abs=0.1)
|
||||
assert roi_pct(100, 0) == 0.0
|
||||
assert money(10_783_466) == "$10.8M"
|
||||
assert money(-250_000) == "-$250K"
|
||||
|
||||
|
||||
def test_payback_edges():
|
||||
assert payback_years(0, [100]) == 0.0
|
||||
assert payback_years(500, []) is None
|
||||
assert payback_years(500, [-100, 200]) is None
|
||||
assert payback_years(300, [-100, 400]) == pytest.approx(2.0)
|
||||
assert payback_months(100, [1_200]) == pytest.approx(1.0)
|
||||
assert payback_label(None) == "beyond 2028"
|
||||
assert payback_label(0.0) == "immediate"
|
||||
assert payback_label(3.3337) == "3.3 months (~Apr 2026)"
|
||||
assert payback_label(14.2) == "14.2 months (~Mar 2027)"
|
||||
|
||||
|
||||
def test_per_row_pvs(composite):
|
||||
rows = composite["rows"]["benefits"] + composite["rows"]["costs"]
|
||||
assert len(rows) == 8
|
||||
for row in rows:
|
||||
assert row["pv"] == pytest.approx(ROW_PVS[row["field_key"]], abs=1)
|
||||
|
||||
|
||||
def test_composite_totals_engine_exact(composite):
|
||||
assert composite["benefits_pv"] == pytest.approx(14_840_636.81, abs=1)
|
||||
assert composite["costs_pv"] == pytest.approx(4_057_170.40, abs=1)
|
||||
assert composite["npv"] == pytest.approx(10_783_466.42, abs=1)
|
||||
assert composite["roi_pct"] == pytest.approx(265.7879, abs=0.01)
|
||||
assert composite["payback_months"] == pytest.approx(3.3337, abs=0.001)
|
||||
assert composite["initial_costs"] == pytest.approx(1_309_000, abs=0.01)
|
||||
|
||||
|
||||
def test_composite_reproduces_published(composite):
|
||||
assert composite["benefits_pv"] == pytest.approx(PUBLISHED["benefits_pv"], abs=5)
|
||||
assert composite["costs_pv"] == pytest.approx(PUBLISHED["costs_pv"], abs=5)
|
||||
assert composite["npv"] == pytest.approx(PUBLISHED["npv"], abs=5)
|
||||
assert round(composite["roi_pct"]) == PUBLISHED["roi_pct"]
|
||||
assert composite["payback_label"] == "3.3 months (~Apr 2026)"
|
||||
|
||||
|
||||
def test_yearly_schedules(composite):
|
||||
assert composite["benefits_by_year"][2026] == pytest.approx(5_816_960.00, abs=0.01)
|
||||
assert composite["benefits_by_year"][2027] == pytest.approx(6_054_460.00, abs=0.01)
|
||||
assert composite["benefits_by_year"][2028] == pytest.approx(6_054_460.00, abs=0.01)
|
||||
for y in YEARS:
|
||||
assert composite["costs_by_year"][y] == pytest.approx(1_105_080.00, abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2026] == pytest.approx(3_402_880.00, abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2028] == pytest.approx(13_301_640.00, abs=0.01)
|
||||
|
||||
|
||||
def test_cross_foots(composite):
|
||||
assert composite["npv"] == pytest.approx(
|
||||
composite["benefits_pv"] - composite["costs_pv"], abs=0.01)
|
||||
for y in YEARS:
|
||||
assert composite["net_by_year"][y] == pytest.approx(
|
||||
composite["benefits_by_year"][y] - composite["costs_by_year"][y], abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2028] == pytest.approx(
|
||||
sum(composite["net_by_year"].values()) - composite["initial_costs"], abs=0.01)
|
||||
for table, total in (("benefits", "benefits_pv"), ("costs", "costs_pv")):
|
||||
assert sum(r["pv"] for r in composite["rows"][table]) == pytest.approx(
|
||||
composite[total], abs=0.01)
|
||||
95
studies/202512_TEI_Genesys_CX_Cloud/tests/test_overlay.py
Normal file
95
studies/202512_TEI_Genesys_CX_Cloud/tests/test_overlay.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Client-overlay pins — identity at the composite, linear per-driver
|
||||
scaling, the direct AI-token input, and copy semantics."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFIT_DRIVERS,
|
||||
BENEFITS_VERBATIM,
|
||||
COMPOSITE,
|
||||
COST_DRIVERS,
|
||||
COSTS_VERBATIM,
|
||||
ClientDrivers,
|
||||
compute_summary,
|
||||
overlay_rows,
|
||||
scale_factor,
|
||||
)
|
||||
|
||||
|
||||
def _row(rows, key):
|
||||
return next(r for r in rows if r["field_key"] == key)
|
||||
|
||||
|
||||
def test_identity_at_composite():
|
||||
"""overlay_rows(COMPOSITE) reproduces the verbatim study to the cent."""
|
||||
ob, oc = overlay_rows(COMPOSITE)
|
||||
got = compute_summary(ob, oc, 0.10)
|
||||
want = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
assert got["benefits_pv"] == pytest.approx(want["benefits_pv"], abs=0.01)
|
||||
assert got["costs_pv"] == pytest.approx(want["costs_pv"], abs=0.01)
|
||||
assert got["npv"] == pytest.approx(want["npv"], abs=0.01)
|
||||
|
||||
|
||||
def test_driver_map_covers_every_row():
|
||||
assert set(BENEFIT_DRIVERS) == {r["field_key"] for r in BENEFITS_VERBATIM}
|
||||
assert set(COST_DRIVERS) == {r["field_key"] for r in COSTS_VERBATIM}
|
||||
|
||||
|
||||
def test_scale_factor():
|
||||
d = ClientDrivers(agents_fte=300, weekly_interactions=160_000,
|
||||
annual_revenue=5_000_000_000)
|
||||
assert scale_factor("agents", d) == pytest.approx(0.5)
|
||||
assert scale_factor("interactions", d) == pytest.approx(2.0)
|
||||
assert scale_factor("revenue", d) == pytest.approx(2.0)
|
||||
assert scale_factor("fixed", d) == 1.0
|
||||
with pytest.raises(KeyError):
|
||||
scale_factor("contacts", d)
|
||||
|
||||
|
||||
def test_half_agents_halves_agent_rows_only():
|
||||
ob, oc = overlay_rows(ClientDrivers(agents_fte=300))
|
||||
assert _row(ob, "legacy_retirement")["year_values"]["1"] == pytest.approx(340_000)
|
||||
assert _row(oc, "cx_cloud_licenses")["year_values"]["1"] == pytest.approx(420_000)
|
||||
# Interaction-, revenue-driven, and fixed rows unmoved.
|
||||
assert _row(ob, "self_service_savings")["year_values"]["1"] == pytest.approx(2_329_600)
|
||||
assert _row(ob, "agent_assist_sales")["year_values"]["1"] == pytest.approx(600_000)
|
||||
assert _row(oc, "implementation")["initial"] == 1_190_000
|
||||
|
||||
|
||||
def test_double_interactions_doubles_volume_rows_only():
|
||||
ob, oc = overlay_rows(ClientDrivers(weekly_interactions=160_000))
|
||||
assert _row(ob, "self_service_savings")["year_values"]["1"] == pytest.approx(4_659_200)
|
||||
assert _row(ob, "agent_efficiency")["year_values"]["1"] == pytest.approx(5_824_000)
|
||||
assert _row(ob, "legacy_retirement")["year_values"]["1"] == pytest.approx(680_000)
|
||||
assert _row(oc, "cx_cloud_licenses")["year_values"]["1"] == pytest.approx(840_000)
|
||||
|
||||
|
||||
def test_double_revenue_doubles_agent_assist_only():
|
||||
ob, _ = overlay_rows(ClientDrivers(annual_revenue=5_000_000_000))
|
||||
assert _row(ob, "agent_assist_sales")["year_values"]["1"] == pytest.approx(1_200_000)
|
||||
assert _row(ob, "self_service_savings")["year_values"]["1"] == pytest.approx(2_329_600)
|
||||
|
||||
|
||||
def test_ai_tokens_direct_input():
|
||||
"""The token line takes the negotiated annual figure directly (rf 0.0),
|
||||
adding annual × Σ1/1.1ⁿ = 250,000 × 2.48685… ≈ $621,713 to costs PV."""
|
||||
_, oc = overlay_rows(ClientDrivers(ai_tokens_annual=250_000))
|
||||
tokens = _row(oc, "genesys_ai_tokens")
|
||||
assert tokens["year_values"] == {"1": 250_000.0, "2": 250_000.0, "3": 250_000.0}
|
||||
base = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
ob, oc = overlay_rows(ClientDrivers(ai_tokens_annual=250_000))
|
||||
got = compute_summary(ob, oc, 0.10)
|
||||
assert got["costs_pv"] - base["costs_pv"] == pytest.approx(621_713.00, abs=1)
|
||||
assert got["benefits_pv"] == pytest.approx(base["benefits_pv"], abs=0.01)
|
||||
|
||||
|
||||
def test_drivers_frozen_and_rows_are_copies():
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
COMPOSITE.agents_fte = 1 # type: ignore[misc]
|
||||
ob, oc = overlay_rows(COMPOSITE)
|
||||
ob[0]["year_values"]["1"] = -1
|
||||
oc[0]["year_values"]["1"] = -1
|
||||
assert BENEFITS_VERBATIM[0]["year_values"]["1"] == 680_000
|
||||
assert COSTS_VERBATIM[0]["year_values"]["1"] == 840_000
|
||||
74
studies/202512_TEI_Genesys_CX_Cloud/tests/test_scenarios.py
Normal file
74
studies/202512_TEI_Genesys_CX_Cloud/tests/test_scenarios.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Scenario pins — hand-checked composite results per scenario, clamp
|
||||
behaviour, and copy semantics."""
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFITS_VERBATIM,
|
||||
COSTS_VERBATIM,
|
||||
SCENARIOS,
|
||||
apply_scenario,
|
||||
compute_summary,
|
||||
)
|
||||
|
||||
|
||||
def _summary(scenario):
|
||||
return compute_summary(
|
||||
apply_scenario(BENEFITS_VERBATIM, scenario),
|
||||
apply_scenario(COSTS_VERBATIM, scenario),
|
||||
0.10,
|
||||
)
|
||||
|
||||
|
||||
def test_scenario_definitions():
|
||||
assert SCENARIOS == {
|
||||
"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 test_moderate_is_identity():
|
||||
got = _summary("moderate")
|
||||
want = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
assert got["benefits_pv"] == pytest.approx(want["benefits_pv"], abs=0.01)
|
||||
assert got["costs_pv"] == pytest.approx(want["costs_pv"], abs=0.01)
|
||||
|
||||
|
||||
def test_conservative_pins():
|
||||
s = _summary("conservative")
|
||||
assert s["benefits_pv"] == pytest.approx(10_543_493.91, abs=1)
|
||||
assert s["costs_pv"] == pytest.approx(3_026_631.40, abs=1)
|
||||
assert s["npv"] == pytest.approx(7_516_862.51, abs=1)
|
||||
assert s["roi_pct"] == pytest.approx(248.36, abs=0.01)
|
||||
assert s["payback_months"] == pytest.approx(3.464, abs=0.001)
|
||||
|
||||
|
||||
def test_aggressive_pins():
|
||||
s = _summary("aggressive")
|
||||
assert s["benefits_pv"] == pytest.approx(18_021_962.25, abs=1)
|
||||
assert s["costs_pv"] == pytest.approx(4_883_285.09, abs=1)
|
||||
assert s["npv"] == pytest.approx(13_138_677.16, abs=1)
|
||||
assert s["roi_pct"] == pytest.approx(269.05, abs=0.01)
|
||||
assert s["payback_months"] == pytest.approx(3.294, abs=0.001)
|
||||
|
||||
|
||||
def test_risk_delta_clamps_at_zero():
|
||||
"""Conservative subtracts 0.10 from cost risk; every cost rf clamps to 0
|
||||
(licenses 0.05, implementation 0.10, ongoing 0.10, tokens 0.0)."""
|
||||
rows = apply_scenario(COSTS_VERBATIM, "conservative")
|
||||
assert all(r["risk_adjustment"] == 0.0 for r in rows)
|
||||
impl = next(r for r in rows if r["field_key"] == "implementation")
|
||||
assert impl["initial"] == pytest.approx(1_190_000 * 0.80) # adoption scales initial
|
||||
|
||||
|
||||
def test_unknown_scenario_raises():
|
||||
with pytest.raises(KeyError):
|
||||
apply_scenario(BENEFITS_VERBATIM, "wildly_optimistic")
|
||||
|
||||
|
||||
def test_inputs_not_mutated():
|
||||
apply_scenario(BENEFITS_VERBATIM, "aggressive")
|
||||
apply_scenario(COSTS_VERBATIM, "conservative")
|
||||
assert BENEFITS_VERBATIM[0]["year_values"]["1"] == 680_000
|
||||
assert COSTS_VERBATIM[1]["initial"] == 1_190_000
|
||||
15
studies/202512_TEI_Genesys_CX_Cloud/tests/test_staging.py
Normal file
15
studies/202512_TEI_Genesys_CX_Cloud/tests/test_staging.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
|
||||
|
||||
from teicalc import staging
|
||||
|
||||
|
||||
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
|
||||
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
Reference in New Issue
Block a user